[
  {
    "path": ".dockerignore",
    "content": "# Git and GitHub folders\n.git/*\n.github/*\n\n# Docker and CI/CD related files\ndocker-compose.yml\n.dockerignore\n.gitignore\n.goreleaser.yml\nDockerfile\n\n# Documentation and license\ndocs/*\nREADME.md\nREADME_CN.md\nLICENSE\n\n# Runtime data folders (should be mounted as volumes)\nauths/*\nlogs/*\nconv/*\nconfig.yaml\n\n# Development/editor\nbin/*\n.vscode/*\n.claude/*\n.codex/*\n.gemini/*\n.serena/*\n.agent/*\n.agents/*\n.opencode/*\n.idea/*\n.bmad/*\n_bmad/*\n_bmad-output/*\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [router-for-me]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is it a request payload issue?**\n[  ] Yes, this is a request payload issue. I am using a client/cURL to send a request payload, but I received an unexpected error.\n[  ] No, it's another issue.\n\n**If it's a request payload issue, you MUST know**\nOur team doesn't have any GODs or ORACLEs or MIND READERs. Please make sure to attach the request log or curl payload.\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**CLI Type**\nWhat type of CLI account do you use?  (gemini-cli, gemini, codex, claude code or openai-compatibility)\n\n**Model Name**\nWhat model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)\n\n**LLM Client**\nWhat LLM Client are you using? (example: roo-code, cline, claude code, etc.)\n\n**Request Information**\nThe best way is to paste the cURL command of the HTTP request here.\nAlternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**OS Type**\n - OS: [e.g. macOS]\n - Version [e.g. 15.6.0]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: docker-image\n\non:\n  push:\n    tags:\n      - v*\n\nenv:\n  APP_NAME: CLIProxyAPI\n  DOCKERHUB_REPO: eceasy/cli-proxy-api\n\njobs:\n  docker_amd64:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Refresh models catalog\n        run: |\n          git fetch --depth 1 https://github.com/router-for-me/models.git main\n          git show FETCH_HEAD:models.json > internal/registry/models/models.json\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      - name: Generate Build Metadata\n        run: |\n          echo \"VERSION=${GITHUB_REF_NAME}\" >> $GITHUB_ENV\n          echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV\n          echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV\n      - name: Build and push (amd64)\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/amd64\n          push: true\n          build-args: |\n            VERSION=${{ env.VERSION }}\n            COMMIT=${{ env.COMMIT }}\n            BUILD_DATE=${{ env.BUILD_DATE }}\n          tags: |\n            ${{ env.DOCKERHUB_REPO }}:latest-amd64\n            ${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}-amd64\n\n  docker_arm64:\n    runs-on: ubuntu-24.04-arm\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Refresh models catalog\n        run: |\n          git fetch --depth 1 https://github.com/router-for-me/models.git main\n          git show FETCH_HEAD:models.json > internal/registry/models/models.json\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      - name: Generate Build Metadata\n        run: |\n          echo \"VERSION=${GITHUB_REF_NAME}\" >> $GITHUB_ENV\n          echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV\n          echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV\n      - name: Build and push (arm64)\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/arm64\n          push: true\n          build-args: |\n            VERSION=${{ env.VERSION }}\n            COMMIT=${{ env.COMMIT }}\n            BUILD_DATE=${{ env.BUILD_DATE }}\n          tags: |\n            ${{ env.DOCKERHUB_REPO }}:latest-arm64\n            ${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}-arm64\n\n  docker_manifest:\n    runs-on: ubuntu-latest\n    needs:\n      - docker_amd64\n      - docker_arm64\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      - name: Generate Build Metadata\n        run: |\n          echo \"VERSION=${GITHUB_REF_NAME}\" >> $GITHUB_ENV\n          echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV\n          echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV\n      - name: Create and push multi-arch manifests\n        run: |\n          docker buildx imagetools create \\\n            --tag \"${DOCKERHUB_REPO}:latest\" \\\n            \"${DOCKERHUB_REPO}:latest-amd64\" \\\n            \"${DOCKERHUB_REPO}:latest-arm64\"\n          docker buildx imagetools create \\\n            --tag \"${DOCKERHUB_REPO}:${VERSION}\" \\\n            \"${DOCKERHUB_REPO}:${VERSION}-amd64\" \\\n            \"${DOCKERHUB_REPO}:${VERSION}-arm64\"\n      - name: Cleanup temporary tags\n        continue-on-error: true\n        env:\n          DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n          DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n        run: |\n          set -euo pipefail\n          namespace=\"${DOCKERHUB_REPO%%/*}\"\n          repo_name=\"${DOCKERHUB_REPO#*/}\"\n\n          token=\"$(\n            curl -fsSL \\\n              -H 'Content-Type: application/json' \\\n              -d \"{\\\"username\\\":\\\"${DOCKERHUB_USERNAME}\\\",\\\"password\\\":\\\"${DOCKERHUB_TOKEN}\\\"}\" \\\n              'https://hub.docker.com/v2/users/login/' \\\n              | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"token\"])'\n          )\"\n\n          delete_tag() {\n            local tag=\"$1\"\n            local url=\"https://hub.docker.com/v2/repositories/${namespace}/${repo_name}/tags/${tag}/\"\n            local http_code\n            http_code=\"$(curl -sS -o /dev/null -w \"%{http_code}\" -X DELETE -H \"Authorization: JWT ${token}\" \"${url}\" || true)\"\n            if [ \"${http_code}\" = \"204\" ] || [ \"${http_code}\" = \"404\" ]; then\n              echo \"Docker Hub tag removed (or missing): ${DOCKERHUB_REPO}:${tag} (HTTP ${http_code})\"\n              return 0\n            fi\n            echo \"Docker Hub tag delete failed: ${DOCKERHUB_REPO}:${tag} (HTTP ${http_code})\"\n            return 0\n          }\n\n          delete_tag \"latest-amd64\"\n          delete_tag \"latest-arm64\"\n          delete_tag \"${VERSION}-amd64\"\n          delete_tag \"${VERSION}-arm64\"\n"
  },
  {
    "path": ".github/workflows/pr-path-guard.yml",
    "content": "name: translator-path-guard\n\non:\n  pull_request:\n    types:\n      - opened\n      - synchronize\n      - reopened\n\njobs:\n  ensure-no-translator-changes:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Detect internal/translator changes\n        id: changed-files\n        uses: tj-actions/changed-files@v45\n        with:\n          files: |\n            internal/translator/**\n      - name: Fail when restricted paths change\n        if: steps.changed-files.outputs.any_changed == 'true'\n        run: |\n          echo \"Changes under internal/translator are not allowed in pull requests.\"\n          echo \"You need to create an issue for our maintenance team to make the necessary changes.\"\n          exit 1\n"
  },
  {
    "path": ".github/workflows/pr-test-build.yml",
    "content": "name: pr-test-build\n\non:\n  pull_request:\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Refresh models catalog\n        run: |\n          git fetch --depth 1 https://github.com/router-for-me/models.git main\n          git show FETCH_HEAD:models.json > internal/registry/models/models.json\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n          cache: true\n      - name: Build\n        run: |\n          go build -o test-output ./cmd/server\n          rm -f test-output\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: goreleaser\n\non:\n  push:\n    # run only against tags\n    tags:\n      - '*'\n\npermissions:\n  contents: write\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Refresh models catalog\n        run: |\n          git fetch --depth 1 https://github.com/router-for-me/models.git main\n          git show FETCH_HEAD:models.json > internal/registry/models/models.json\n      - run: git fetch --force --tags\n      - uses: actions/setup-go@v4\n        with:\n          go-version: '>=1.26.0'\n          cache: true\n      - name: Generate Build Metadata\n        run: |\n          echo \"VERSION=${GITHUB_REF_NAME}\" >> $GITHUB_ENV\n          echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV\n          echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV\n      - uses: goreleaser/goreleaser-action@v4\n        with:\n          distribution: goreleaser\n          version: latest\n          args: release --clean --skip=validate\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          VERSION: ${{ env.VERSION }}\n          COMMIT: ${{ env.COMMIT }}\n          BUILD_DATE: ${{ env.BUILD_DATE }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries\ncli-proxy-api\n*.exe\n\n# Configuration\nconfig.yaml\n.env\n\n# Generated content\nbin/*\nlogs/*\nconv/*\ntemp/*\nrefs/*\n\n# Storage backends\npgstore/*\ngitstore/*\nobjectstore/*\n\n# Static assets\nstatic/*\n\n# Authentication data\nauths/*\n!auths/.gitkeep\n\n# Documentation\ndocs/*\nAGENTS.md\nCLAUDE.md\nGEMINI.md\n\n# Tooling metadata\n.vscode/*\n.codex/*\n.claude/*\n.gemini/*\n.serena/*\n.agent/*\n.agents/*\n.agents/*\n.opencode/*\n.idea/*\n.bmad/*\n_bmad/*\n_bmad-output/*\n\n# macOS\n.DS_Store\n._*\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "version: 2\n\nbuilds:\n  - id: \"cli-proxy-api\"\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - windows\n      - darwin\n    goarch:\n      - amd64\n      - arm64\n    main: ./cmd/server/\n    binary: cli-proxy-api\n    ldflags:\n      - -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'\narchives:\n  - id: \"cli-proxy-api\"\n    format: tar.gz\n    format_overrides:\n      - goos: windows\n        format: zip\n    files:\n      - LICENSE\n      - README.md\n      - README_CN.md\n      - config.example.yaml\n\nchecksum:\n  name_template: 'checksums.txt'\n\nsnapshot:\n  name_template: \"{{ incpatch .Version }}-next\"\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - '^docs:'\n      - '^test:'\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.26-alpine AS builder\n\nWORKDIR /app\n\nCOPY go.mod go.sum ./\n\nRUN go mod download\n\nCOPY . .\n\nARG VERSION=dev\nARG COMMIT=none\nARG BUILD_DATE=unknown\n\nRUN CGO_ENABLED=0 GOOS=linux go build -ldflags=\"-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'\" -o ./CLIProxyAPI ./cmd/server/\n\nFROM alpine:3.22.0\n\nRUN apk add --no-cache tzdata\n\nRUN mkdir /CLIProxyAPI\n\nCOPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI\n\nCOPY config.example.yaml /CLIProxyAPI/config.example.yaml\n\nWORKDIR /CLIProxyAPI\n\nEXPOSE 8317\n\nENV TZ=Asia/Shanghai\n\nRUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo \"${TZ}\" > /etc/timezone\n\nCMD [\"./CLIProxyAPI\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025-2005.9 Luis Pater\nCopyright (c) 2025.9-present Router-For.ME\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# CLI Proxy API\n\nEnglish | [中文](README_CN.md)\n\nA proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.\n\nIt now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.\n\nSo you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.\n\n## Sponsor\n\n[![z.ai](https://assets.router-for.me/english-5-0.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\nThis project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.\n\nGLM CODING PLAN is a subscription service designed for AI coding, starting at just $10/month. It provides access to their flagship GLM-4.7 & （GLM-5 Only Available  for Pro Users）model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.\n\nGet 10% OFF GLM CODING PLAN：https://z.ai/subscribe?ic=8JVLJQFSKB\n\n---\n\n<table>\n<tbody>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cliproxyapi\"><img src=\"./assets/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href=\"https://www.packyapi.com/register?aff=cliproxyapi\">this link</a> and enter the \"cliproxyapi\" promo code during recharge to get 10% off.</td>\n</tr>\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=TJNAIF\"><img src=\"./assets/aicodemirror.png\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href=\"https://www.aicodemirror.com/register?invitecode=TJNAIF\">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>\n</tr>\n</tbody>\n</table>\n\n## Overview\n\n- OpenAI/Gemini/Claude compatible API endpoints for CLI models\n- OpenAI Codex support (GPT models) via OAuth login\n- Claude Code support via OAuth login\n- Qwen Code support via OAuth login\n- iFlow support via OAuth login\n- Amp CLI and IDE extensions support with provider routing\n- Streaming and non-streaming responses\n- Function calling/tools support\n- Multimodal input support (text and images)\n- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)\n- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)\n- Generative Language API Key support\n- AI Studio Build multi-account load balancing\n- Gemini CLI multi-account load balancing\n- Claude Code multi-account load balancing\n- Qwen Code multi-account load balancing\n- iFlow multi-account load balancing\n- OpenAI Codex multi-account load balancing\n- OpenAI-compatible upstream providers via config (e.g., OpenRouter)\n- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)\n\n## Getting Started\n\nCLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)\n\n## Management API\n\nsee [MANAGEMENT_API.md](https://help.router-for.me/management/api)\n\n## Amp CLI Support\n\nCLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:\n\n- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)\n- Management proxy for OAuth authentication and account features\n- Smart model fallback with automatic routing\n- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)\n- Security-first design with localhost-only management endpoints\n\n**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**\n\n## SDK Docs\n\n- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)\n- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)\n- Access: [docs/sdk-access.md](docs/sdk-access.md)\n- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)\n- Custom Provider Example: `examples/custom-provider`\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/amazing-feature`)\n3. Commit your changes (`git commit -m 'Add some amazing feature'`)\n4. Push to the branch (`git push origin feature/amazing-feature`)\n5. Open a Pull Request\n\n## Who is with us?\n\nThose projects are based on CLIProxyAPI:\n\n### [vibeproxy](https://github.com/automazeio/vibeproxy)\n\nNative macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed\n\n### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)\n\nBrowser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed\n\n### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)\n\nCLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed\n\n### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)\n\nNative macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.\n\n### [Quotio](https://github.com/nguyenphutrong/quotio)\n\nNative macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.\n\n### [CodMate](https://github.com/loocor/CodMate)\n\nNative macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.\n\n### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)\n\nWindows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.\n\n### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)\n\nVSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.\n\n### [ZeroLimit](https://github.com/0xtbug/zero-limit)\n\nWindows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed.\n\n### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)\n\nA lightweight web admin panel for CLIProxyAPI with health checks, resource monitoring, real-time logs, auto-update, request statistics and pricing display. Supports one-click installation and systemd service.\n\n### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)\n\nA Windows tray application implemented using PowerShell scripts, without relying on any third-party libraries. The main features include: automatic creation of shortcuts, silent running, password management, channel switching (Main / Plus), and automatic downloading and updating.\n\n### [霖君](https://github.com/wangdabaoqq/LinJun)\n\n霖君 is a cross-platform desktop application for managing AI programming assistants, supporting macOS, Windows, and Linux systems. Unified management of Claude Code, Gemini CLI, OpenAI Codex, Qwen Code, and other AI coding tools, with local proxy for multi-account quota tracking and one-click configuration.\n\n### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)\n\nA modern web-based management dashboard for CLIProxyAPI built with Next.js, React, and PostgreSQL. Features real-time log streaming, structured configuration editing, API key management, OAuth provider integration for Claude/Gemini/Codex, usage analytics, container management, and config sync with OpenCode via companion plugin - no manual YAML editing needed.\n\n### [All API Hub](https://github.com/qixing-jk/all-api-hub)\n\nBrowser extension for one-stop management of New API-compatible relay site accounts, featuring balance and usage dashboards, auto check-in, one-click key export to common apps, in-page API availability testing, and channel/model sync and redirection. It integrates with CLIProxyAPI through the Management API for one-click provider import and config sync.\n\n### [Shadow AI](https://github.com/HEUDavid/shadow-ai)\n\nShadow AI is an AI assistant tool designed specifically for restricted environments. It provides a stealthy operation\nmode without windows or traces, and enables cross-device AI Q&A interaction and control via the local area network (\nLAN). Essentially, it is an automated collaboration layer of \"screen/audio capture + AI inference + low-friction delivery\",\nhelping users to immersively use AI assistants across applications on controlled devices or in restricted environments.\n\n> [!NOTE]  \n> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.\n\n## More choices\n\nThose projects are ports of CLIProxyAPI or inspired by it:\n\n### [9Router](https://github.com/decolua/9router)\n\nA Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.\n\n### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)\n\nNever stop coding. Smart routing to FREE & low-cost AI models with automatic fallback.\n\nOmniRoute is an AI gateway for multi-provider LLMs: an OpenAI-compatible endpoint with smart routing, load balancing, retries, and fallbacks. Add policies, rate limits, caching, and observability for reliable, cost-aware inference.\n\n> [!NOTE]  \n> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"
  },
  {
    "path": "README_CN.md",
    "content": "# CLI 代理 API\n\n[English](README.md) | 中文\n\n一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。\n\n现已支持通过 OAuth 登录接入 OpenAI Codex（GPT 系列）和 Claude Code。\n\n您可以使用本地或多账户的CLI方式，通过任何与 OpenAI（包括Responses）/Gemini/Claude 兼容的客户端和SDK进行访问。\n\n## 赞助商\n\n[![bigmodel.cn](https://assets.router-for.me/chinese-5-0.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)\n\n本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。\n\nGLM CODING PLAN 是专为AI编码打造的订阅套餐，每月最低仅需20元，即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7（受限于算力，目前仅限Pro用户开放），为开发者提供顶尖的编码体验。\n\n智谱AI为本产品提供了特别优惠，使用以下链接购买可以享受九折优惠：https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\n\n---\n\n<table>\n<tbody>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cliproxyapi\"><img src=\"./assets/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>感谢 PackyCode 对本项目的赞助！PackyCode 是一家可靠高效的 API 中转服务商，提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠：使用<a href=\"https://www.packyapi.com/register?aff=cliproxyapi\">此链接</a>注册，并在充值时输入 \"cliproxyapi\" 优惠码即可享受九折优惠。</td>\n</tr>\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=TJNAIF\"><img src=\"./assets/aicodemirror.png\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>感谢 AICodeMirror 赞助了本项目！AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务，支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折，充值更有折上折！AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利，通过<a href=\"https://www.aicodemirror.com/register?invitecode=TJNAIF\">此链接</a>注册的用户，可享受首充8折，企业客户最高可享 7.5 折！</td>\n</tr>\n</tbody>\n</table>\n\n\n## 功能特性\n\n- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点\n- 新增 OpenAI Codex（GPT 系列）支持（OAuth 登录）\n- 新增 Claude Code 支持（OAuth 登录）\n- 新增 Qwen Code 支持（OAuth 登录）\n- 新增 iFlow 支持（OAuth 登录）\n- 支持流式与非流式响应\n- 函数调用/工具支持\n- 多模态输入（文本、图片）\n- 多账户支持与轮询负载均衡（Gemini、OpenAI、Claude、Qwen 与 iFlow）\n- 简单的 CLI 身份验证流程（Gemini、OpenAI、Claude、Qwen 与 iFlow）\n- 支持 Gemini AIStudio API 密钥\n- 支持 AI Studio Build 多账户轮询\n- 支持 Gemini CLI 多账户轮询\n- 支持 Claude Code 多账户轮询\n- 支持 Qwen Code 多账户轮询\n- 支持 iFlow 多账户轮询\n- 支持 OpenAI Codex 多账户轮询\n- 通过配置接入上游 OpenAI 兼容提供商（例如 OpenRouter）\n- 可复用的 Go SDK（见 `docs/sdk-usage_CN.md`）\n\n## 新手入门\n\nCLIProxyAPI 用户手册： [https://help.router-for.me/](https://help.router-for.me/cn/)\n\n## 管理 API 文档\n\n请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)\n\n## Amp CLI 支持\n\nCLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持，可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具：\n\n- 提供商路由别名，兼容 Amp 的 API 路径模式（`/api/provider/{provider}/v1...`）\n- 管理代理，处理 OAuth 认证和账号功能\n- 智能模型回退与自动路由\n- 以安全为先的设计，管理端点仅限 localhost\n\n**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**\n\n## SDK 文档\n\n- 使用文档：[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)\n- 高级（执行器与翻译器）：[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)\n- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)\n- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)\n- 自定义 Provider 示例：`examples/custom-provider`\n\n## 贡献\n\n欢迎贡献！请随时提交 Pull Request。\n\n1. Fork 仓库\n2. 创建您的功能分支（`git checkout -b feature/amazing-feature`）\n3. 提交您的更改（`git commit -m 'Add some amazing feature'`）\n4. 推送到分支（`git push origin feature/amazing-feature`）\n5. 打开 Pull Request\n\n## 谁与我们在一起？\n\n这些项目基于 CLIProxyAPI:\n\n### [vibeproxy](https://github.com/automazeio/vibeproxy)\n\n一个原生 macOS 菜单栏应用，让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具，无需 API 密钥。\n\n### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)\n\n一款基于浏览器的 SRT 字幕翻译工具，可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能，无需 API 密钥。\n\n### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)\n\nCLI 封装器，用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型（Gemini, Codex, Antigravity），无需 API 密钥。\n\n### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)\n\n基于 macOS 平台的原生 CLIProxyAPI GUI：配置供应商、模型映射以及OAuth端点，无需 API 密钥。\n\n### [Quotio](https://github.com/nguyenphutrong/quotio)\n\n原生 macOS 菜单栏应用，统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅，提供实时配额追踪和智能自动故障转移，支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具，无需 API 密钥。\n\n### [CodMate](https://github.com/loocor/CodMate)\n\n原生 macOS SwiftUI 应用，用于管理 CLI AI 会话（Claude Code、Codex、Gemini CLI），提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证，支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。\n\n### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)\n\n原生 Windows CLIProxyAPI 分支，集成 TUI、系统托盘及多服务商 OAuth 认证，专为 AI 编程工具打造，无需 API 密钥。\n\n### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)\n\n一款 VSCode 扩展，提供了在 VSCode 中快速切换 Claude Code 模型的功能，内置 CLIProxyAPI 作为其后端，支持后台自动启动和关闭。\n\n### [ZeroLimit](https://github.com/0xtbug/zero-limit)\n\nWindows 桌面应用，基于 Tauri + React 构建，用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪，提供实时仪表盘、系统托盘集成和一键代理控制，无需 API 密钥。\n\n### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)\n\n面向 CLIProxyAPI 的 Web 管理面板，提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示，支持一键安装与 systemd 服务。\n\n### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)\n\nWindows 托盘应用，基于 PowerShell 脚本实现，不依赖任何第三方库。主要功能包括：自动创建快捷方式、静默运行、密码管理、通道切换（Main / Plus）以及自动下载与更新。\n\n### [霖君](https://github.com/wangdabaoqq/LinJun)\n\n霖君是一款用于管理AI编程助手的跨平台桌面应用，支持macOS、Windows、Linux系统。统一管理Claude Code、Gemini CLI、OpenAI Codex、Qwen Code等AI编程工具，本地代理实现多账户配额跟踪和一键配置。\n\n### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)\n\n一个面向 CLIProxyAPI 的现代化 Web 管理仪表盘，基于 Next.js、React 和 PostgreSQL 构建。支持实时日志流、结构化配置编辑、API Key 管理、Claude/Gemini/Codex 的 OAuth 提供方集成、使用量分析、容器管理，并可通过配套插件与 OpenCode 同步配置，无需手动编辑 YAML。\n\n### [All API Hub](https://github.com/qixing-jk/all-api-hub)\n\n用于一站式管理 New API 兼容中转站账号的浏览器扩展，提供余额与用量看板、自动签到、密钥一键导出到常用应用、网页内 API 可用性测试，以及渠道与模型同步和重定向。支持通过 CLIProxyAPI Management API 一键导入 Provider 与同步配置。\n\n### [Shadow AI](https://github.com/HEUDavid/shadow-ai)\n\nShadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口、无痕迹的隐蔽运行方式，并通过局域网实现跨设备的 AI 问答交互与控制。本质上是一个「屏幕/音频采集 + AI 推理 + 低摩擦投送」的自动化协作层，帮助用户在受控设备/受限环境下沉浸式跨应用地使用 AI 助手。\n\n> [!NOTE]  \n> 如果你开发了基于 CLIProxyAPI 的项目，请提交一个 PR（拉取请求）将其添加到此列表中。\n\n## 更多选择\n\n以下项目是 CLIProxyAPI 的移植版或受其启发：\n\n### [9Router](https://github.com/decolua/9router)\n\n基于 Next.js 的实现，灵感来自 CLIProxyAPI，易于安装使用；自研格式转换（OpenAI/Claude/Gemini/Ollama）、组合系统与自动回退、多账户管理（指数退避）、Next.js Web 控制台，并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具，无需 API 密钥。\n\n### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)\n\n代码不止，创新不停。智能路由至免费及低成本 AI 模型，并支持自动故障转移。\n\nOmniRoute 是一个面向多供应商大语言模型的 AI 网关：它提供兼容 OpenAI 的端点，具备智能路由、负载均衡、重试及回退机制。通过添加策略、速率限制、缓存和可观测性，确保推理过程既可靠又具备成本意识。\n\n> [!NOTE]  \n> 如果你开发了 CLIProxyAPI 的移植或衍生项目，请提交 PR 将其添加到此列表中。\n\n## 许可证\n\n此项目根据 MIT 许可证授权 - 有关详细信息，请参阅 [LICENSE](LICENSE) 文件。\n\n## 写给所有中国网友的\n\nQQ 群：188637136\n\n或\n\nTelegram 群：https://t.me/CLIProxyAPI\n"
  },
  {
    "path": "auths/.gitkeep",
    "content": ""
  },
  {
    "path": "cmd/fetch_antigravity_models/main.go",
    "content": "// Command fetch_antigravity_models connects to the Antigravity API using the\n// stored auth credentials and saves the dynamically fetched model list to a\n// JSON file for inspection or offline use.\n//\n// Usage:\n//\n//\tgo run ./cmd/fetch_antigravity_models [flags]\n//\n// Flags:\n//\n//\t--auths-dir <path>  Directory containing auth JSON files (default: \"auths\")\n//\t--output    <path>  Output JSON file path             (default: \"antigravity_models.json\")\n//\t--pretty            Pretty-print the output JSON      (default: true)\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\tsdkauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\nconst (\n\tantigravityBaseURLDaily        = \"https://daily-cloudcode-pa.googleapis.com\"\n\tantigravitySandboxBaseURLDaily = \"https://daily-cloudcode-pa.sandbox.googleapis.com\"\n\tantigravityBaseURLProd         = \"https://cloudcode-pa.googleapis.com\"\n\tantigravityModelsPath          = \"/v1internal:fetchAvailableModels\"\n)\n\nfunc init() {\n\tlogging.SetupBaseLogger()\n\tlog.SetLevel(log.InfoLevel)\n}\n\n// modelOutput wraps the fetched model list with fetch metadata.\ntype modelOutput struct {\n\tModels []modelEntry `json:\"models\"`\n}\n\n// modelEntry contains only the fields we want to keep for static model definitions.\ntype modelEntry struct {\n\tID                  string `json:\"id\"`\n\tObject              string `json:\"object\"`\n\tOwnedBy             string `json:\"owned_by\"`\n\tType                string `json:\"type\"`\n\tDisplayName         string `json:\"display_name\"`\n\tName                string `json:\"name\"`\n\tDescription         string `json:\"description\"`\n\tContextLength       int    `json:\"context_length,omitempty\"`\n\tMaxCompletionTokens int    `json:\"max_completion_tokens,omitempty\"`\n}\n\nfunc main() {\n\tvar authsDir string\n\tvar outputPath string\n\tvar pretty bool\n\n\tflag.StringVar(&authsDir, \"auths-dir\", \"auths\", \"Directory containing auth JSON files\")\n\tflag.StringVar(&outputPath, \"output\", \"antigravity_models.json\", \"Output JSON file path\")\n\tflag.BoolVar(&pretty, \"pretty\", true, \"Pretty-print the output JSON\")\n\tflag.Parse()\n\n\t// Resolve relative paths against the working directory.\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error: cannot get working directory: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tif !filepath.IsAbs(authsDir) {\n\t\tauthsDir = filepath.Join(wd, authsDir)\n\t}\n\tif !filepath.IsAbs(outputPath) {\n\t\toutputPath = filepath.Join(wd, outputPath)\n\t}\n\n\tfmt.Printf(\"Scanning auth files in: %s\\n\", authsDir)\n\n\t// Load all auth records from the directory.\n\tfileStore := sdkauth.NewFileTokenStore()\n\tfileStore.SetBaseDir(authsDir)\n\n\tctx := context.Background()\n\tauths, err := fileStore.List(ctx)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error: failed to list auth files: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tif len(auths) == 0 {\n\t\tfmt.Fprintf(os.Stderr, \"error: no auth files found in %s\\n\", authsDir)\n\t\tos.Exit(1)\n\t}\n\n\t// Find the first enabled antigravity auth.\n\tvar chosen *coreauth.Auth\n\tfor _, a := range auths {\n\t\tif a == nil || a.Disabled {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.EqualFold(strings.TrimSpace(a.Provider), \"antigravity\") {\n\t\t\tchosen = a\n\t\t\tbreak\n\t\t}\n\t}\n\tif chosen == nil {\n\t\tfmt.Fprintf(os.Stderr, \"error: no enabled antigravity auth found in %s\\n\", authsDir)\n\t\tos.Exit(1)\n\t}\n\n\tfmt.Printf(\"Using auth: id=%s label=%s\\n\", chosen.ID, chosen.Label)\n\n\t// Fetch models from the upstream Antigravity API.\n\tfmt.Println(\"Fetching Antigravity model list from upstream...\")\n\n\tfetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)\n\tdefer cancel()\n\n\tmodels := fetchModels(fetchCtx, chosen)\n\tif len(models) == 0 {\n\t\tfmt.Fprintln(os.Stderr, \"warning: no models returned (API may be unavailable or token expired)\")\n\t} else {\n\t\tfmt.Printf(\"Fetched %d models.\\n\", len(models))\n\t}\n\n\t// Build the output payload.\n\tout := modelOutput{\n\t\tModels: models,\n\t}\n\n\t// Marshal to JSON.\n\tvar raw []byte\n\tif pretty {\n\t\traw, err = json.MarshalIndent(out, \"\", \"  \")\n\t} else {\n\t\traw, err = json.Marshal(out)\n\t}\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error: failed to marshal JSON: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif err = os.WriteFile(outputPath, raw, 0o644); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error: failed to write output file %s: %v\\n\", outputPath, err)\n\t\tos.Exit(1)\n\t}\n\n\tfmt.Printf(\"Model list saved to: %s\\n\", outputPath)\n}\n\nfunc fetchModels(ctx context.Context, auth *coreauth.Auth) []modelEntry {\n\taccessToken := metaStringValue(auth.Metadata, \"access_token\")\n\tif accessToken == \"\" {\n\t\tfmt.Fprintln(os.Stderr, \"error: no access token found in auth\")\n\t\treturn nil\n\t}\n\n\tbaseURLs := []string{antigravityBaseURLProd, antigravityBaseURLDaily, antigravitySandboxBaseURLDaily}\n\n\tfor _, baseURL := range baseURLs {\n\t\tmodelsURL := baseURL + antigravityModelsPath\n\n\t\tvar payload []byte\n\t\tif auth != nil && auth.Metadata != nil {\n\t\t\tif pid, ok := auth.Metadata[\"project_id\"].(string); ok && strings.TrimSpace(pid) != \"\" {\n\t\t\t\tpayload = []byte(fmt.Sprintf(`{\"project\": \"%s\"}`, strings.TrimSpace(pid)))\n\t\t\t}\n\t\t}\n\t\tif len(payload) == 0 {\n\t\t\tpayload = []byte(`{}`)\n\t\t}\n\n\t\thttpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, strings.NewReader(string(payload)))\n\t\tif errReq != nil {\n\t\t\tcontinue\n\t\t}\n\t\thttpReq.Close = true\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\t\thttpReq.Header.Set(\"User-Agent\", \"antigravity/1.19.6 darwin/arm64\")\n\n\t\thttpClient := &http.Client{Timeout: 30 * time.Second}\n\t\tif transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {\n\t\t\thttpClient.Transport = transport\n\t\t}\n\t\thttpResp, errDo := httpClient.Do(httpReq)\n\t\tif errDo != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\t\thttpResp.Body.Close()\n\t\tif errRead != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {\n\t\t\tcontinue\n\t\t}\n\n\t\tresult := gjson.GetBytes(bodyBytes, \"models\")\n\t\tif !result.Exists() {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar models []modelEntry\n\n\t\tfor originalName, modelData := range result.Map() {\n\t\t\tmodelID := strings.TrimSpace(originalName)\n\t\t\tif modelID == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Skip internal/experimental models\n\t\t\tswitch modelID {\n\t\t\tcase \"chat_20706\", \"chat_23310\", \"tab_flash_lite_preview\", \"tab_jump_flash_lite_preview\", \"gemini-2.5-flash-thinking\", \"gemini-2.5-pro\":\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdisplayName := modelData.Get(\"displayName\").String()\n\t\t\tif displayName == \"\" {\n\t\t\t\tdisplayName = modelID\n\t\t\t}\n\n\t\t\tentry := modelEntry{\n\t\t\t\tID:          modelID,\n\t\t\t\tObject:      \"model\",\n\t\t\t\tOwnedBy:     \"antigravity\",\n\t\t\t\tType:        \"antigravity\",\n\t\t\t\tDisplayName: displayName,\n\t\t\t\tName:        modelID,\n\t\t\t\tDescription: displayName,\n\t\t\t}\n\n\t\t\tif maxTok := modelData.Get(\"maxTokens\").Int(); maxTok > 0 {\n\t\t\t\tentry.ContextLength = int(maxTok)\n\t\t\t}\n\t\t\tif maxOut := modelData.Get(\"maxOutputTokens\").Int(); maxOut > 0 {\n\t\t\t\tentry.MaxCompletionTokens = int(maxOut)\n\t\t\t}\n\n\t\t\tmodels = append(models, entry)\n\t\t}\n\n\t\treturn models\n\t}\n\n\treturn nil\n}\n\nfunc metaStringValue(m map[string]interface{}, key string) string {\n\tif m == nil {\n\t\treturn \"\"\n\t}\n\tv, ok := m[key]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tswitch val := v.(type) {\n\tcase string:\n\t\treturn val\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "cmd/server/main.go",
    "content": "// Package main provides the entry point for the CLI Proxy API server.\n// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces\n// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.\npackage main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/joho/godotenv\"\n\tconfigaccess \"github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/store\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/tui\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/usage\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nvar (\n\tVersion           = \"dev\"\n\tCommit            = \"none\"\n\tBuildDate         = \"unknown\"\n\tDefaultConfigPath = \"\"\n)\n\n// init initializes the shared logger setup.\nfunc init() {\n\tlogging.SetupBaseLogger()\n\tbuildinfo.Version = Version\n\tbuildinfo.Commit = Commit\n\tbuildinfo.BuildDate = BuildDate\n}\n\n// main is the entry point of the application.\n// It parses command-line flags, loads configuration, and starts the appropriate\n// service based on the provided flags (login, codex-login, or server mode).\nfunc main() {\n\tfmt.Printf(\"CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\\n\", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)\n\n\t// Command-line flags to control the application's behavior.\n\tvar login bool\n\tvar codexLogin bool\n\tvar codexDeviceLogin bool\n\tvar claudeLogin bool\n\tvar qwenLogin bool\n\tvar iflowLogin bool\n\tvar iflowCookie bool\n\tvar noBrowser bool\n\tvar oauthCallbackPort int\n\tvar antigravityLogin bool\n\tvar kimiLogin bool\n\tvar projectID string\n\tvar vertexImport string\n\tvar configPath string\n\tvar password string\n\tvar tuiMode bool\n\tvar standalone bool\n\n\t// Define command-line flags for different operation modes.\n\tflag.BoolVar(&login, \"login\", false, \"Login Google Account\")\n\tflag.BoolVar(&codexLogin, \"codex-login\", false, \"Login to Codex using OAuth\")\n\tflag.BoolVar(&codexDeviceLogin, \"codex-device-login\", false, \"Login to Codex using device code flow\")\n\tflag.BoolVar(&claudeLogin, \"claude-login\", false, \"Login to Claude using OAuth\")\n\tflag.BoolVar(&qwenLogin, \"qwen-login\", false, \"Login to Qwen using OAuth\")\n\tflag.BoolVar(&iflowLogin, \"iflow-login\", false, \"Login to iFlow using OAuth\")\n\tflag.BoolVar(&iflowCookie, \"iflow-cookie\", false, \"Login to iFlow using Cookie\")\n\tflag.BoolVar(&noBrowser, \"no-browser\", false, \"Don't open browser automatically for OAuth\")\n\tflag.IntVar(&oauthCallbackPort, \"oauth-callback-port\", 0, \"Override OAuth callback port (defaults to provider-specific port)\")\n\tflag.BoolVar(&antigravityLogin, \"antigravity-login\", false, \"Login to Antigravity using OAuth\")\n\tflag.BoolVar(&kimiLogin, \"kimi-login\", false, \"Login to Kimi using OAuth\")\n\tflag.StringVar(&projectID, \"project_id\", \"\", \"Project ID (Gemini only, not required)\")\n\tflag.StringVar(&configPath, \"config\", DefaultConfigPath, \"Configure File Path\")\n\tflag.StringVar(&vertexImport, \"vertex-import\", \"\", \"Import Vertex service account key JSON file\")\n\tflag.StringVar(&password, \"password\", \"\", \"\")\n\tflag.BoolVar(&tuiMode, \"tui\", false, \"Start with terminal management UI\")\n\tflag.BoolVar(&standalone, \"standalone\", false, \"In TUI mode, start an embedded local server\")\n\n\tflag.CommandLine.Usage = func() {\n\t\tout := flag.CommandLine.Output()\n\t\t_, _ = fmt.Fprintf(out, \"Usage of %s\\n\", os.Args[0])\n\t\tflag.CommandLine.VisitAll(func(f *flag.Flag) {\n\t\t\tif f.Name == \"password\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"  -%s\", f.Name)\n\t\t\tname, unquoteUsage := flag.UnquoteUsage(f)\n\t\t\tif name != \"\" {\n\t\t\t\ts += \" \" + name\n\t\t\t}\n\t\t\tif len(s) <= 4 {\n\t\t\t\ts += \"\t\"\n\t\t\t} else {\n\t\t\t\ts += \"\\n    \"\n\t\t\t}\n\t\t\tif unquoteUsage != \"\" {\n\t\t\t\ts += unquoteUsage\n\t\t\t}\n\t\t\tif f.DefValue != \"\" && f.DefValue != \"false\" && f.DefValue != \"0\" {\n\t\t\t\ts += fmt.Sprintf(\" (default %s)\", f.DefValue)\n\t\t\t}\n\t\t\t_, _ = fmt.Fprint(out, s+\"\\n\")\n\t\t})\n\t}\n\n\t// Parse the command-line flags.\n\tflag.Parse()\n\n\t// Core application variables.\n\tvar err error\n\tvar cfg *config.Config\n\tvar isCloudDeploy bool\n\tvar (\n\t\tusePostgresStore     bool\n\t\tpgStoreDSN           string\n\t\tpgStoreSchema        string\n\t\tpgStoreLocalPath     string\n\t\tpgStoreInst          *store.PostgresStore\n\t\tuseGitStore          bool\n\t\tgitStoreRemoteURL    string\n\t\tgitStoreUser         string\n\t\tgitStorePassword     string\n\t\tgitStoreLocalPath    string\n\t\tgitStoreInst         *store.GitTokenStore\n\t\tgitStoreRoot         string\n\t\tuseObjectStore       bool\n\t\tobjectStoreEndpoint  string\n\t\tobjectStoreAccess    string\n\t\tobjectStoreSecret    string\n\t\tobjectStoreBucket    string\n\t\tobjectStoreLocalPath string\n\t\tobjectStoreInst      *store.ObjectTokenStore\n\t)\n\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\tlog.Errorf(\"failed to get working directory: %v\", err)\n\t\treturn\n\t}\n\n\t// Load environment variables from .env if present.\n\tif errLoad := godotenv.Load(filepath.Join(wd, \".env\")); errLoad != nil {\n\t\tif !errors.Is(errLoad, os.ErrNotExist) {\n\t\t\tlog.WithError(errLoad).Warn(\"failed to load .env file\")\n\t\t}\n\t}\n\n\tlookupEnv := func(keys ...string) (string, bool) {\n\t\tfor _, key := range keys {\n\t\t\tif value, ok := os.LookupEnv(key); ok {\n\t\t\t\tif trimmed := strings.TrimSpace(value); trimmed != \"\" {\n\t\t\t\t\treturn trimmed, true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn \"\", false\n\t}\n\twritableBase := util.WritablePath()\n\tif value, ok := lookupEnv(\"PGSTORE_DSN\", \"pgstore_dsn\"); ok {\n\t\tusePostgresStore = true\n\t\tpgStoreDSN = value\n\t}\n\tif usePostgresStore {\n\t\tif value, ok := lookupEnv(\"PGSTORE_SCHEMA\", \"pgstore_schema\"); ok {\n\t\t\tpgStoreSchema = value\n\t\t}\n\t\tif value, ok := lookupEnv(\"PGSTORE_LOCAL_PATH\", \"pgstore_local_path\"); ok {\n\t\t\tpgStoreLocalPath = value\n\t\t}\n\t\tif pgStoreLocalPath == \"\" {\n\t\t\tif writableBase != \"\" {\n\t\t\t\tpgStoreLocalPath = writableBase\n\t\t\t} else {\n\t\t\t\tpgStoreLocalPath = wd\n\t\t\t}\n\t\t}\n\t\tuseGitStore = false\n\t}\n\tif value, ok := lookupEnv(\"GITSTORE_GIT_URL\", \"gitstore_git_url\"); ok {\n\t\tuseGitStore = true\n\t\tgitStoreRemoteURL = value\n\t}\n\tif value, ok := lookupEnv(\"GITSTORE_GIT_USERNAME\", \"gitstore_git_username\"); ok {\n\t\tgitStoreUser = value\n\t}\n\tif value, ok := lookupEnv(\"GITSTORE_GIT_TOKEN\", \"gitstore_git_token\"); ok {\n\t\tgitStorePassword = value\n\t}\n\tif value, ok := lookupEnv(\"GITSTORE_LOCAL_PATH\", \"gitstore_local_path\"); ok {\n\t\tgitStoreLocalPath = value\n\t}\n\tif value, ok := lookupEnv(\"OBJECTSTORE_ENDPOINT\", \"objectstore_endpoint\"); ok {\n\t\tuseObjectStore = true\n\t\tobjectStoreEndpoint = value\n\t}\n\tif value, ok := lookupEnv(\"OBJECTSTORE_ACCESS_KEY\", \"objectstore_access_key\"); ok {\n\t\tobjectStoreAccess = value\n\t}\n\tif value, ok := lookupEnv(\"OBJECTSTORE_SECRET_KEY\", \"objectstore_secret_key\"); ok {\n\t\tobjectStoreSecret = value\n\t}\n\tif value, ok := lookupEnv(\"OBJECTSTORE_BUCKET\", \"objectstore_bucket\"); ok {\n\t\tobjectStoreBucket = value\n\t}\n\tif value, ok := lookupEnv(\"OBJECTSTORE_LOCAL_PATH\", \"objectstore_local_path\"); ok {\n\t\tobjectStoreLocalPath = value\n\t}\n\n\t// Check for cloud deploy mode only on first execution\n\t// Read env var name in uppercase: DEPLOY\n\tdeployEnv := os.Getenv(\"DEPLOY\")\n\tif deployEnv == \"cloud\" {\n\t\tisCloudDeploy = true\n\t}\n\n\t// Determine and load the configuration file.\n\t// Prefer the Postgres store when configured, otherwise fallback to git or local files.\n\tvar configFilePath string\n\tif usePostgresStore {\n\t\tif pgStoreLocalPath == \"\" {\n\t\t\tpgStoreLocalPath = wd\n\t\t}\n\t\tpgStoreLocalPath = filepath.Join(pgStoreLocalPath, \"pgstore\")\n\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tpgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{\n\t\t\tDSN:      pgStoreDSN,\n\t\t\tSchema:   pgStoreSchema,\n\t\t\tSpoolDir: pgStoreLocalPath,\n\t\t})\n\t\tcancel()\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"failed to initialize postgres token store: %v\", err)\n\t\t\treturn\n\t\t}\n\t\texamplePath := filepath.Join(wd, \"config.example.yaml\")\n\t\tctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)\n\t\tif errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {\n\t\t\tcancel()\n\t\t\tlog.Errorf(\"failed to bootstrap postgres-backed config: %v\", errBootstrap)\n\t\t\treturn\n\t\t}\n\t\tcancel()\n\t\tconfigFilePath = pgStoreInst.ConfigPath()\n\t\tcfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)\n\t\tif err == nil {\n\t\t\tcfg.AuthDir = pgStoreInst.AuthDir()\n\t\t\tlog.Infof(\"postgres-backed token store enabled, workspace path: %s\", pgStoreInst.WorkDir())\n\t\t}\n\t} else if useObjectStore {\n\t\tif objectStoreLocalPath == \"\" {\n\t\t\tif writableBase != \"\" {\n\t\t\t\tobjectStoreLocalPath = writableBase\n\t\t\t} else {\n\t\t\t\tobjectStoreLocalPath = wd\n\t\t\t}\n\t\t}\n\t\tobjectStoreRoot := filepath.Join(objectStoreLocalPath, \"objectstore\")\n\t\tresolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)\n\t\tuseSSL := true\n\t\tif strings.Contains(resolvedEndpoint, \"://\") {\n\t\t\tparsed, errParse := url.Parse(resolvedEndpoint)\n\t\t\tif errParse != nil {\n\t\t\t\tlog.Errorf(\"failed to parse object store endpoint %q: %v\", objectStoreEndpoint, errParse)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch strings.ToLower(parsed.Scheme) {\n\t\t\tcase \"http\":\n\t\t\t\tuseSSL = false\n\t\t\tcase \"https\":\n\t\t\t\tuseSSL = true\n\t\t\tdefault:\n\t\t\t\tlog.Errorf(\"unsupported object store scheme %q (only http and https are allowed)\", parsed.Scheme)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif parsed.Host == \"\" {\n\t\t\t\tlog.Errorf(\"object store endpoint %q is missing host information\", objectStoreEndpoint)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresolvedEndpoint = parsed.Host\n\t\t\tif parsed.Path != \"\" && parsed.Path != \"/\" {\n\t\t\t\tresolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, \"/\")\n\t\t\t}\n\t\t}\n\t\tresolvedEndpoint = strings.TrimRight(resolvedEndpoint, \"/\")\n\t\tobjCfg := store.ObjectStoreConfig{\n\t\t\tEndpoint:  resolvedEndpoint,\n\t\t\tBucket:    objectStoreBucket,\n\t\t\tAccessKey: objectStoreAccess,\n\t\t\tSecretKey: objectStoreSecret,\n\t\t\tLocalRoot: objectStoreRoot,\n\t\t\tUseSSL:    useSSL,\n\t\t\tPathStyle: true,\n\t\t}\n\t\tobjectStoreInst, err = store.NewObjectTokenStore(objCfg)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"failed to initialize object token store: %v\", err)\n\t\t\treturn\n\t\t}\n\t\texamplePath := filepath.Join(wd, \"config.example.yaml\")\n\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tif errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {\n\t\t\tcancel()\n\t\t\tlog.Errorf(\"failed to bootstrap object-backed config: %v\", errBootstrap)\n\t\t\treturn\n\t\t}\n\t\tcancel()\n\t\tconfigFilePath = objectStoreInst.ConfigPath()\n\t\tcfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)\n\t\tif err == nil {\n\t\t\tif cfg == nil {\n\t\t\t\tcfg = &config.Config{}\n\t\t\t}\n\t\t\tcfg.AuthDir = objectStoreInst.AuthDir()\n\t\t\tlog.Infof(\"object-backed token store enabled, bucket: %s\", objectStoreBucket)\n\t\t}\n\t} else if useGitStore {\n\t\tif gitStoreLocalPath == \"\" {\n\t\t\tif writableBase != \"\" {\n\t\t\t\tgitStoreLocalPath = writableBase\n\t\t\t} else {\n\t\t\t\tgitStoreLocalPath = wd\n\t\t\t}\n\t\t}\n\t\tgitStoreRoot = filepath.Join(gitStoreLocalPath, \"gitstore\")\n\t\tauthDir := filepath.Join(gitStoreRoot, \"auths\")\n\t\tgitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)\n\t\tgitStoreInst.SetBaseDir(authDir)\n\t\tif errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {\n\t\t\tlog.Errorf(\"failed to prepare git token store: %v\", errRepo)\n\t\t\treturn\n\t\t}\n\t\tconfigFilePath = gitStoreInst.ConfigPath()\n\t\tif configFilePath == \"\" {\n\t\t\tconfigFilePath = filepath.Join(gitStoreRoot, \"config\", \"config.yaml\")\n\t\t}\n\t\tif _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {\n\t\t\texamplePath := filepath.Join(wd, \"config.example.yaml\")\n\t\t\tif _, errExample := os.Stat(examplePath); errExample != nil {\n\t\t\t\tlog.Errorf(\"failed to find template config file: %v\", errExample)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {\n\t\t\t\tlog.Errorf(\"failed to bootstrap git-backed config: %v\", errCopy)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {\n\t\t\t\tlog.Errorf(\"failed to commit initial git-backed config: %v\", errCommit)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog.Infof(\"git-backed config initialized from template: %s\", configFilePath)\n\t\t} else if statErr != nil {\n\t\t\tlog.Errorf(\"failed to inspect git-backed config: %v\", statErr)\n\t\t\treturn\n\t\t}\n\t\tcfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)\n\t\tif err == nil {\n\t\t\tcfg.AuthDir = gitStoreInst.AuthDir()\n\t\t\tlog.Infof(\"git-backed token store enabled, repository path: %s\", gitStoreRoot)\n\t\t}\n\t} else if configPath != \"\" {\n\t\tconfigFilePath = configPath\n\t\tcfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)\n\t} else {\n\t\twd, err = os.Getwd()\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"failed to get working directory: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tconfigFilePath = filepath.Join(wd, \"config.yaml\")\n\t\tcfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)\n\t}\n\tif err != nil {\n\t\tlog.Errorf(\"failed to load config: %v\", err)\n\t\treturn\n\t}\n\tif cfg == nil {\n\t\tcfg = &config.Config{}\n\t}\n\n\t// In cloud deploy mode, check if we have a valid configuration\n\tvar configFileExists bool\n\tif isCloudDeploy {\n\t\tif info, errStat := os.Stat(configFilePath); errStat != nil {\n\t\t\t// Don't mislead: API server will not start until configuration is provided.\n\t\t\tlog.Info(\"Cloud deploy mode: No configuration file detected; standing by for configuration\")\n\t\t\tconfigFileExists = false\n\t\t} else if info.IsDir() {\n\t\t\tlog.Info(\"Cloud deploy mode: Config path is a directory; standing by for configuration\")\n\t\t\tconfigFileExists = false\n\t\t} else if cfg.Port == 0 {\n\t\t\t// LoadConfigOptional returns empty config when file is empty or invalid.\n\t\t\t// Config file exists but is empty or invalid; treat as missing config\n\t\t\tlog.Info(\"Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration\")\n\t\t\tconfigFileExists = false\n\t\t} else {\n\t\t\tlog.Info(\"Cloud deploy mode: Configuration file detected; starting service\")\n\t\t\tconfigFileExists = true\n\t\t}\n\t}\n\tusage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)\n\tcoreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)\n\n\tif err = logging.ConfigureLogOutput(cfg); err != nil {\n\t\tlog.Errorf(\"failed to configure log output: %v\", err)\n\t\treturn\n\t}\n\n\tlog.Infof(\"CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)\n\n\t// Set the log level based on the configuration.\n\tutil.SetLogLevel(cfg)\n\n\tif resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {\n\t\tlog.Errorf(\"failed to resolve auth directory: %v\", errResolveAuthDir)\n\t\treturn\n\t} else {\n\t\tcfg.AuthDir = resolvedAuthDir\n\t}\n\tmanagementasset.SetCurrentConfig(cfg)\n\n\t// Create login options to be used in authentication flows.\n\toptions := &cmd.LoginOptions{\n\t\tNoBrowser:    noBrowser,\n\t\tCallbackPort: oauthCallbackPort,\n\t}\n\n\t// Register the shared token store once so all components use the same persistence backend.\n\tif usePostgresStore {\n\t\tsdkAuth.RegisterTokenStore(pgStoreInst)\n\t} else if useObjectStore {\n\t\tsdkAuth.RegisterTokenStore(objectStoreInst)\n\t} else if useGitStore {\n\t\tsdkAuth.RegisterTokenStore(gitStoreInst)\n\t} else {\n\t\tsdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())\n\t}\n\n\t// Register built-in access providers before constructing services.\n\tconfigaccess.Register(&cfg.SDKConfig)\n\n\t// Handle different command modes based on the provided flags.\n\n\tif vertexImport != \"\" {\n\t\t// Handle Vertex service account import\n\t\tcmd.DoVertexImport(cfg, vertexImport)\n\t} else if login {\n\t\t// Handle Google/Gemini login\n\t\tcmd.DoLogin(cfg, projectID, options)\n\t} else if antigravityLogin {\n\t\t// Handle Antigravity login\n\t\tcmd.DoAntigravityLogin(cfg, options)\n\t} else if codexLogin {\n\t\t// Handle Codex login\n\t\tcmd.DoCodexLogin(cfg, options)\n\t} else if codexDeviceLogin {\n\t\t// Handle Codex device-code login\n\t\tcmd.DoCodexDeviceLogin(cfg, options)\n\t} else if claudeLogin {\n\t\t// Handle Claude login\n\t\tcmd.DoClaudeLogin(cfg, options)\n\t} else if qwenLogin {\n\t\tcmd.DoQwenLogin(cfg, options)\n\t} else if iflowLogin {\n\t\tcmd.DoIFlowLogin(cfg, options)\n\t} else if iflowCookie {\n\t\tcmd.DoIFlowCookieAuth(cfg, options)\n\t} else if kimiLogin {\n\t\tcmd.DoKimiLogin(cfg, options)\n\t} else {\n\t\t// In cloud deploy mode without config file, just wait for shutdown signals\n\t\tif isCloudDeploy && !configFileExists {\n\t\t\t// No config file available, just wait for shutdown\n\t\t\tcmd.WaitForCloudDeploy()\n\t\t\treturn\n\t\t}\n\t\tif tuiMode {\n\t\t\tif standalone {\n\t\t\t\t// Standalone mode: start an embedded local server and connect TUI client to it.\n\t\t\t\tmanagementasset.StartAutoUpdater(context.Background(), configFilePath)\n\t\t\t\tregistry.StartModelsUpdater(context.Background())\n\t\t\t\thook := tui.NewLogHook(2000)\n\t\t\t\thook.SetFormatter(&logging.LogFormatter{})\n\t\t\t\tlog.AddHook(hook)\n\n\t\t\t\torigStdout := os.Stdout\n\t\t\t\torigStderr := os.Stderr\n\t\t\t\torigLogOutput := log.StandardLogger().Out\n\t\t\t\tlog.SetOutput(io.Discard)\n\n\t\t\t\tdevNull, errOpenDevNull := os.Open(os.DevNull)\n\t\t\t\tif errOpenDevNull == nil {\n\t\t\t\t\tos.Stdout = devNull\n\t\t\t\t\tos.Stderr = devNull\n\t\t\t\t}\n\n\t\t\t\trestoreIO := func() {\n\t\t\t\t\tos.Stdout = origStdout\n\t\t\t\t\tos.Stderr = origStderr\n\t\t\t\t\tlog.SetOutput(origLogOutput)\n\t\t\t\t\tif devNull != nil {\n\t\t\t\t\t\t_ = devNull.Close()\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlocalMgmtPassword := fmt.Sprintf(\"tui-%d-%d\", os.Getpid(), time.Now().UnixNano())\n\t\t\t\tif password == \"\" {\n\t\t\t\t\tpassword = localMgmtPassword\n\t\t\t\t}\n\n\t\t\t\tcancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)\n\n\t\t\t\tclient := tui.NewClient(cfg.Port, password)\n\t\t\t\tready := false\n\t\t\t\tbackoff := 100 * time.Millisecond\n\t\t\t\tfor i := 0; i < 30; i++ {\n\t\t\t\t\tif _, errGetConfig := client.GetConfig(); errGetConfig == nil {\n\t\t\t\t\t\tready = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\ttime.Sleep(backoff)\n\t\t\t\t\tif backoff < time.Second {\n\t\t\t\t\t\tbackoff = time.Duration(float64(backoff) * 1.5)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif !ready {\n\t\t\t\t\trestoreIO()\n\t\t\t\t\tcancel()\n\t\t\t\t\t<-done\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"TUI error: embedded server is not ready\\n\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {\n\t\t\t\t\trestoreIO()\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"TUI error: %v\\n\", errRun)\n\t\t\t\t} else {\n\t\t\t\t\trestoreIO()\n\t\t\t\t}\n\n\t\t\t\tcancel()\n\t\t\t\t<-done\n\t\t\t} else {\n\t\t\t\t// Default TUI mode: pure management client.\n\t\t\t\t// The proxy server must already be running.\n\t\t\t\tif errRun := tui.Run(cfg.Port, password, nil, os.Stdout); errRun != nil {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"TUI error: %v\\n\", errRun)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// Start the main proxy service\n\t\t\tmanagementasset.StartAutoUpdater(context.Background(), configFilePath)\n\t\t\tregistry.StartModelsUpdater(context.Background())\n\t\t\tcmd.StartService(cfg, configFilePath, password)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "config.example.yaml",
    "content": "# Server host/interface to bind to. Default is empty (\"\") to bind all interfaces (IPv4 + IPv6).\n# Use \"127.0.0.1\" or \"localhost\" to restrict access to local machine only.\nhost: \"\"\n\n# Server port\nport: 8317\n\n# TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.\ntls:\n  enable: false\n  cert: \"\"\n  key: \"\"\n\n# Management API settings\nremote-management:\n  # Whether to allow remote (non-localhost) management access.\n  # When false, only localhost can access management endpoints (a key is still required).\n  allow-remote: false\n\n  # Management key. If a plaintext value is provided here, it will be hashed on startup.\n  # All management requests (even from localhost) require this key.\n  # Leave empty to disable the Management API entirely (404 for all /v0/management routes).\n  secret-key: \"\"\n\n  # Disable the bundled management control panel asset download and HTTP route when true.\n  disable-control-panel: false\n\n  # GitHub repository for the management control panel. Accepts a repository URL or releases API URL.\n  panel-github-repository: \"https://github.com/router-for-me/Cli-Proxy-API-Management-Center\"\n\n# Authentication directory (supports ~ for home directory)\nauth-dir: \"~/.cli-proxy-api\"\n\n# API keys for authentication\napi-keys:\n  - \"your-api-key-1\"\n  - \"your-api-key-2\"\n  - \"your-api-key-3\"\n\n# Enable debug logging\ndebug: false\n\n# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.\npprof:\n  enable: false\n  addr: \"127.0.0.1:8316\"\n\n# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.\ncommercial-mode: false\n\n# When true, write application logs to rotating files instead of stdout\nlogging-to-file: false\n\n# Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log\n# files are deleted until within the limit. Set to 0 to disable.\nlogs-max-total-size-mb: 0\n\n# Maximum number of error log files retained when request logging is disabled.\n# When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup.\nerror-logs-max-files: 10\n\n# When false, disable in-memory usage statistics aggregation\nusage-statistics-enabled: false\n\n# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/\n# Per-entry proxy-url also supports \"direct\" or \"none\" to bypass both the global proxy-url and environment proxies explicitly.\nproxy-url: \"\"\n\n# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).\nforce-model-prefix: false\n\n# When true, forward filtered upstream response headers to downstream clients.\n# Default is false (disabled).\npassthrough-headers: false\n\n# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.\nrequest-retry: 3\n\n# Maximum number of different credentials to try for one failed request.\n# Set to 0 to keep legacy behavior (try all available credentials).\nmax-retry-credentials: 0\n\n# Maximum wait time in seconds for a cooled-down credential before triggering a retry.\nmax-retry-interval: 30\n\n# Quota exceeded behavior\nquota-exceeded:\n  switch-project: true # Whether to automatically switch to another project when a quota is exceeded\n  switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded\n\n# Routing strategy for selecting credentials when multiple match.\nrouting:\n  strategy: \"round-robin\" # round-robin (default), fill-first\n\n# When true, enable authentication for the WebSocket API (/v1/ws).\nws-auth: false\n\n# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.\nnonstream-keepalive-interval: 0\n\n# Streaming behavior (SSE keep-alives + safe bootstrap retries).\n# streaming:\n#   keepalive-seconds: 15   # Default: 0 (disabled). <= 0 disables keep-alives.\n#   bootstrap-retries: 1    # Default: 0 (disabled). Retries before first byte is sent.\n\n# Gemini API keys\n# gemini-api-key:\n#   - api-key: \"AIzaSy...01\"\n#     prefix: \"test\" # optional: require calls like \"test/gemini-3-pro-preview\" to target this credential\n#     base-url: \"https://generativelanguage.googleapis.com\"\n#     headers:\n#       X-Custom-Header: \"custom-value\"\n#     proxy-url: \"socks5://proxy.example.com:1080\"\n#     # proxy-url: \"direct\" # optional: explicit direct connect for this credential\n#     models:\n#       - name: \"gemini-2.5-flash\" # upstream model name\n#         alias: \"gemini-flash\"    # client alias mapped to the upstream model\n#     excluded-models:\n#       - \"gemini-2.5-pro\"     # exclude specific models from this provider (exact match)\n#       - \"gemini-2.5-*\"       # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)\n#       - \"*-preview\"          # wildcard matching suffix (e.g. gemini-3-pro-preview)\n#       - \"*flash*\"            # wildcard matching substring (e.g. gemini-2.5-flash-lite)\n#   - api-key: \"AIzaSy...02\"\n\n# Codex API keys\n# codex-api-key:\n#   - api-key: \"sk-atSM...\"\n#     prefix: \"test\" # optional: require calls like \"test/gpt-5-codex\" to target this credential\n#     base-url: \"https://www.example.com\" # use the custom codex API endpoint\n#     headers:\n#       X-Custom-Header: \"custom-value\"\n#     proxy-url: \"socks5://proxy.example.com:1080\" # optional: per-key proxy override\n#     # proxy-url: \"direct\" # optional: explicit direct connect for this credential\n#     models:\n#       - name: \"gpt-5-codex\"   # upstream model name\n#         alias: \"codex-latest\" # client alias mapped to the upstream model\n#     excluded-models:\n#       - \"gpt-5.1\"         # exclude specific models (exact match)\n#       - \"gpt-5-*\"         # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)\n#       - \"*-mini\"          # wildcard matching suffix (e.g. gpt-5-codex-mini)\n#       - \"*codex*\"         # wildcard matching substring (e.g. gpt-5-codex-low)\n\n# Claude API keys\n# claude-api-key:\n#   - api-key: \"sk-atSM...\" # use the official claude API key, no need to set the base url\n#   - api-key: \"sk-atSM...\"\n#     prefix: \"test\" # optional: require calls like \"test/claude-sonnet-latest\" to target this credential\n#     base-url: \"https://www.example.com\" # use the custom claude API endpoint\n#     headers:\n#       X-Custom-Header: \"custom-value\"\n#     proxy-url: \"socks5://proxy.example.com:1080\" # optional: per-key proxy override\n#     # proxy-url: \"direct\" # optional: explicit direct connect for this credential\n#     models:\n#       - name: \"claude-3-5-sonnet-20241022\" # upstream model name\n#         alias: \"claude-sonnet-latest\"      # client alias mapped to the upstream model\n#     excluded-models:\n#       - \"claude-opus-4-5-20251101\" # exclude specific models (exact match)\n#       - \"claude-3-*\"               # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)\n#       - \"*-thinking\"               # wildcard matching suffix (e.g. claude-opus-4-5-thinking)\n#       - \"*haiku*\"                  # wildcard matching substring (e.g. claude-3-5-haiku-20241022)\n#     cloak:                         # optional: request cloaking for non-Claude-Code clients\n#       mode: \"auto\"                 # \"auto\" (default): cloak only when client is not Claude Code\n#                                    # \"always\": always apply cloaking\n#                                    # \"never\": never apply cloaking\n#       strict-mode: false           # false (default): prepend Claude Code prompt to user system messages\n#                                    # true: strip all user system messages, keep only Claude Code prompt\n#       sensitive-words:             # optional: words to obfuscate with zero-width characters\n#         - \"API\"\n#         - \"proxy\"\n#       cache-user-id: true          # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request\n\n# Default headers for Claude API requests. Update when Claude Code releases new versions.\n# These are used as fallbacks when the client does not send its own headers.\n# claude-header-defaults:\n#   user-agent: \"claude-cli/2.1.44 (external, sdk-cli)\"\n#   package-version: \"0.74.0\"\n#   runtime-version: \"v24.3.0\"\n#   timeout: \"600\"\n\n# Default headers for Codex OAuth model requests.\n# These are used only for file-backed/OAuth Codex requests when the client\n# does not send the header. `user-agent` applies to HTTP and websocket requests;\n# `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries.\n# codex-header-defaults:\n#   user-agent: \"codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0\"\n#   beta-features: \"multi_agent\"\n\n# OpenAI compatibility providers\n# openai-compatibility:\n#   - name: \"openrouter\" # The name of the provider; it will be used in the user agent and other places.\n#     prefix: \"test\" # optional: require calls like \"test/kimi-k2\" to target this provider's credentials\n#     base-url: \"https://openrouter.ai/api/v1\" # The base URL of the provider.\n#     headers:\n#       X-Custom-Header: \"custom-value\"\n#     api-key-entries:\n#       - api-key: \"sk-or-v1-...b780\"\n#         proxy-url: \"socks5://proxy.example.com:1080\" # optional: per-key proxy override\n#         # proxy-url: \"direct\" # optional: explicit direct connect for this credential\n#       - api-key: \"sk-or-v1-...b781\" # without proxy-url\n#     models: # The models supported by the provider.\n#       - name: \"moonshotai/kimi-k2:free\" # The actual model name.\n#         alias: \"kimi-k2\" # The alias used in the API.\n#       # You may repeat the same alias to build an internal model pool.\n#       # The client still sees only one alias in the model list.\n#       # Requests to that alias will round-robin across the upstream names below,\n#       # and if the chosen upstream fails before producing output, the request will\n#       # continue with the next upstream model in the same alias pool.\n#       - name: \"qwen3.5-plus\"\n#         alias: \"claude-opus-4.66\"\n#       - name: \"glm-5\"\n#         alias: \"claude-opus-4.66\"\n#       - name: \"kimi-k2.5\"\n#         alias: \"claude-opus-4.66\"\n\n# Vertex API keys (Vertex-compatible endpoints, base-url is optional)\n# vertex-api-key:\n#   - api-key: \"vk-123...\"                        # x-goog-api-key header\n#     prefix: \"test\"                              # optional: require calls like \"test/vertex-pro\" to target this credential\n#     base-url: \"https://example.com/api\"         # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted\n#     proxy-url: \"socks5://proxy.example.com:1080\" # optional per-key proxy override\n#     # proxy-url: \"direct\" # optional: explicit direct connect for this credential\n#     headers:\n#       X-Custom-Header: \"custom-value\"\n#     models:                                     # optional: map aliases to upstream model names\n#       - name: \"gemini-2.5-flash\"                # upstream model name\n#         alias: \"vertex-flash\"                   # client-visible alias\n#       - name: \"gemini-2.5-pro\"\n#         alias: \"vertex-pro\"\n#     excluded-models:                            # optional: models to exclude from listing\n#       - \"imagen-3.0-generate-002\"\n#       - \"imagen-*\"\n\n# Amp Integration\n# ampcode:\n#   # Configure upstream URL for Amp CLI OAuth and management features\n#   upstream-url: \"https://ampcode.com\"\n#   # Optional: Override API key for Amp upstream (otherwise uses env or file)\n#   upstream-api-key: \"\"\n#   # Per-client upstream API key mapping\n#   # Maps client API keys (from top-level api-keys) to different Amp upstream API keys.\n#   # Useful when different clients need to use different Amp accounts/quotas.\n#   # If a client key isn't mapped, falls back to upstream-api-key (default behavior).\n#   upstream-api-keys:\n#     - upstream-api-key: \"amp_key_for_team_a\"    # Upstream key to use for these clients\n#       api-keys:                                 # Client keys that use this upstream key\n#         - \"your-api-key-1\"\n#         - \"your-api-key-2\"\n#     - upstream-api-key: \"amp_key_for_team_b\"\n#       api-keys:\n#         - \"your-api-key-3\"\n#   # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)\n#   restrict-management-to-localhost: false\n#   # Force model mappings to run before checking local API keys (default: false)\n#   force-model-mappings: false\n#   # Amp Model Mappings\n#   # Route unavailable Amp models to alternative models available in your local proxy.\n#   # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)\n#   # but you have a similar model available (e.g., Claude Sonnet 4).\n#   model-mappings:\n#     - from: \"claude-opus-4-5-20251101\"          # Model requested by Amp CLI\n#       to: \"gemini-claude-opus-4-5-thinking\"     # Route to this available model instead\n#     - from: \"claude-sonnet-4-5-20250929\"\n#       to: \"gemini-claude-sonnet-4-5-thinking\"\n#     - from: \"claude-haiku-4-5-20251001\"\n#       to: \"gemini-2.5-flash\"\n\n# Global OAuth model name aliases (per channel)\n# These aliases rename model IDs for both model listing and request routing.\n# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.\n# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.\n# You can repeat the same name with different aliases to expose multiple client model names.\n# oauth-model-alias:\n#   gemini-cli:\n#     - name: \"gemini-2.5-pro\"          # original model name under this channel\n#       alias: \"g2.5p\"                  # client-visible alias\n#       fork: true                      # when true, keep original and also add the alias as an extra model (default: false)\n#   vertex:\n#     - name: \"gemini-2.5-pro\"\n#       alias: \"g2.5p\"\n#   aistudio:\n#     - name: \"gemini-2.5-pro\"\n#       alias: \"g2.5p\"\n#   antigravity:\n#     - name: \"gemini-3-pro-high\"\n#       alias: \"gemini-3-pro-preview\"\n#   claude:\n#     - name: \"claude-sonnet-4-5-20250929\"\n#       alias: \"cs4.5\"\n#   codex:\n#     - name: \"gpt-5\"\n#       alias: \"g5\"\n#   qwen:\n#     - name: \"qwen3-coder-plus\"\n#       alias: \"qwen-plus\"\n#   iflow:\n#     - name: \"glm-4.7\"\n#       alias: \"glm-god\"\n#   kimi:\n#     - name: \"kimi-k2.5\"\n#       alias: \"k2.5\"\n\n# OAuth provider excluded models\n# oauth-excluded-models:\n#   gemini-cli:\n#     - \"gemini-2.5-pro\"     # exclude specific models (exact match)\n#     - \"gemini-2.5-*\"       # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)\n#     - \"*-preview\"          # wildcard matching suffix (e.g. gemini-3-pro-preview)\n#     - \"*flash*\"            # wildcard matching substring (e.g. gemini-2.5-flash-lite)\n#   vertex:\n#     - \"gemini-3-pro-preview\"\n#   aistudio:\n#     - \"gemini-3-pro-preview\"\n#   antigravity:\n#     - \"gemini-3-pro-preview\"\n#   claude:\n#     - \"claude-3-5-haiku-20241022\"\n#   codex:\n#     - \"gpt-5-codex-mini\"\n#   qwen:\n#     - \"vision-model\"\n#   iflow:\n#     - \"tstars2.0\"\n#   kimi:\n#     - \"kimi-k2-thinking\"\n\n# Optional payload configuration\n# payload:\n#   default: # Default rules only set parameters when they are missing in the payload.\n#     - models:\n#         - name: \"gemini-2.5-pro\" # Supports wildcards (e.g., \"gemini-*\")\n#           protocol: \"gemini\" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity\n#       params: # JSON path (gjson/sjson syntax) -> value\n#         \"generationConfig.thinkingConfig.thinkingBudget\": 32768\n#   default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).\n#     - models:\n#         - name: \"gemini-2.5-pro\" # Supports wildcards (e.g., \"gemini-*\")\n#           protocol: \"gemini\" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity\n#       params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)\n#         \"generationConfig.responseJsonSchema\": \"{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{\\\"answer\\\":{\\\"type\\\":\\\"string\\\"}}}\"\n#   override: # Override rules always set parameters, overwriting any existing values.\n#     - models:\n#         - name: \"gpt-*\" # Supports wildcards (e.g., \"gpt-*\")\n#           protocol: \"codex\" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity\n#       params: # JSON path (gjson/sjson syntax) -> value\n#         \"reasoning.effort\": \"high\"\n#   override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON).\n#     - models:\n#         - name: \"gpt-*\" # Supports wildcards (e.g., \"gpt-*\")\n#           protocol: \"codex\" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity\n#       params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)\n#         \"response_format\": \"{\\\"type\\\":\\\"json_schema\\\",\\\"json_schema\\\":{\\\"name\\\":\\\"answer\\\",\\\"schema\\\":{\\\"type\\\":\\\"object\\\"}}}\"\n#   filter: # Filter rules remove specified parameters from the payload.\n#     - models:\n#         - name: \"gemini-2.5-pro\" # Supports wildcards (e.g., \"gemini-*\")\n#           protocol: \"gemini\" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity\n#       params: # JSON paths (gjson/sjson syntax) to remove from the payload\n#         - \"generationConfig.thinkingConfig.thinkingBudget\"\n#         - \"generationConfig.responseJsonSchema\"\n"
  },
  {
    "path": "docker-build.ps1",
    "content": "# build.ps1 - Windows PowerShell Build Script\n#\n# This script automates the process of building and running the Docker container\n# with version information dynamically injected at build time.\n\n# Stop script execution on any error\n$ErrorActionPreference = \"Stop\"\n\n# --- Step 1: Choose Environment ---\nWrite-Host \"Please select an option:\"\nWrite-Host \"1) Run using Pre-built Image (Recommended)\"\nWrite-Host \"2) Build from Source and Run (For Developers)\"\n$choice = Read-Host -Prompt \"Enter choice [1-2]\"\n\n# --- Step 2: Execute based on choice ---\nswitch ($choice) {\n    \"1\" {\n        Write-Host \"--- Running with Pre-built Image ---\"\n        docker compose up -d --remove-orphans --no-build\n        Write-Host \"Services are starting from remote image.\"\n        Write-Host \"Run 'docker compose logs -f' to see the logs.\"\n    }\n    \"2\" {\n        Write-Host \"--- Building from Source and Running ---\"\n\n        # Get Version Information\n        $VERSION = (git describe --tags --always --dirty)\n        $COMMIT  = (git rev-parse --short HEAD)\n        $BUILD_DATE = (Get-Date).ToUniversalTime().ToString(\"yyyy-MM-ddTHH:mm:ssZ\")\n\n        Write-Host \"Building with the following info:\"\n        Write-Host \"  Version: $VERSION\"\n        Write-Host \"  Commit: $COMMIT\"\n        Write-Host \"  Build Date: $BUILD_DATE\"\n        Write-Host \"----------------------------------------\"\n\n        # Build and start the services with a local-only image tag\n        $env:CLI_PROXY_IMAGE = \"cli-proxy-api:local\"\n        \n        Write-Host \"Building the Docker image...\"\n        docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE\n\n        Write-Host \"Starting the services...\"\n        docker compose up -d --remove-orphans --pull never\n\n        Write-Host \"Build complete. Services are starting.\"\n        Write-Host \"Run 'docker compose logs -f' to see the logs.\"\n    }\n    default {\n        Write-Host \"Invalid choice. Please enter 1 or 2.\"\n        exit 1\n    }\n}"
  },
  {
    "path": "docker-build.sh",
    "content": "#!/usr/bin/env bash\n#\n# build.sh - Linux/macOS Build Script\n#\n# This script automates the process of building and running the Docker container\n# with version information dynamically injected at build time.\n\n# Hidden feature: Preserve usage statistics across rebuilds\n# Usage: ./docker-build.sh --with-usage\n# First run prompts for management API key, saved to temp/stats/.api_secret\n\nset -euo pipefail\n\nSTATS_DIR=\"temp/stats\"\nSTATS_FILE=\"${STATS_DIR}/.usage_backup.json\"\nSECRET_FILE=\"${STATS_DIR}/.api_secret\"\nWITH_USAGE=false\n\nget_port() {\n  if [[ -f \"config.yaml\" ]]; then\n    grep -E \"^port:\" config.yaml | sed -E 's/^port: *[\"'\"'\"']?([0-9]+)[\"'\"'\"']?.*$/\\1/'\n  else\n    echo \"8317\"\n  fi\n}\n\nexport_stats_api_secret() {\n  if [[ -f \"${SECRET_FILE}\" ]]; then\n    API_SECRET=$(cat \"${SECRET_FILE}\")\n  else\n    if [[ ! -d \"${STATS_DIR}\" ]]; then\n      mkdir -p \"${STATS_DIR}\"\n    fi\n    echo \"First time using --with-usage. Management API key required.\"\n    read -r -p \"Enter management key: \" -s API_SECRET\n    echo\n    echo \"${API_SECRET}\" > \"${SECRET_FILE}\"\n    chmod 600 \"${SECRET_FILE}\"\n  fi\n}\n\ncheck_container_running() {\n  local port\n  port=$(get_port)\n\n  if ! curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:${port}/\" | grep -q \"200\"; then\n    echo \"Error: cli-proxy-api service is not responding at localhost:${port}\"\n    echo \"Please start the container first or use without --with-usage flag.\"\n    exit 1\n  fi\n}\n\nexport_stats() {\n  local port\n  port=$(get_port)\n\n  if [[ ! -d \"${STATS_DIR}\" ]]; then\n    mkdir -p \"${STATS_DIR}\"\n  fi\n  check_container_running\n  echo \"Exporting usage statistics...\"\n  EXPORT_RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"X-Management-Key: ${API_SECRET}\" \\\n    \"http://localhost:${port}/v0/management/usage/export\")\n  HTTP_CODE=$(echo \"${EXPORT_RESPONSE}\" | tail -n1)\n  RESPONSE_BODY=$(echo \"${EXPORT_RESPONSE}\" | sed '$d')\n\n  if [[ \"${HTTP_CODE}\" != \"200\" ]]; then\n    echo \"Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}\"\n    exit 1\n  fi\n\n  echo \"${RESPONSE_BODY}\" > \"${STATS_FILE}\"\n  echo \"Statistics exported to ${STATS_FILE}\"\n}\n\nimport_stats() {\n  local port\n  port=$(get_port)\n\n  echo \"Importing usage statistics...\"\n  IMPORT_RESPONSE=$(curl -s -w \"\\n%{http_code}\" -X POST \\\n    -H \"X-Management-Key: ${API_SECRET}\" \\\n    -H \"Content-Type: application/json\" \\\n    -d @\"${STATS_FILE}\" \\\n    \"http://localhost:${port}/v0/management/usage/import\")\n  IMPORT_CODE=$(echo \"${IMPORT_RESPONSE}\" | tail -n1)\n  IMPORT_BODY=$(echo \"${IMPORT_RESPONSE}\" | sed '$d')\n\n  if [[ \"${IMPORT_CODE}\" == \"200\" ]]; then\n    echo \"Statistics imported successfully\"\n  else\n    echo \"Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}\"\n  fi\n\n  rm -f \"${STATS_FILE}\"\n}\n\nwait_for_service() {\n  local port\n  port=$(get_port)\n\n  echo \"Waiting for service to be ready...\"\n  for i in {1..30}; do\n    if curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:${port}/\" | grep -q \"200\"; then\n      break\n    fi\n    sleep 1\n  done\n  sleep 2\n}\n\nif [[ \"${1:-}\" == \"--with-usage\" ]]; then\n  WITH_USAGE=true\n  export_stats_api_secret\nfi\n\n# --- Step 1: Choose Environment ---\necho \"Please select an option:\"\necho \"1) Run using Pre-built Image (Recommended)\"\necho \"2) Build from Source and Run (For Developers)\"\nread -r -p \"Enter choice [1-2]: \" choice\n\n# --- Step 2: Execute based on choice ---\ncase \"$choice\" in\n  1)\n    echo \"--- Running with Pre-built Image ---\"\n    if [[ \"${WITH_USAGE}\" == \"true\" ]]; then\n      export_stats\n    fi\n    docker compose up -d --remove-orphans --no-build\n    if [[ \"${WITH_USAGE}\" == \"true\" ]]; then\n      wait_for_service\n      import_stats\n    fi\n    echo \"Services are starting from remote image.\"\n    echo \"Run 'docker compose logs -f' to see the logs.\"\n    ;;\n  2)\n    echo \"--- Building from Source and Running ---\"\n\n    # Get Version Information\n    VERSION=\"$(git describe --tags --always --dirty)\"\n    COMMIT=\"$(git rev-parse --short HEAD)\"\n    BUILD_DATE=\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"\n\n    echo \"Building with the following info:\"\n    echo \"  Version: ${VERSION}\"\n    echo \"  Commit: ${COMMIT}\"\n    echo \"  Build Date: ${BUILD_DATE}\"\n    echo \"----------------------------------------\"\n\n    # Build and start the services with a local-only image tag\n    export CLI_PROXY_IMAGE=\"cli-proxy-api:local\"\n\n    echo \"Building the Docker image...\"\n    docker compose build \\\n      --build-arg VERSION=\"${VERSION}\" \\\n      --build-arg COMMIT=\"${COMMIT}\" \\\n      --build-arg BUILD_DATE=\"${BUILD_DATE}\"\n\n    if [[ \"${WITH_USAGE}\" == \"true\" ]]; then\n      export_stats\n    fi\n\n    echo \"Starting the services...\"\n    docker compose up -d --remove-orphans --pull never\n\n    if [[ \"${WITH_USAGE}\" == \"true\" ]]; then\n      wait_for_service\n      import_stats\n    fi\n\n    echo \"Build complete. Services are starting.\"\n    echo \"Run 'docker compose logs -f' to see the logs.\"\n    ;;\n  *)\n    echo \"Invalid choice. Please enter 1 or 2.\"\n    exit 1\n    ;;\nesac\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  cli-proxy-api:\n    image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}\n    pull_policy: always\n    build:\n      context: .\n      dockerfile: Dockerfile\n      args:\n        VERSION: ${VERSION:-dev}\n        COMMIT: ${COMMIT:-none}\n        BUILD_DATE: ${BUILD_DATE:-unknown}\n    container_name: cli-proxy-api\n    # env_file:\n    #   - .env\n    environment:\n      DEPLOY: ${DEPLOY:-}\n    ports:\n      - \"8317:8317\"\n      - \"8085:8085\"\n      - \"1455:1455\"\n      - \"54545:54545\"\n      - \"51121:51121\"\n      - \"11451:11451\"\n    volumes:\n      - ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml\n      - ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api\n      - ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs\n    restart: unless-stopped\n"
  },
  {
    "path": "examples/custom-provider/main.go",
    "content": "// Package main demonstrates how to create a custom AI provider executor\n// and integrate it with the CLI Proxy API server. This example shows how to:\n// - Create a custom executor that implements the Executor interface\n// - Register custom translators for request/response transformation\n// - Integrate the custom provider with the SDK server\n// - Register custom models in the model registry\n//\n// This example uses a simple echo service (httpbin.org) as the upstream API\n// for demonstration purposes. In a real implementation, you would replace\n// this with your actual AI service provider.\npackage main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tclipexec \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging\"\n\tsdktr \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n)\n\nconst (\n\t// providerKey is the identifier for our custom provider.\n\tproviderKey = \"myprov\"\n\n\t// fOpenAI represents the OpenAI chat format.\n\tfOpenAI = sdktr.Format(\"openai.chat\")\n\n\t// fMyProv represents our custom provider's chat format.\n\tfMyProv = sdktr.Format(\"myprov.chat\")\n)\n\n// init registers trivial translators for demonstration purposes.\n// In a real implementation, you would implement proper request/response\n// transformation logic between OpenAI format and your provider's format.\nfunc init() {\n\tsdktr.Register(fOpenAI, fMyProv,\n\t\tfunc(model string, raw []byte, stream bool) []byte { return raw },\n\t\tsdktr.ResponseTransform{\n\t\t\tStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {\n\t\t\t\treturn []string{string(raw)}\n\t\t\t},\n\t\t\tNonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {\n\t\t\t\treturn string(raw)\n\t\t\t},\n\t\t},\n\t)\n}\n\n// MyExecutor is a minimal provider implementation for demonstration purposes.\n// It implements the Executor interface to handle requests to a custom AI provider.\ntype MyExecutor struct{}\n\n// Identifier returns the unique identifier for this executor.\nfunc (MyExecutor) Identifier() string { return providerKey }\n\n// PrepareRequest optionally injects credentials to raw HTTP requests.\n// This method is called before each request to allow the executor to modify\n// the HTTP request with authentication headers or other necessary modifications.\n//\n// Parameters:\n//   - req: The HTTP request to prepare\n//   - a: The authentication information\n//\n// Returns:\n//   - error: An error if request preparation fails\nfunc (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {\n\tif req == nil || a == nil {\n\t\treturn nil\n\t}\n\tif a.Attributes != nil {\n\t\tif ak := strings.TrimSpace(a.Attributes[\"api_key\"]); ak != \"\" {\n\t\t\treq.Header.Set(\"Authorization\", \"Bearer \"+ak)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc buildHTTPClient(a *coreauth.Auth) *http.Client {\n\tif a == nil || strings.TrimSpace(a.ProxyURL) == \"\" {\n\t\treturn http.DefaultClient\n\t}\n\tu, err := url.Parse(a.ProxyURL)\n\tif err != nil || (u.Scheme != \"http\" && u.Scheme != \"https\") {\n\t\treturn http.DefaultClient\n\t}\n\treturn &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}\n}\n\nfunc upstreamEndpoint(a *coreauth.Auth) string {\n\tif a != nil && a.Attributes != nil {\n\t\tif ep := strings.TrimSpace(a.Attributes[\"endpoint\"]); ep != \"\" {\n\t\t\treturn ep\n\t\t}\n\t}\n\t// Demo echo endpoint; replace with your upstream.\n\treturn \"https://httpbin.org/post\"\n}\n\nfunc (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {\n\tclient := buildHTTPClient(a)\n\tendpoint := upstreamEndpoint(a)\n\n\thttpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))\n\tif errNew != nil {\n\t\treturn clipexec.Response{}, errNew\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Inject credentials via PrepareRequest hook.\n\tif errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {\n\t\treturn clipexec.Response{}, errPrep\n\t}\n\n\tresp, errDo := client.Do(httpReq)\n\tif errDo != nil {\n\t\treturn clipexec.Response{}, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"close response body error: %v\\n\", errClose)\n\t\t}\n\t}()\n\tbody, _ := io.ReadAll(resp.Body)\n\treturn clipexec.Response{Payload: body}, nil\n}\n\nfunc (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"myprov executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {\n\t\treturn nil, errPrep\n\t}\n\tclient := buildHTTPClient(a)\n\treturn client.Do(httpReq)\n}\n\nfunc (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {\n\treturn clipexec.Response{}, errors.New(\"count tokens not implemented\")\n}\n\nfunc (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (*clipexec.StreamResult, error) {\n\tch := make(chan clipexec.StreamChunk, 1)\n\tgo func() {\n\t\tdefer close(ch)\n\t\tch <- clipexec.StreamChunk{Payload: []byte(\"data: {\\\"ok\\\":true}\\n\\n\")}\n\t}()\n\treturn &clipexec.StreamResult{Chunks: ch}, nil\n}\n\nfunc (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn a, nil\n}\n\nfunc main() {\n\tcfg, err := config.LoadConfig(\"config.yaml\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\ttokenStore := sdkAuth.GetTokenStore()\n\tif dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {\n\t\tdirSetter.SetBaseDir(cfg.AuthDir)\n\t}\n\tcore := coreauth.NewManager(tokenStore, nil, nil)\n\tcore.RegisterExecutor(MyExecutor{})\n\n\thooks := cliproxy.Hooks{\n\t\tOnAfterStart: func(s *cliproxy.Service) {\n\t\t\t// Register demo models for the custom provider so they appear in /v1/models.\n\t\t\tmodels := []*cliproxy.ModelInfo{{ID: \"myprov-pro-1\", Object: \"model\", Type: providerKey, DisplayName: \"MyProv Pro 1\"}}\n\t\t\tfor _, a := range core.List() {\n\t\t\t\tif strings.EqualFold(a.Provider, providerKey) {\n\t\t\t\t\tcliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t}\n\n\tsvc, err := cliproxy.NewBuilder().\n\t\tWithConfig(cfg).\n\t\tWithConfigPath(\"config.yaml\").\n\t\tWithCoreAuthManager(core).\n\t\tWithServerOptions(\n\t\t\t// Optional: add a simple middleware + custom request logger\n\t\t\tapi.WithMiddleware(func(c *gin.Context) { c.Header(\"X-Example\", \"custom-provider\"); c.Next() }),\n\t\t\tapi.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {\n\t\t\t\treturn logging.NewFileRequestLoggerWithOptions(true, \"logs\", filepath.Dir(cfgPath), cfg.ErrorLogsMaxFiles)\n\t\t\t}),\n\t\t).\n\t\tWithHooks(hooks).\n\t\tBuild()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tif errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {\n\t\tpanic(errRun)\n\t}\n\t_ = os.Stderr // keep os import used (demo only)\n\t_ = time.Second\n}\n"
  },
  {
    "path": "examples/http-request/main.go",
    "content": "// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest\n// to execute arbitrary HTTP requests with provider credentials injected.\n//\n// This example registers a minimal custom executor that injects an Authorization\n// header from auth.Attributes[\"api_key\"], then performs two requests against\n// httpbin.org to show the injected headers.\npackage main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tclipexec \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst providerKey = \"echo\"\n\n// EchoExecutor is a minimal provider implementation for demonstration purposes.\ntype EchoExecutor struct{}\n\nfunc (EchoExecutor) Identifier() string { return providerKey }\n\nfunc (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {\n\tif req == nil || auth == nil {\n\t\treturn nil\n\t}\n\tif auth.Attributes != nil {\n\t\tif apiKey := strings.TrimSpace(auth.Attributes[\"api_key\"]); apiKey != \"\" {\n\t\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"echo executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {\n\t\treturn nil, errPrep\n\t}\n\treturn http.DefaultClient.Do(httpReq)\n}\n\nfunc (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {\n\treturn clipexec.Response{}, errors.New(\"echo executor: Execute not implemented\")\n}\n\nfunc (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (*clipexec.StreamResult, error) {\n\treturn nil, errors.New(\"echo executor: ExecuteStream not implemented\")\n}\n\nfunc (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn nil, errors.New(\"echo executor: Refresh not implemented\")\n}\n\nfunc (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {\n\treturn clipexec.Response{}, errors.New(\"echo executor: CountTokens not implemented\")\n}\n\nfunc main() {\n\tlog.SetLevel(log.InfoLevel)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tcore := coreauth.NewManager(nil, nil, nil)\n\tcore.RegisterExecutor(EchoExecutor{})\n\n\tauth := &coreauth.Auth{\n\t\tID:       \"demo-echo\",\n\t\tProvider: providerKey,\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": \"demo-api-key\",\n\t\t},\n\t}\n\n\t// Example 1: Build a prepared request and execute it using your own http.Client.\n\treqPrepared, errReqPrepared := core.NewHttpRequest(\n\t\tctx,\n\t\tauth,\n\t\thttp.MethodGet,\n\t\t\"https://httpbin.org/anything\",\n\t\tnil,\n\t\thttp.Header{\"X-Example\": []string{\"prepared\"}},\n\t)\n\tif errReqPrepared != nil {\n\t\tpanic(errReqPrepared)\n\t}\n\trespPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)\n\tif errDoPrepared != nil {\n\t\tpanic(errDoPrepared)\n\t}\n\tdefer func() {\n\t\tif errClose := respPrepared.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"close response body error: %v\", errClose)\n\t\t}\n\t}()\n\tbodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)\n\tif errReadPrepared != nil {\n\t\tpanic(errReadPrepared)\n\t}\n\tfmt.Printf(\"Prepared request status: %d\\n%s\\n\\n\", respPrepared.StatusCode, bodyPrepared)\n\n\t// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).\n\trawBody := []byte(`{\"hello\":\"world\"}`)\n\trawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, \"https://httpbin.org/anything\", bytes.NewReader(rawBody))\n\tif errRawReq != nil {\n\t\tpanic(errRawReq)\n\t}\n\trawReq.Header.Set(\"Content-Type\", \"application/json\")\n\trawReq.Header.Set(\"X-Example\", \"executed\")\n\n\trespExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)\n\tif errDoExec != nil {\n\t\tpanic(errDoExec)\n\t}\n\tdefer func() {\n\t\tif errClose := respExec.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"close response body error: %v\", errClose)\n\t\t}\n\t}()\n\tbodyExec, errReadExec := io.ReadAll(respExec.Body)\n\tif errReadExec != nil {\n\t\tpanic(errReadExec)\n\t}\n\tfmt.Printf(\"Manager HttpRequest status: %d\\n%s\\n\", respExec.StatusCode, bodyExec)\n}\n"
  },
  {
    "path": "examples/translator/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin\"\n)\n\nfunc main() {\n\trawRequest := []byte(`{\"messages\":[{\"content\":[{\"text\":\"Hello! Gemini\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"gemini-2.5-pro\",\"stream\":false}`)\n\tfmt.Println(\"Has gemini->openai response translator:\", translator.HasResponseTransformerByFormatName(\n\t\ttranslator.FormatGemini,\n\t\ttranslator.FormatOpenAI,\n\t))\n\n\ttranslatedRequest := translator.TranslateRequestByFormatName(\n\t\ttranslator.FormatOpenAI,\n\t\ttranslator.FormatGemini,\n\t\t\"gemini-2.5-pro\",\n\t\trawRequest,\n\t\tfalse,\n\t)\n\n\tfmt.Printf(\"Translated request to Gemini format:\\n%s\\n\\n\", translatedRequest)\n\n\tclaudeResponse := []byte(`{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"Okay, here's what's going through my mind. I need to schedule a meeting\"},{\"thoughtSignature\":\"\",\"functionCall\":{\"name\":\"schedule_meeting\",\"args\":{\"topic\":\"Q3 planning\",\"attendees\":[\"Bob\",\"Alice\"],\"time\":\"10:00\",\"date\":\"2025-03-27\"}}}]},\"finishReason\":\"STOP\",\"avgLogprobs\":-0.50018133435930523}],\"usageMetadata\":{\"promptTokenCount\":117,\"candidatesTokenCount\":28,\"totalTokenCount\":474,\"trafficType\":\"PROVISIONED_THROUGHPUT\",\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":117}],\"candidatesTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":28}],\"thoughtsTokenCount\":329},\"modelVersion\":\"gemini-2.5-pro\",\"createTime\":\"2025-08-15T04:12:55.249090Z\",\"responseId\":\"x7OeaIKaD6CU48APvNXDyA4\"}`)\n\n\tconvertedResponse := translator.TranslateNonStreamByFormatName(\n\t\tcontext.Background(),\n\t\ttranslator.FormatGemini,\n\t\ttranslator.FormatOpenAI,\n\t\t\"gemini-2.5-pro\",\n\t\trawRequest,\n\t\ttranslatedRequest,\n\t\tclaudeResponse,\n\t\tnil,\n\t)\n\n\tfmt.Printf(\"Converted response for OpenAI clients:\\n%s\\n\", convertedResponse)\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/router-for-me/CLIProxyAPI/v6\n\ngo 1.26.0\n\nrequire (\n\tgithub.com/andybalholm/brotli v1.0.6\n\tgithub.com/atotto/clipboard v0.1.4\n\tgithub.com/charmbracelet/bubbles v1.0.0\n\tgithub.com/charmbracelet/bubbletea v1.3.10\n\tgithub.com/charmbracelet/lipgloss v1.1.0\n\tgithub.com/fsnotify/fsnotify v1.9.0\n\tgithub.com/gin-gonic/gin v1.10.1\n\tgithub.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/jackc/pgx/v5 v5.7.6\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/klauspost/compress v1.17.4\n\tgithub.com/minio/minio-go/v7 v7.0.66\n\tgithub.com/refraction-networking/utls v1.8.2\n\tgithub.com/sirupsen/logrus v1.9.3\n\tgithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966\n\tgithub.com/tidwall/gjson v1.18.0\n\tgithub.com/tidwall/sjson v1.2.5\n\tgithub.com/tiktoken-go/tokenizer v0.7.0\n\tgolang.org/x/crypto v0.45.0\n\tgolang.org/x/net v0.47.0\n\tgolang.org/x/oauth2 v0.30.0\n\tgolang.org/x/sync v0.18.0\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tcloud.google.com/go/compute/metadata v0.3.0 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/ProtonMail/go-crypto v1.3.0 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/bytedance/sonic v1.11.6 // indirect\n\tgithub.com/bytedance/sonic/loader v0.1.1 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.4.1 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.11.6 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.15 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.2 // indirect\n\tgithub.com/clipperhouse/displaywidth v0.9.0 // indirect\n\tgithub.com/clipperhouse/stringish v0.1.1 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.5.0 // indirect\n\tgithub.com/cloudflare/circl v1.6.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/cyphar/filepath-securejoin v0.4.1 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/emirpasic/gods v1.18.1 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.3 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-git/gcfg/v2 v2.0.2 // indirect\n\tgithub.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.20.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/kevinburke/ssh_config v1.4.0 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.3.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.19 // indirect\n\tgithub.com/minio/md5-simd v1.1.2 // indirect\n\tgithub.com/minio/sha256-simd v1.0.1 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.2 // indirect\n\tgithub.com/pjbgf/sha1cd v0.5.0 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/rs/xid v1.5.0 // indirect\n\tgithub.com/sergi/go-diff v1.4.0 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgolang.org/x/arch v0.8.0 // indirect\n\tgolang.org/x/sys v0.38.0 // indirect\n\tgolang.org/x/text v0.31.0 // indirect\n\tgoogle.golang.org/protobuf v1.34.1 // indirect\n\tgopkg.in/ini.v1 v1.67.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=\ncloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=\ngithub.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=\ngithub.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=\ngithub.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=\ngithub.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=\ngithub.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=\ngithub.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=\ngithub.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=\ngithub.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=\ngithub.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=\ngithub.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=\ngithub.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=\ngithub.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=\ngithub.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=\ngithub.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=\ngithub.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=\ngithub.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=\ngithub.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=\ngithub.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=\ngithub.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=\ngithub.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=\ngithub.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=\ngithub.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=\ngithub.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=\ngithub.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=\ngithub.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=\ngithub.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=\ngithub.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=\ngithub.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=\ngithub.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=\ngithub.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=\ngithub.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=\ngithub.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=\ngithub.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=\ngithub.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=\ngithub.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=\ngithub.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=\ngithub.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=\ngithub.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=\ngithub.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=\ngithub.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=\ngithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=\ngithub.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=\ngithub.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=\ngithub.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=\ngithub.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=\ngithub.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=\ngithub.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=\ngithub.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=\ngithub.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=\ngithub.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=\ngithub.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=\ngithub.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=\ngithub.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=\ngithub.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=\ngithub.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=\ngithub.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=\ngithub.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=\ngithub.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=\ngithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngithub.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=\ngithub.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=\ngolang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=\ngolang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=\ngolang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=\ngolang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=\ngolang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=\ngolang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=\ngoogle.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\n"
  },
  {
    "path": "internal/access/config_access/provider.go",
    "content": "package configaccess\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\n// Register ensures the config-access provider is available to the access manager.\nfunc Register(cfg *sdkconfig.SDKConfig) {\n\tif cfg == nil {\n\t\tsdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)\n\t\treturn\n\t}\n\n\tkeys := normalizeKeys(cfg.APIKeys)\n\tif len(keys) == 0 {\n\t\tsdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)\n\t\treturn\n\t}\n\n\tsdkaccess.RegisterProvider(\n\t\tsdkaccess.AccessProviderTypeConfigAPIKey,\n\t\tnewProvider(sdkaccess.DefaultAccessProviderName, keys),\n\t)\n}\n\ntype provider struct {\n\tname string\n\tkeys map[string]struct{}\n}\n\nfunc newProvider(name string, keys []string) *provider {\n\tproviderName := strings.TrimSpace(name)\n\tif providerName == \"\" {\n\t\tproviderName = sdkaccess.DefaultAccessProviderName\n\t}\n\tkeySet := make(map[string]struct{}, len(keys))\n\tfor _, key := range keys {\n\t\tkeySet[key] = struct{}{}\n\t}\n\treturn &provider{name: providerName, keys: keySet}\n}\n\nfunc (p *provider) Identifier() string {\n\tif p == nil || p.name == \"\" {\n\t\treturn sdkaccess.DefaultAccessProviderName\n\t}\n\treturn p.name\n}\n\nfunc (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {\n\tif p == nil {\n\t\treturn nil, sdkaccess.NewNotHandledError()\n\t}\n\tif len(p.keys) == 0 {\n\t\treturn nil, sdkaccess.NewNotHandledError()\n\t}\n\tauthHeader := r.Header.Get(\"Authorization\")\n\tauthHeaderGoogle := r.Header.Get(\"X-Goog-Api-Key\")\n\tauthHeaderAnthropic := r.Header.Get(\"X-Api-Key\")\n\tqueryKey := \"\"\n\tqueryAuthToken := \"\"\n\tif r.URL != nil {\n\t\tqueryKey = r.URL.Query().Get(\"key\")\n\t\tqueryAuthToken = r.URL.Query().Get(\"auth_token\")\n\t}\n\tif authHeader == \"\" && authHeaderGoogle == \"\" && authHeaderAnthropic == \"\" && queryKey == \"\" && queryAuthToken == \"\" {\n\t\treturn nil, sdkaccess.NewNoCredentialsError()\n\t}\n\n\tapiKey := extractBearerToken(authHeader)\n\n\tcandidates := []struct {\n\t\tvalue  string\n\t\tsource string\n\t}{\n\t\t{apiKey, \"authorization\"},\n\t\t{authHeaderGoogle, \"x-goog-api-key\"},\n\t\t{authHeaderAnthropic, \"x-api-key\"},\n\t\t{queryKey, \"query-key\"},\n\t\t{queryAuthToken, \"query-auth-token\"},\n\t}\n\n\tfor _, candidate := range candidates {\n\t\tif candidate.value == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := p.keys[candidate.value]; ok {\n\t\t\treturn &sdkaccess.Result{\n\t\t\t\tProvider:  p.Identifier(),\n\t\t\t\tPrincipal: candidate.value,\n\t\t\t\tMetadata: map[string]string{\n\t\t\t\t\t\"source\": candidate.source,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn nil, sdkaccess.NewInvalidCredentialError()\n}\n\nfunc extractBearerToken(header string) string {\n\tif header == \"\" {\n\t\treturn \"\"\n\t}\n\tparts := strings.SplitN(header, \" \", 2)\n\tif len(parts) != 2 {\n\t\treturn header\n\t}\n\tif strings.ToLower(parts[0]) != \"bearer\" {\n\t\treturn header\n\t}\n\treturn strings.TrimSpace(parts[1])\n}\n\nfunc normalizeKeys(keys []string) []string {\n\tif len(keys) == 0 {\n\t\treturn nil\n\t}\n\tnormalized := make([]string, 0, len(keys))\n\tseen := make(map[string]struct{}, len(keys))\n\tfor _, key := range keys {\n\t\ttrimmedKey := strings.TrimSpace(key)\n\t\tif trimmedKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[trimmedKey]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmedKey] = struct{}{}\n\t\tnormalized = append(normalized, trimmedKey)\n\t}\n\tif len(normalized) == 0 {\n\t\treturn nil\n\t}\n\treturn normalized\n}\n"
  },
  {
    "path": "internal/access/reconcile.go",
    "content": "package access\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\n\tconfigaccess \"github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ReconcileProviders builds the desired provider list by reusing existing providers when possible\n// and creating or removing providers only when their configuration changed. It returns the final\n// ordered provider slice along with the identifiers of providers that were added, updated, or\n// removed compared to the previous configuration.\nfunc ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {\n\t_ = oldCfg\n\tif newCfg == nil {\n\t\treturn nil, nil, nil, nil, nil\n\t}\n\n\tresult = sdkaccess.RegisteredProviders()\n\n\texistingMap := make(map[string]sdkaccess.Provider, len(existing))\n\tfor _, provider := range existing {\n\t\tproviderID := identifierFromProvider(provider)\n\t\tif providerID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\texistingMap[providerID] = provider\n\t}\n\n\tfinalIDs := make(map[string]struct{}, len(result))\n\n\tisInlineProvider := func(id string) bool {\n\t\treturn strings.EqualFold(id, sdkaccess.DefaultAccessProviderName)\n\t}\n\tappendChange := func(list *[]string, id string) {\n\t\tif isInlineProvider(id) {\n\t\t\treturn\n\t\t}\n\t\t*list = append(*list, id)\n\t}\n\n\tfor _, provider := range result {\n\t\tproviderID := identifierFromProvider(provider)\n\t\tif providerID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfinalIDs[providerID] = struct{}{}\n\n\t\texistingProvider, exists := existingMap[providerID]\n\t\tif !exists {\n\t\t\tappendChange(&added, providerID)\n\t\t\tcontinue\n\t\t}\n\t\tif !providerInstanceEqual(existingProvider, provider) {\n\t\t\tappendChange(&updated, providerID)\n\t\t}\n\t}\n\n\tfor providerID := range existingMap {\n\t\tif _, exists := finalIDs[providerID]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tappendChange(&removed, providerID)\n\t}\n\n\tsort.Strings(added)\n\tsort.Strings(updated)\n\tsort.Strings(removed)\n\n\treturn result, added, updated, removed, nil\n}\n\n// ApplyAccessProviders reconciles the configured access providers against the\n// currently registered providers and updates the manager. It logs a concise\n// summary of the detected changes and returns whether any provider changed.\nfunc ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {\n\tif manager == nil || newCfg == nil {\n\t\treturn false, nil\n\t}\n\n\texisting := manager.Providers()\n\tconfigaccess.Register(&newCfg.SDKConfig)\n\tproviders, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)\n\tif err != nil {\n\t\tlog.Errorf(\"failed to reconcile request auth providers: %v\", err)\n\t\treturn false, fmt.Errorf(\"reconciling access providers: %w\", err)\n\t}\n\n\tmanager.SetProviders(providers)\n\n\tif len(added)+len(updated)+len(removed) > 0 {\n\t\tlog.Debugf(\"auth providers reconciled (added=%d updated=%d removed=%d)\", len(added), len(updated), len(removed))\n\t\tlog.Debugf(\"auth providers changes details - added=%v updated=%v removed=%v\", added, updated, removed)\n\t\treturn true, nil\n\t}\n\n\tlog.Debug(\"auth providers unchanged after config update\")\n\treturn false, nil\n}\n\nfunc identifierFromProvider(provider sdkaccess.Provider) string {\n\tif provider == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(provider.Identifier())\n}\n\nfunc providerInstanceEqual(a, b sdkaccess.Provider) bool {\n\tif a == nil || b == nil {\n\t\treturn a == nil && b == nil\n\t}\n\tif reflect.TypeOf(a) != reflect.TypeOf(b) {\n\t\treturn false\n\t}\n\tvalueA := reflect.ValueOf(a)\n\tvalueB := reflect.ValueOf(b)\n\tif valueA.Kind() == reflect.Pointer && valueB.Kind() == reflect.Pointer {\n\t\treturn valueA.Pointer() == valueB.Pointer()\n\t}\n\treturn reflect.DeepEqual(a, b)\n}\n"
  },
  {
    "path": "internal/api/handlers/management/api_tools.go",
    "content": "package management\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/google\"\n)\n\nconst defaultAPICallTimeout = 60 * time.Second\n\nconst (\n\tgeminiOAuthClientID     = \"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com\"\n\tgeminiOAuthClientSecret = \"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\"\n)\n\nvar geminiOAuthScopes = []string{\n\t\"https://www.googleapis.com/auth/cloud-platform\",\n\t\"https://www.googleapis.com/auth/userinfo.email\",\n\t\"https://www.googleapis.com/auth/userinfo.profile\",\n}\n\nconst (\n\tantigravityOAuthClientID     = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\"\n\tantigravityOAuthClientSecret = \"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\"\n)\n\nvar antigravityOAuthTokenURL = \"https://oauth2.googleapis.com/token\"\n\ntype apiCallRequest struct {\n\tAuthIndexSnake  *string           `json:\"auth_index\"`\n\tAuthIndexCamel  *string           `json:\"authIndex\"`\n\tAuthIndexPascal *string           `json:\"AuthIndex\"`\n\tMethod          string            `json:\"method\"`\n\tURL             string            `json:\"url\"`\n\tHeader          map[string]string `json:\"header\"`\n\tData            string            `json:\"data\"`\n}\n\ntype apiCallResponse struct {\n\tStatusCode int                 `json:\"status_code\"`\n\tHeader     map[string][]string `json:\"header\"`\n\tBody       string              `json:\"body\"`\n}\n\n// APICall makes a generic HTTP request on behalf of the management API caller.\n// It is protected by the management middleware.\n//\n// Endpoint:\n//\n//\tPOST /v0/management/api-call\n//\n// Authentication:\n//\n//\tSame as other management APIs (requires a management key and remote-management rules).\n//\tYou can provide the key via:\n//\t- Authorization: Bearer <key>\n//\t- X-Management-Key: <key>\n//\n// Request JSON:\n//   - auth_index / authIndex / AuthIndex (optional):\n//     The credential \"auth_index\" from GET /v0/management/auth-files (or other endpoints returning it).\n//     If omitted or not found, credential-specific proxy/token substitution is skipped.\n//   - method (required): HTTP method, e.g. GET, POST, PUT, PATCH, DELETE.\n//   - url (required): Absolute URL including scheme and host, e.g. \"https://api.example.com/v1/ping\".\n//   - header (optional): Request headers map.\n//     Supports magic variable \"$TOKEN$\" which is replaced using the selected credential:\n//     1) metadata.access_token\n//     2) attributes.api_key\n//     3) metadata.token / metadata.id_token / metadata.cookie\n//     Example: {\"Authorization\":\"Bearer $TOKEN$\"}.\n//     Note: if you need to override the HTTP Host header, set header[\"Host\"].\n//   - data (optional): Raw request body as string (useful for POST/PUT/PATCH).\n//\n// Proxy selection (highest priority first):\n//  1. Selected credential proxy_url\n//  2. Global config proxy-url\n//  3. Direct connect (environment proxies are not used)\n//\n// Response JSON (returned with HTTP 200 when the APICall itself succeeds):\n//   - status_code: Upstream HTTP status code.\n//   - header: Upstream response headers.\n//   - body: Upstream response body as string.\n//\n// Example:\n//\n//\tcurl -sS -X POST \"http://127.0.0.1:8317/v0/management/api-call\" \\\n//\t  -H \"Authorization: Bearer <MANAGEMENT_KEY>\" \\\n//\t  -H \"Content-Type: application/json\" \\\n//\t  -d '{\"auth_index\":\"<AUTH_INDEX>\",\"method\":\"GET\",\"url\":\"https://api.example.com/v1/ping\",\"header\":{\"Authorization\":\"Bearer $TOKEN$\"}}'\n//\n//\tcurl -sS -X POST \"http://127.0.0.1:8317/v0/management/api-call\" \\\n//\t  -H \"Authorization: Bearer 831227\" \\\n//\t  -H \"Content-Type: application/json\" \\\n//\t  -d '{\"auth_index\":\"<AUTH_INDEX>\",\"method\":\"POST\",\"url\":\"https://api.example.com/v1/fetchAvailableModels\",\"header\":{\"Authorization\":\"Bearer $TOKEN$\",\"Content-Type\":\"application/json\",\"User-Agent\":\"cliproxyapi\"},\"data\":\"{}\"}'\nfunc (h *Handler) APICall(c *gin.Context) {\n\tvar body apiCallRequest\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\n\tmethod := strings.ToUpper(strings.TrimSpace(body.Method))\n\tif method == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"missing method\"})\n\t\treturn\n\t}\n\n\turlStr := strings.TrimSpace(body.URL)\n\tif urlStr == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"missing url\"})\n\t\treturn\n\t}\n\tparsedURL, errParseURL := url.Parse(urlStr)\n\tif errParseURL != nil || parsedURL.Scheme == \"\" || parsedURL.Host == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid url\"})\n\t\treturn\n\t}\n\n\tauthIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel, body.AuthIndexPascal)\n\tauth := h.authByIndex(authIndex)\n\n\treqHeaders := body.Header\n\tif reqHeaders == nil {\n\t\treqHeaders = map[string]string{}\n\t}\n\n\tvar hostOverride string\n\tvar token string\n\tvar tokenResolved bool\n\tvar tokenErr error\n\tfor key, value := range reqHeaders {\n\t\tif !strings.Contains(value, \"$TOKEN$\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !tokenResolved {\n\t\t\ttoken, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth)\n\t\t\ttokenResolved = true\n\t\t}\n\t\tif auth != nil && token == \"\" {\n\t\t\tif tokenErr != nil {\n\t\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"auth token refresh failed\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"auth token not found\"})\n\t\t\treturn\n\t\t}\n\t\tif token == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treqHeaders[key] = strings.ReplaceAll(value, \"$TOKEN$\", token)\n\t}\n\n\tvar requestBody io.Reader\n\tif body.Data != \"\" {\n\t\trequestBody = strings.NewReader(body.Data)\n\t}\n\n\treq, errNewRequest := http.NewRequestWithContext(c.Request.Context(), method, urlStr, requestBody)\n\tif errNewRequest != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"failed to build request\"})\n\t\treturn\n\t}\n\n\tfor key, value := range reqHeaders {\n\t\tif strings.EqualFold(key, \"host\") {\n\t\t\thostOverride = strings.TrimSpace(value)\n\t\t\tcontinue\n\t\t}\n\t\treq.Header.Set(key, value)\n\t}\n\tif hostOverride != \"\" {\n\t\treq.Host = hostOverride\n\t}\n\n\thttpClient := &http.Client{\n\t\tTimeout: defaultAPICallTimeout,\n\t}\n\thttpClient.Transport = h.apiCallTransport(auth)\n\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\tlog.WithError(errDo).Debug(\"management APICall request failed\")\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"request failed\"})\n\t\treturn\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\trespBody, errReadAll := io.ReadAll(resp.Body)\n\tif errReadAll != nil {\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"failed to read response\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, apiCallResponse{\n\t\tStatusCode: resp.StatusCode,\n\t\tHeader:     resp.Header,\n\t\tBody:       string(respBody),\n\t})\n}\n\nfunc firstNonEmptyString(values ...*string) string {\n\tfor _, v := range values {\n\t\tif v == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif out := strings.TrimSpace(*v); out != \"\" {\n\t\t\treturn out\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc tokenValueForAuth(auth *coreauth.Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif v := tokenValueFromMetadata(auth.Metadata); v != \"\" {\n\t\treturn v\n\t}\n\tif auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"api_key\"]); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\tif shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {\n\t\tif v := tokenValueFromMetadata(shared.MetadataSnapshot()); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", nil\n\t}\n\n\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\tif provider == \"gemini-cli\" {\n\t\ttoken, errToken := h.refreshGeminiOAuthAccessToken(ctx, auth)\n\t\treturn token, errToken\n\t}\n\tif provider == \"antigravity\" {\n\t\ttoken, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth)\n\t\treturn token, errToken\n\t}\n\n\treturn tokenValueForAuth(auth), nil\n}\n\nfunc (h *Handler) refreshGeminiOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif auth == nil {\n\t\treturn \"\", nil\n\t}\n\n\tmetadata, updater := geminiOAuthMetadata(auth)\n\tif len(metadata) == 0 {\n\t\treturn \"\", fmt.Errorf(\"gemini oauth metadata missing\")\n\t}\n\n\tbase := make(map[string]any)\n\tif tokenRaw, ok := metadata[\"token\"].(map[string]any); ok && tokenRaw != nil {\n\t\tbase = cloneMap(tokenRaw)\n\t}\n\n\tvar token oauth2.Token\n\tif len(base) > 0 {\n\t\tif raw, errMarshal := json.Marshal(base); errMarshal == nil {\n\t\t\t_ = json.Unmarshal(raw, &token)\n\t\t}\n\t}\n\n\tif token.AccessToken == \"\" {\n\t\ttoken.AccessToken = stringValue(metadata, \"access_token\")\n\t}\n\tif token.RefreshToken == \"\" {\n\t\ttoken.RefreshToken = stringValue(metadata, \"refresh_token\")\n\t}\n\tif token.TokenType == \"\" {\n\t\ttoken.TokenType = stringValue(metadata, \"token_type\")\n\t}\n\tif token.Expiry.IsZero() {\n\t\tif expiry := stringValue(metadata, \"expiry\"); expiry != \"\" {\n\t\t\tif ts, errParseTime := time.Parse(time.RFC3339, expiry); errParseTime == nil {\n\t\t\t\ttoken.Expiry = ts\n\t\t\t}\n\t\t}\n\t}\n\n\tconf := &oauth2.Config{\n\t\tClientID:     geminiOAuthClientID,\n\t\tClientSecret: geminiOAuthClientSecret,\n\t\tScopes:       geminiOAuthScopes,\n\t\tEndpoint:     google.Endpoint,\n\t}\n\n\tctxToken := ctx\n\thttpClient := &http.Client{\n\t\tTimeout:   defaultAPICallTimeout,\n\t\tTransport: h.apiCallTransport(auth),\n\t}\n\tctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)\n\n\tsrc := conf.TokenSource(ctxToken, &token)\n\tcurrentToken, errToken := src.Token()\n\tif errToken != nil {\n\t\treturn \"\", errToken\n\t}\n\n\tmerged := buildOAuthTokenMap(base, currentToken)\n\tfields := buildOAuthTokenFields(currentToken, merged)\n\tif updater != nil {\n\t\tupdater(fields)\n\t}\n\treturn strings.TrimSpace(currentToken.AccessToken), nil\n}\n\nfunc (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif auth == nil {\n\t\treturn \"\", nil\n\t}\n\n\tmetadata := auth.Metadata\n\tif len(metadata) == 0 {\n\t\treturn \"\", fmt.Errorf(\"antigravity oauth metadata missing\")\n\t}\n\n\tcurrent := strings.TrimSpace(tokenValueFromMetadata(metadata))\n\tif current != \"\" && !antigravityTokenNeedsRefresh(metadata) {\n\t\treturn current, nil\n\t}\n\n\trefreshToken := stringValue(metadata, \"refresh_token\")\n\tif refreshToken == \"\" {\n\t\treturn \"\", fmt.Errorf(\"antigravity refresh token missing\")\n\t}\n\n\ttokenURL := strings.TrimSpace(antigravityOAuthTokenURL)\n\tif tokenURL == \"\" {\n\t\ttokenURL = \"https://oauth2.googleapis.com/token\"\n\t}\n\tform := url.Values{}\n\tform.Set(\"client_id\", antigravityOAuthClientID)\n\tform.Set(\"client_secret\", antigravityOAuthClientSecret)\n\tform.Set(\"grant_type\", \"refresh_token\")\n\tform.Set(\"refresh_token\", refreshToken)\n\n\treq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))\n\tif errReq != nil {\n\t\treturn \"\", errReq\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\thttpClient := &http.Client{\n\t\tTimeout:   defaultAPICallTimeout,\n\t\tTransport: h.apiCallTransport(auth),\n\t}\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn \"\", errDo\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, errRead := io.ReadAll(resp.Body)\n\tif errRead != nil {\n\t\treturn \"\", errRead\n\t}\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\treturn \"\", fmt.Errorf(\"antigravity oauth token refresh failed: status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn    int64  `json:\"expires_in\"`\n\t\tTokenType    string `json:\"token_type\"`\n\t}\n\tif errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {\n\t\treturn \"\", errUnmarshal\n\t}\n\n\tif strings.TrimSpace(tokenResp.AccessToken) == \"\" {\n\t\treturn \"\", fmt.Errorf(\"antigravity oauth token refresh returned empty access_token\")\n\t}\n\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tnow := time.Now()\n\tauth.Metadata[\"access_token\"] = strings.TrimSpace(tokenResp.AccessToken)\n\tif strings.TrimSpace(tokenResp.RefreshToken) != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = strings.TrimSpace(tokenResp.RefreshToken)\n\t}\n\tif tokenResp.ExpiresIn > 0 {\n\t\tauth.Metadata[\"expires_in\"] = tokenResp.ExpiresIn\n\t\tauth.Metadata[\"timestamp\"] = now.UnixMilli()\n\t\tauth.Metadata[\"expired\"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)\n\t}\n\tauth.Metadata[\"type\"] = \"antigravity\"\n\n\tif h != nil && h.authManager != nil {\n\t\tauth.LastRefreshedAt = now\n\t\tauth.UpdatedAt = now\n\t\t_, _ = h.authManager.Update(ctx, auth)\n\t}\n\n\treturn strings.TrimSpace(tokenResp.AccessToken), nil\n}\n\nfunc antigravityTokenNeedsRefresh(metadata map[string]any) bool {\n\t// Refresh a bit early to avoid requests racing token expiry.\n\tconst skew = 30 * time.Second\n\n\tif metadata == nil {\n\t\treturn true\n\t}\n\tif expStr, ok := metadata[\"expired\"].(string); ok {\n\t\tif ts, errParse := time.Parse(time.RFC3339, strings.TrimSpace(expStr)); errParse == nil {\n\t\t\treturn !ts.After(time.Now().Add(skew))\n\t\t}\n\t}\n\texpiresIn := int64Value(metadata[\"expires_in\"])\n\ttimestampMs := int64Value(metadata[\"timestamp\"])\n\tif expiresIn > 0 && timestampMs > 0 {\n\t\texp := time.UnixMilli(timestampMs).Add(time.Duration(expiresIn) * time.Second)\n\t\treturn !exp.After(time.Now().Add(skew))\n\t}\n\treturn true\n}\n\nfunc int64Value(raw any) int64 {\n\tswitch typed := raw.(type) {\n\tcase int:\n\t\treturn int64(typed)\n\tcase int32:\n\t\treturn int64(typed)\n\tcase int64:\n\t\treturn typed\n\tcase uint:\n\t\treturn int64(typed)\n\tcase uint32:\n\t\treturn int64(typed)\n\tcase uint64:\n\t\tif typed > uint64(^uint64(0)>>1) {\n\t\t\treturn 0\n\t\t}\n\t\treturn int64(typed)\n\tcase float32:\n\t\treturn int64(typed)\n\tcase float64:\n\t\treturn int64(typed)\n\tcase json.Number:\n\t\tif i, errParse := typed.Int64(); errParse == nil {\n\t\t\treturn i\n\t\t}\n\tcase string:\n\t\tif s := strings.TrimSpace(typed); s != \"\" {\n\t\t\tif i, errParse := json.Number(s).Int64(); errParse == nil {\n\t\t\t\treturn i\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc geminiOAuthMetadata(auth *coreauth.Auth) (map[string]any, func(map[string]any)) {\n\tif auth == nil {\n\t\treturn nil, nil\n\t}\n\tif shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {\n\t\tsnapshot := shared.MetadataSnapshot()\n\t\treturn snapshot, func(fields map[string]any) { shared.MergeMetadata(fields) }\n\t}\n\treturn auth.Metadata, func(fields map[string]any) {\n\t\tif auth.Metadata == nil {\n\t\t\tauth.Metadata = make(map[string]any)\n\t\t}\n\t\tfor k, v := range fields {\n\t\t\tauth.Metadata[k] = v\n\t\t}\n\t}\n}\n\nfunc stringValue(metadata map[string]any, key string) string {\n\tif len(metadata) == 0 || key == \"\" {\n\t\treturn \"\"\n\t}\n\tif v, ok := metadata[key].(string); ok {\n\t\treturn strings.TrimSpace(v)\n\t}\n\treturn \"\"\n}\n\nfunc cloneMap(in map[string]any) map[string]any {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]any, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc buildOAuthTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {\n\tmerged := cloneMap(base)\n\tif merged == nil {\n\t\tmerged = make(map[string]any)\n\t}\n\tif tok == nil {\n\t\treturn merged\n\t}\n\tif raw, errMarshal := json.Marshal(tok); errMarshal == nil {\n\t\tvar tokenMap map[string]any\n\t\tif errUnmarshal := json.Unmarshal(raw, &tokenMap); errUnmarshal == nil {\n\t\t\tfor k, v := range tokenMap {\n\t\t\t\tmerged[k] = v\n\t\t\t}\n\t\t}\n\t}\n\treturn merged\n}\n\nfunc buildOAuthTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any {\n\tfields := make(map[string]any, 5)\n\tif tok != nil && tok.AccessToken != \"\" {\n\t\tfields[\"access_token\"] = tok.AccessToken\n\t}\n\tif tok != nil && tok.TokenType != \"\" {\n\t\tfields[\"token_type\"] = tok.TokenType\n\t}\n\tif tok != nil && tok.RefreshToken != \"\" {\n\t\tfields[\"refresh_token\"] = tok.RefreshToken\n\t}\n\tif tok != nil && !tok.Expiry.IsZero() {\n\t\tfields[\"expiry\"] = tok.Expiry.Format(time.RFC3339)\n\t}\n\tif len(merged) > 0 {\n\t\tfields[\"token\"] = cloneMap(merged)\n\t}\n\treturn fields\n}\n\nfunc tokenValueFromMetadata(metadata map[string]any) string {\n\tif len(metadata) == 0 {\n\t\treturn \"\"\n\t}\n\tif v, ok := metadata[\"accessToken\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\treturn strings.TrimSpace(v)\n\t}\n\tif v, ok := metadata[\"access_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\treturn strings.TrimSpace(v)\n\t}\n\tif tokenRaw, ok := metadata[\"token\"]; ok && tokenRaw != nil {\n\t\tswitch typed := tokenRaw.(type) {\n\t\tcase string:\n\t\t\tif v := strings.TrimSpace(typed); v != \"\" {\n\t\t\t\treturn v\n\t\t\t}\n\t\tcase map[string]any:\n\t\t\tif v, ok := typed[\"access_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\t\t\treturn strings.TrimSpace(v)\n\t\t\t}\n\t\t\tif v, ok := typed[\"accessToken\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\t\t\treturn strings.TrimSpace(v)\n\t\t\t}\n\t\tcase map[string]string:\n\t\t\tif v := strings.TrimSpace(typed[\"access_token\"]); v != \"\" {\n\t\t\t\treturn v\n\t\t\t}\n\t\t\tif v := strings.TrimSpace(typed[\"accessToken\"]); v != \"\" {\n\t\t\t\treturn v\n\t\t\t}\n\t\t}\n\t}\n\tif v, ok := metadata[\"token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\treturn strings.TrimSpace(v)\n\t}\n\tif v, ok := metadata[\"id_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\treturn strings.TrimSpace(v)\n\t}\n\tif v, ok := metadata[\"cookie\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\treturn strings.TrimSpace(v)\n\t}\n\treturn \"\"\n}\n\nfunc (h *Handler) authByIndex(authIndex string) *coreauth.Auth {\n\tauthIndex = strings.TrimSpace(authIndex)\n\tif authIndex == \"\" || h == nil || h.authManager == nil {\n\t\treturn nil\n\t}\n\tauths := h.authManager.List()\n\tfor _, auth := range auths {\n\t\tif auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tauth.EnsureIndex()\n\t\tif auth.Index == authIndex {\n\t\t\treturn auth\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {\n\tvar proxyCandidates []string\n\tif auth != nil {\n\t\tif proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != \"\" {\n\t\t\tproxyCandidates = append(proxyCandidates, proxyStr)\n\t\t}\n\t}\n\tif h != nil && h.cfg != nil {\n\t\tif proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != \"\" {\n\t\t\tproxyCandidates = append(proxyCandidates, proxyStr)\n\t\t}\n\t}\n\n\tfor _, proxyStr := range proxyCandidates {\n\t\tif transport := buildProxyTransport(proxyStr); transport != nil {\n\t\t\treturn transport\n\t\t}\n\t}\n\n\ttransport, ok := http.DefaultTransport.(*http.Transport)\n\tif !ok || transport == nil {\n\t\treturn &http.Transport{Proxy: nil}\n\t}\n\tclone := transport.Clone()\n\tclone.Proxy = nil\n\treturn clone\n}\n\nfunc buildProxyTransport(proxyStr string) *http.Transport {\n\ttransport, _, errBuild := proxyutil.BuildHTTPTransport(proxyStr)\n\tif errBuild != nil {\n\t\tlog.WithError(errBuild).Debug(\"build proxy transport failed\")\n\t\treturn nil\n\t}\n\treturn transport\n}\n"
  },
  {
    "path": "internal/api/handlers/management/api_tools_test.go",
    "content": "package management\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) {\n\tt.Parallel()\n\n\th := &Handler{\n\t\tcfg: &config.Config{\n\t\t\tSDKConfig: sdkconfig.SDKConfig{ProxyURL: \"http://global-proxy.example.com:8080\"},\n\t\t},\n\t}\n\n\ttransport := h.apiCallTransport(&coreauth.Auth{ProxyURL: \"direct\"})\n\thttpTransport, ok := transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"transport type = %T, want *http.Transport\", transport)\n\t}\n\tif httpTransport.Proxy != nil {\n\t\tt.Fatal(\"expected direct transport to disable proxy function\")\n\t}\n}\n\nfunc TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) {\n\tt.Parallel()\n\n\th := &Handler{\n\t\tcfg: &config.Config{\n\t\t\tSDKConfig: sdkconfig.SDKConfig{ProxyURL: \"http://global-proxy.example.com:8080\"},\n\t\t},\n\t}\n\n\ttransport := h.apiCallTransport(&coreauth.Auth{ProxyURL: \"bad-value\"})\n\thttpTransport, ok := transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"transport type = %T, want *http.Transport\", transport)\n\t}\n\n\treq, errRequest := http.NewRequest(http.MethodGet, \"https://example.com\", nil)\n\tif errRequest != nil {\n\t\tt.Fatalf(\"http.NewRequest returned error: %v\", errRequest)\n\t}\n\n\tproxyURL, errProxy := httpTransport.Proxy(req)\n\tif errProxy != nil {\n\t\tt.Fatalf(\"httpTransport.Proxy returned error: %v\", errProxy)\n\t}\n\tif proxyURL == nil || proxyURL.String() != \"http://global-proxy.example.com:8080\" {\n\t\tt.Fatalf(\"proxy URL = %v, want http://global-proxy.example.com:8080\", proxyURL)\n\t}\n}\n\nfunc TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tgeminiAuth := &coreauth.Auth{\n\t\tID:       \"gemini:apikey:123\",\n\t\tProvider: \"gemini\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": \"shared-key\",\n\t\t},\n\t}\n\tcompatAuth := &coreauth.Auth{\n\t\tID:       \"openai-compatibility:bohe:456\",\n\t\tProvider: \"bohe\",\n\t\tLabel:    \"bohe\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\":      \"shared-key\",\n\t\t\t\"compat_name\":  \"bohe\",\n\t\t\t\"provider_key\": \"bohe\",\n\t\t},\n\t}\n\n\tif _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil {\n\t\tt.Fatalf(\"register gemini auth: %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), compatAuth); errRegister != nil {\n\t\tt.Fatalf(\"register compat auth: %v\", errRegister)\n\t}\n\n\tgeminiIndex := geminiAuth.EnsureIndex()\n\tcompatIndex := compatAuth.EnsureIndex()\n\tif geminiIndex == compatIndex {\n\t\tt.Fatalf(\"shared api key produced duplicate auth_index %q\", geminiIndex)\n\t}\n\n\th := &Handler{authManager: manager}\n\n\tgotGemini := h.authByIndex(geminiIndex)\n\tif gotGemini == nil {\n\t\tt.Fatal(\"expected gemini auth by index\")\n\t}\n\tif gotGemini.ID != geminiAuth.ID {\n\t\tt.Fatalf(\"authByIndex(gemini) returned %q, want %q\", gotGemini.ID, geminiAuth.ID)\n\t}\n\n\tgotCompat := h.authByIndex(compatIndex)\n\tif gotCompat == nil {\n\t\tt.Fatal(\"expected compat auth by index\")\n\t}\n\tif gotCompat.ID != compatAuth.ID {\n\t\tt.Fatalf(\"authByIndex(compat) returned %q, want %q\", gotCompat.ID, compatAuth.ID)\n\t}\n}\n"
  },
  {
    "path": "internal/api/handlers/management/auth_files.go",
    "content": "package management\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\tgeminiAuth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini\"\n\tiflowauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/google\"\n)\n\nvar lastRefreshKeys = []string{\"last_refresh\", \"lastRefresh\", \"last_refreshed_at\", \"lastRefreshedAt\"}\n\nconst (\n\tanthropicCallbackPort = 54545\n\tgeminiCallbackPort    = 8085\n\tcodexCallbackPort     = 1455\n\tgeminiCLIEndpoint     = \"https://cloudcode-pa.googleapis.com\"\n\tgeminiCLIVersion      = \"v1internal\"\n)\n\ntype callbackForwarder struct {\n\tprovider string\n\tserver   *http.Server\n\tdone     chan struct{}\n}\n\nvar (\n\tcallbackForwardersMu sync.Mutex\n\tcallbackForwarders   = make(map[int]*callbackForwarder)\n)\n\nfunc extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {\n\tif len(meta) == 0 {\n\t\treturn time.Time{}, false\n\t}\n\tfor _, key := range lastRefreshKeys {\n\t\tif val, ok := meta[key]; ok {\n\t\t\tif ts, ok1 := parseLastRefreshValue(val); ok1 {\n\t\t\t\treturn ts, true\n\t\t\t}\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc parseLastRefreshValue(v any) (time.Time, bool) {\n\tswitch val := v.(type) {\n\tcase string:\n\t\ts := strings.TrimSpace(val)\n\t\tif s == \"\" {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\tlayouts := []string{time.RFC3339, time.RFC3339Nano, \"2006-01-02 15:04:05\", \"2006-01-02T15:04:05Z07:00\"}\n\t\tfor _, layout := range layouts {\n\t\t\tif ts, err := time.Parse(layout, s); err == nil {\n\t\t\t\treturn ts.UTC(), true\n\t\t\t}\n\t\t}\n\t\tif unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {\n\t\t\treturn time.Unix(unix, 0).UTC(), true\n\t\t}\n\tcase float64:\n\t\tif val <= 0 {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\treturn time.Unix(int64(val), 0).UTC(), true\n\tcase int64:\n\t\tif val <= 0 {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\treturn time.Unix(val, 0).UTC(), true\n\tcase int:\n\t\tif val <= 0 {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\treturn time.Unix(int64(val), 0).UTC(), true\n\tcase json.Number:\n\t\tif i, err := val.Int64(); err == nil && i > 0 {\n\t\t\treturn time.Unix(i, 0).UTC(), true\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc isWebUIRequest(c *gin.Context) bool {\n\traw := strings.TrimSpace(c.Query(\"is_webui\"))\n\tif raw == \"\" {\n\t\treturn false\n\t}\n\tswitch strings.ToLower(raw) {\n\tcase \"1\", \"true\", \"yes\", \"on\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {\n\tcallbackForwardersMu.Lock()\n\tprev := callbackForwarders[port]\n\tif prev != nil {\n\t\tdelete(callbackForwarders, port)\n\t}\n\tcallbackForwardersMu.Unlock()\n\n\tif prev != nil {\n\t\tstopForwarderInstance(port, prev)\n\t}\n\n\taddr := fmt.Sprintf(\"127.0.0.1:%d\", port)\n\tln, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to listen on %s: %w\", addr, err)\n\t}\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttarget := targetBase\n\t\tif raw := r.URL.RawQuery; raw != \"\" {\n\t\t\tif strings.Contains(target, \"?\") {\n\t\t\t\ttarget = target + \"&\" + raw\n\t\t\t} else {\n\t\t\t\ttarget = target + \"?\" + raw\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\t\thttp.Redirect(w, r, target, http.StatusFound)\n\t})\n\n\tsrv := &http.Server{\n\t\tHandler:           handler,\n\t\tReadHeaderTimeout: 5 * time.Second,\n\t\tWriteTimeout:      5 * time.Second,\n\t}\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\tif errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {\n\t\t\tlog.WithError(errServe).Warnf(\"callback forwarder for %s stopped unexpectedly\", provider)\n\t\t}\n\t\tclose(done)\n\t}()\n\n\tforwarder := &callbackForwarder{\n\t\tprovider: provider,\n\t\tserver:   srv,\n\t\tdone:     done,\n\t}\n\n\tcallbackForwardersMu.Lock()\n\tcallbackForwarders[port] = forwarder\n\tcallbackForwardersMu.Unlock()\n\n\tlog.Infof(\"callback forwarder for %s listening on %s\", provider, addr)\n\n\treturn forwarder, nil\n}\n\nfunc stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {\n\tif forwarder == nil {\n\t\treturn\n\t}\n\tcallbackForwardersMu.Lock()\n\tif current := callbackForwarders[port]; current == forwarder {\n\t\tdelete(callbackForwarders, port)\n\t}\n\tcallbackForwardersMu.Unlock()\n\n\tstopForwarderInstance(port, forwarder)\n}\n\nfunc stopForwarderInstance(port int, forwarder *callbackForwarder) {\n\tif forwarder == nil || forwarder.server == nil {\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tif err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\tlog.WithError(err).Warnf(\"failed to shut down callback forwarder on port %d\", port)\n\t}\n\n\tselect {\n\tcase <-forwarder.done:\n\tcase <-time.After(2 * time.Second):\n\t}\n\n\tlog.Infof(\"callback forwarder on port %d stopped\", port)\n}\n\nfunc (h *Handler) managementCallbackURL(path string) (string, error) {\n\tif h == nil || h.cfg == nil || h.cfg.Port <= 0 {\n\t\treturn \"\", fmt.Errorf(\"server port is not configured\")\n\t}\n\tif !strings.HasPrefix(path, \"/\") {\n\t\tpath = \"/\" + path\n\t}\n\tscheme := \"http\"\n\tif h.cfg.TLS.Enable {\n\t\tscheme = \"https\"\n\t}\n\treturn fmt.Sprintf(\"%s://127.0.0.1:%d%s\", scheme, h.cfg.Port, path), nil\n}\n\nfunc (h *Handler) ListAuthFiles(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(500, gin.H{\"error\": \"handler not initialized\"})\n\t\treturn\n\t}\n\tif h.authManager == nil {\n\t\th.listAuthFilesFromDisk(c)\n\t\treturn\n\t}\n\tauths := h.authManager.List()\n\tfiles := make([]gin.H, 0, len(auths))\n\tfor _, auth := range auths {\n\t\tif entry := h.buildAuthFileEntry(auth); entry != nil {\n\t\t\tfiles = append(files, entry)\n\t\t}\n\t}\n\tsort.Slice(files, func(i, j int) bool {\n\t\tnameI, _ := files[i][\"name\"].(string)\n\t\tnameJ, _ := files[j][\"name\"].(string)\n\t\treturn strings.ToLower(nameI) < strings.ToLower(nameJ)\n\t})\n\tc.JSON(200, gin.H{\"files\": files})\n}\n\n// GetAuthFileModels returns the models supported by a specific auth file\nfunc (h *Handler) GetAuthFileModels(c *gin.Context) {\n\tname := c.Query(\"name\")\n\tif name == \"\" {\n\t\tc.JSON(400, gin.H{\"error\": \"name is required\"})\n\t\treturn\n\t}\n\n\t// Try to find auth ID via authManager\n\tvar authID string\n\tif h.authManager != nil {\n\t\tauths := h.authManager.List()\n\t\tfor _, auth := range auths {\n\t\t\tif auth.FileName == name || auth.ID == name {\n\t\t\t\tauthID = auth.ID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif authID == \"\" {\n\t\tauthID = name // fallback to filename as ID\n\t}\n\n\t// Get models from registry\n\treg := registry.GetGlobalRegistry()\n\tmodels := reg.GetModelsForClient(authID)\n\n\tresult := make([]gin.H, 0, len(models))\n\tfor _, m := range models {\n\t\tentry := gin.H{\n\t\t\t\"id\": m.ID,\n\t\t}\n\t\tif m.DisplayName != \"\" {\n\t\t\tentry[\"display_name\"] = m.DisplayName\n\t\t}\n\t\tif m.Type != \"\" {\n\t\t\tentry[\"type\"] = m.Type\n\t\t}\n\t\tif m.OwnedBy != \"\" {\n\t\t\tentry[\"owned_by\"] = m.OwnedBy\n\t\t}\n\t\tresult = append(result, entry)\n\t}\n\n\tc.JSON(200, gin.H{\"models\": result})\n}\n\n// List auth files from disk when the auth manager is unavailable.\nfunc (h *Handler) listAuthFilesFromDisk(c *gin.Context) {\n\tentries, err := os.ReadDir(h.cfg.AuthDir)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to read auth dir: %v\", err)})\n\t\treturn\n\t}\n\tfiles := make([]gin.H, 0)\n\tfor _, e := range entries {\n\t\tif e.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := e.Name()\n\t\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\t\tcontinue\n\t\t}\n\t\tif info, errInfo := e.Info(); errInfo == nil {\n\t\t\tfileData := gin.H{\"name\": name, \"size\": info.Size(), \"modtime\": info.ModTime()}\n\n\t\t\t// Read file to get type field\n\t\t\tfull := filepath.Join(h.cfg.AuthDir, name)\n\t\t\tif data, errRead := os.ReadFile(full); errRead == nil {\n\t\t\t\ttypeValue := gjson.GetBytes(data, \"type\").String()\n\t\t\t\temailValue := gjson.GetBytes(data, \"email\").String()\n\t\t\t\tfileData[\"type\"] = typeValue\n\t\t\t\tfileData[\"email\"] = emailValue\n\t\t\t\tif pv := gjson.GetBytes(data, \"priority\"); pv.Exists() {\n\t\t\t\t\tswitch pv.Type {\n\t\t\t\t\tcase gjson.Number:\n\t\t\t\t\t\tfileData[\"priority\"] = int(pv.Int())\n\t\t\t\t\tcase gjson.String:\n\t\t\t\t\t\tif parsed, errAtoi := strconv.Atoi(strings.TrimSpace(pv.String())); errAtoi == nil {\n\t\t\t\t\t\t\tfileData[\"priority\"] = parsed\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif nv := gjson.GetBytes(data, \"note\"); nv.Exists() && nv.Type == gjson.String {\n\t\t\t\t\tif trimmed := strings.TrimSpace(nv.String()); trimmed != \"\" {\n\t\t\t\t\t\tfileData[\"note\"] = trimmed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfiles = append(files, fileData)\n\t\t}\n\t}\n\tc.JSON(200, gin.H{\"files\": files})\n}\n\nfunc (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {\n\tif auth == nil {\n\t\treturn nil\n\t}\n\tauth.EnsureIndex()\n\truntimeOnly := isRuntimeOnlyAuth(auth)\n\tif runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {\n\t\treturn nil\n\t}\n\tpath := strings.TrimSpace(authAttribute(auth, \"path\"))\n\tif path == \"\" && !runtimeOnly {\n\t\treturn nil\n\t}\n\tname := strings.TrimSpace(auth.FileName)\n\tif name == \"\" {\n\t\tname = auth.ID\n\t}\n\tentry := gin.H{\n\t\t\"id\":             auth.ID,\n\t\t\"auth_index\":     auth.Index,\n\t\t\"name\":           name,\n\t\t\"type\":           strings.TrimSpace(auth.Provider),\n\t\t\"provider\":       strings.TrimSpace(auth.Provider),\n\t\t\"label\":          auth.Label,\n\t\t\"status\":         auth.Status,\n\t\t\"status_message\": auth.StatusMessage,\n\t\t\"disabled\":       auth.Disabled,\n\t\t\"unavailable\":    auth.Unavailable,\n\t\t\"runtime_only\":   runtimeOnly,\n\t\t\"source\":         \"memory\",\n\t\t\"size\":           int64(0),\n\t}\n\tif email := authEmail(auth); email != \"\" {\n\t\tentry[\"email\"] = email\n\t}\n\tif accountType, account := auth.AccountInfo(); accountType != \"\" || account != \"\" {\n\t\tif accountType != \"\" {\n\t\t\tentry[\"account_type\"] = accountType\n\t\t}\n\t\tif account != \"\" {\n\t\t\tentry[\"account\"] = account\n\t\t}\n\t}\n\tif !auth.CreatedAt.IsZero() {\n\t\tentry[\"created_at\"] = auth.CreatedAt\n\t}\n\tif !auth.UpdatedAt.IsZero() {\n\t\tentry[\"modtime\"] = auth.UpdatedAt\n\t\tentry[\"updated_at\"] = auth.UpdatedAt\n\t}\n\tif !auth.LastRefreshedAt.IsZero() {\n\t\tentry[\"last_refresh\"] = auth.LastRefreshedAt\n\t}\n\tif !auth.NextRetryAfter.IsZero() {\n\t\tentry[\"next_retry_after\"] = auth.NextRetryAfter\n\t}\n\tif path != \"\" {\n\t\tentry[\"path\"] = path\n\t\tentry[\"source\"] = \"file\"\n\t\tif info, err := os.Stat(path); err == nil {\n\t\t\tentry[\"size\"] = info.Size()\n\t\t\tentry[\"modtime\"] = info.ModTime()\n\t\t} else if os.IsNotExist(err) {\n\t\t\t// Hide credentials removed from disk but still lingering in memory.\n\t\t\tif !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), \"removed via management api\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tentry[\"source\"] = \"memory\"\n\t\t} else {\n\t\t\tlog.WithError(err).Warnf(\"failed to stat auth file %s\", path)\n\t\t}\n\t}\n\tif claims := extractCodexIDTokenClaims(auth); claims != nil {\n\t\tentry[\"id_token\"] = claims\n\t}\n\t// Expose priority from Attributes (set by synthesizer from JSON \"priority\" field).\n\t// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).\n\tif p := strings.TrimSpace(authAttribute(auth, \"priority\")); p != \"\" {\n\t\tif parsed, err := strconv.Atoi(p); err == nil {\n\t\t\tentry[\"priority\"] = parsed\n\t\t}\n\t} else if auth.Metadata != nil {\n\t\tif rawPriority, ok := auth.Metadata[\"priority\"]; ok {\n\t\t\tswitch v := rawPriority.(type) {\n\t\t\tcase float64:\n\t\t\t\tentry[\"priority\"] = int(v)\n\t\t\tcase int:\n\t\t\t\tentry[\"priority\"] = v\n\t\t\tcase string:\n\t\t\t\tif parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {\n\t\t\t\t\tentry[\"priority\"] = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t// Expose note from Attributes (set by synthesizer from JSON \"note\" field).\n\t// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).\n\tif note := strings.TrimSpace(authAttribute(auth, \"note\")); note != \"\" {\n\t\tentry[\"note\"] = note\n\t} else if auth.Metadata != nil {\n\t\tif rawNote, ok := auth.Metadata[\"note\"].(string); ok {\n\t\t\tif trimmed := strings.TrimSpace(rawNote); trimmed != \"\" {\n\t\t\t\tentry[\"note\"] = trimmed\n\t\t\t}\n\t\t}\n\t}\n\treturn entry\n}\n\nfunc extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {\n\tif auth == nil || auth.Metadata == nil {\n\t\treturn nil\n\t}\n\tif !strings.EqualFold(strings.TrimSpace(auth.Provider), \"codex\") {\n\t\treturn nil\n\t}\n\tidTokenRaw, ok := auth.Metadata[\"id_token\"].(string)\n\tif !ok {\n\t\treturn nil\n\t}\n\tidToken := strings.TrimSpace(idTokenRaw)\n\tif idToken == \"\" {\n\t\treturn nil\n\t}\n\tclaims, err := codex.ParseJWTToken(idToken)\n\tif err != nil || claims == nil {\n\t\treturn nil\n\t}\n\n\tresult := gin.H{}\n\tif v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != \"\" {\n\t\tresult[\"chatgpt_account_id\"] = v\n\t}\n\tif v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != \"\" {\n\t\tresult[\"plan_type\"] = v\n\t}\n\tif v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {\n\t\tresult[\"chatgpt_subscription_active_start\"] = v\n\t}\n\tif v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {\n\t\tresult[\"chatgpt_subscription_active_until\"] = v\n\t}\n\n\tif len(result) == 0 {\n\t\treturn nil\n\t}\n\treturn result\n}\n\nfunc authEmail(auth *coreauth.Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"email\"].(string); ok {\n\t\t\treturn strings.TrimSpace(v)\n\t\t}\n\t}\n\tif auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"email\"]); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t\tif v := strings.TrimSpace(auth.Attributes[\"account_email\"]); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc authAttribute(auth *coreauth.Auth, key string) string {\n\tif auth == nil || len(auth.Attributes) == 0 {\n\t\treturn \"\"\n\t}\n\treturn auth.Attributes[key]\n}\n\nfunc isRuntimeOnlyAuth(auth *coreauth.Auth) bool {\n\tif auth == nil || len(auth.Attributes) == 0 {\n\t\treturn false\n\t}\n\treturn strings.EqualFold(strings.TrimSpace(auth.Attributes[\"runtime_only\"]), \"true\")\n}\n\n// Download single auth file by name\nfunc (h *Handler) DownloadAuthFile(c *gin.Context) {\n\tname := c.Query(\"name\")\n\tif name == \"\" || strings.Contains(name, string(os.PathSeparator)) {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid name\"})\n\t\treturn\n\t}\n\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\tc.JSON(400, gin.H{\"error\": \"name must end with .json\"})\n\t\treturn\n\t}\n\tfull := filepath.Join(h.cfg.AuthDir, name)\n\tdata, err := os.ReadFile(full)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tc.JSON(404, gin.H{\"error\": \"file not found\"})\n\t\t} else {\n\t\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to read file: %v\", err)})\n\t\t}\n\t\treturn\n\t}\n\tc.Header(\"Content-Disposition\", fmt.Sprintf(\"attachment; filename=\\\"%s\\\"\", name))\n\tc.Data(200, \"application/json\", data)\n}\n\n// Upload auth file: multipart or raw JSON with ?name=\nfunc (h *Handler) UploadAuthFile(c *gin.Context) {\n\tif h.authManager == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"core auth manager unavailable\"})\n\t\treturn\n\t}\n\tctx := c.Request.Context()\n\tif file, err := c.FormFile(\"file\"); err == nil && file != nil {\n\t\tname := filepath.Base(file.Filename)\n\t\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\t\tc.JSON(400, gin.H{\"error\": \"file must be .json\"})\n\t\t\treturn\n\t\t}\n\t\tdst := filepath.Join(h.cfg.AuthDir, name)\n\t\tif !filepath.IsAbs(dst) {\n\t\t\tif abs, errAbs := filepath.Abs(dst); errAbs == nil {\n\t\t\t\tdst = abs\n\t\t\t}\n\t\t}\n\t\tif errSave := c.SaveUploadedFile(file, dst); errSave != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to save file: %v\", errSave)})\n\t\t\treturn\n\t\t}\n\t\tdata, errRead := os.ReadFile(dst)\n\t\tif errRead != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to read saved file: %v\", errRead)})\n\t\t\treturn\n\t\t}\n\t\tif errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": errReg.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(200, gin.H{\"status\": \"ok\"})\n\t\treturn\n\t}\n\tname := c.Query(\"name\")\n\tif name == \"\" || strings.Contains(name, string(os.PathSeparator)) {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid name\"})\n\t\treturn\n\t}\n\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\tc.JSON(400, gin.H{\"error\": \"name must end with .json\"})\n\t\treturn\n\t}\n\tdata, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tdst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))\n\tif !filepath.IsAbs(dst) {\n\t\tif abs, errAbs := filepath.Abs(dst); errAbs == nil {\n\t\t\tdst = abs\n\t\t}\n\t}\n\tif errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {\n\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to write file: %v\", errWrite)})\n\t\treturn\n\t}\n\tif err = h.registerAuthFromFile(ctx, dst, data); err != nil {\n\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"status\": \"ok\"})\n}\n\n// Delete auth files: single by name or all\nfunc (h *Handler) DeleteAuthFile(c *gin.Context) {\n\tif h.authManager == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"core auth manager unavailable\"})\n\t\treturn\n\t}\n\tctx := c.Request.Context()\n\tif all := c.Query(\"all\"); all == \"true\" || all == \"1\" || all == \"*\" {\n\t\tentries, err := os.ReadDir(h.cfg.AuthDir)\n\t\tif err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to read auth dir: %v\", err)})\n\t\t\treturn\n\t\t}\n\t\tdeleted := 0\n\t\tfor _, e := range entries {\n\t\t\tif e.IsDir() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tname := e.Name()\n\t\t\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfull := filepath.Join(h.cfg.AuthDir, name)\n\t\t\tif !filepath.IsAbs(full) {\n\t\t\t\tif abs, errAbs := filepath.Abs(full); errAbs == nil {\n\t\t\t\t\tfull = abs\n\t\t\t\t}\n\t\t\t}\n\t\t\tif err = os.Remove(full); err == nil {\n\t\t\t\tif errDel := h.deleteTokenRecord(ctx, full); errDel != nil {\n\t\t\t\t\tc.JSON(500, gin.H{\"error\": errDel.Error()})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tdeleted++\n\t\t\t\th.disableAuth(ctx, full)\n\t\t\t}\n\t\t}\n\t\tc.JSON(200, gin.H{\"status\": \"ok\", \"deleted\": deleted})\n\t\treturn\n\t}\n\tname := c.Query(\"name\")\n\tif name == \"\" || strings.Contains(name, string(os.PathSeparator)) {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid name\"})\n\t\treturn\n\t}\n\n\ttargetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))\n\ttargetID := \"\"\n\tif targetAuth := h.findAuthForDelete(name); targetAuth != nil {\n\t\ttargetID = strings.TrimSpace(targetAuth.ID)\n\t\tif path := strings.TrimSpace(authAttribute(targetAuth, \"path\")); path != \"\" {\n\t\t\ttargetPath = path\n\t\t}\n\t}\n\tif !filepath.IsAbs(targetPath) {\n\t\tif abs, errAbs := filepath.Abs(targetPath); errAbs == nil {\n\t\t\ttargetPath = abs\n\t\t}\n\t}\n\tif errRemove := os.Remove(targetPath); errRemove != nil {\n\t\tif os.IsNotExist(errRemove) {\n\t\t\tc.JSON(404, gin.H{\"error\": \"file not found\"})\n\t\t} else {\n\t\t\tc.JSON(500, gin.H{\"error\": fmt.Sprintf(\"failed to remove file: %v\", errRemove)})\n\t\t}\n\t\treturn\n\t}\n\tif errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {\n\t\tc.JSON(500, gin.H{\"error\": errDeleteRecord.Error()})\n\t\treturn\n\t}\n\tif targetID != \"\" {\n\t\th.disableAuth(ctx, targetID)\n\t} else {\n\t\th.disableAuth(ctx, targetPath)\n\t}\n\tc.JSON(200, gin.H{\"status\": \"ok\"})\n}\n\nfunc (h *Handler) findAuthForDelete(name string) *coreauth.Auth {\n\tif h == nil || h.authManager == nil {\n\t\treturn nil\n\t}\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn nil\n\t}\n\tif auth, ok := h.authManager.GetByID(name); ok {\n\t\treturn auth\n\t}\n\tauths := h.authManager.List()\n\tfor _, auth := range auths {\n\t\tif auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.TrimSpace(auth.FileName) == name {\n\t\t\treturn auth\n\t\t}\n\t\tif filepath.Base(strings.TrimSpace(authAttribute(auth, \"path\"))) == name {\n\t\t\treturn auth\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) authIDForPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tid := path\n\tif h != nil && h.cfg != nil {\n\t\tauthDir := strings.TrimSpace(h.cfg.AuthDir)\n\t\tif authDir != \"\" {\n\t\t\tif rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != \"\" {\n\t\t\t\tid = rel\n\t\t\t}\n\t\t}\n\t}\n\t// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.\n\tif runtime.GOOS == \"windows\" {\n\t\tid = strings.ToLower(id)\n\t}\n\treturn id\n}\n\nfunc (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {\n\tif h.authManager == nil {\n\t\treturn nil\n\t}\n\tif path == \"\" {\n\t\treturn fmt.Errorf(\"auth path is empty\")\n\t}\n\tif data == nil {\n\t\tvar err error\n\t\tdata, err = os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to read auth file: %w\", err)\n\t\t}\n\t}\n\tmetadata := make(map[string]any)\n\tif err := json.Unmarshal(data, &metadata); err != nil {\n\t\treturn fmt.Errorf(\"invalid auth file: %w\", err)\n\t}\n\tprovider, _ := metadata[\"type\"].(string)\n\tif provider == \"\" {\n\t\tprovider = \"unknown\"\n\t}\n\tlabel := provider\n\tif email, ok := metadata[\"email\"].(string); ok && email != \"\" {\n\t\tlabel = email\n\t}\n\tlastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)\n\n\tauthID := h.authIDForPath(path)\n\tif authID == \"\" {\n\t\tauthID = path\n\t}\n\tattr := map[string]string{\n\t\t\"path\":   path,\n\t\t\"source\": path,\n\t}\n\tauth := &coreauth.Auth{\n\t\tID:         authID,\n\t\tProvider:   provider,\n\t\tFileName:   filepath.Base(path),\n\t\tLabel:      label,\n\t\tStatus:     coreauth.StatusActive,\n\t\tAttributes: attr,\n\t\tMetadata:   metadata,\n\t\tCreatedAt:  time.Now(),\n\t\tUpdatedAt:  time.Now(),\n\t}\n\tif hasLastRefresh {\n\t\tauth.LastRefreshedAt = lastRefresh\n\t}\n\tif existing, ok := h.authManager.GetByID(authID); ok {\n\t\tauth.CreatedAt = existing.CreatedAt\n\t\tif !hasLastRefresh {\n\t\t\tauth.LastRefreshedAt = existing.LastRefreshedAt\n\t\t}\n\t\tauth.NextRefreshAfter = existing.NextRefreshAfter\n\t\tauth.Runtime = existing.Runtime\n\t\t_, err := h.authManager.Update(ctx, auth)\n\t\treturn err\n\t}\n\t_, err := h.authManager.Register(ctx, auth)\n\treturn err\n}\n\n// PatchAuthFileStatus toggles the disabled state of an auth file\nfunc (h *Handler) PatchAuthFileStatus(c *gin.Context) {\n\tif h.authManager == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"core auth manager unavailable\"})\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tName     string `json:\"name\"`\n\t\tDisabled *bool  `json:\"disabled\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid request body\"})\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"name is required\"})\n\t\treturn\n\t}\n\tif req.Disabled == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"disabled is required\"})\n\t\treturn\n\t}\n\n\tctx := c.Request.Context()\n\n\t// Find auth by name or ID\n\tvar targetAuth *coreauth.Auth\n\tif auth, ok := h.authManager.GetByID(name); ok {\n\t\ttargetAuth = auth\n\t} else {\n\t\tauths := h.authManager.List()\n\t\tfor _, auth := range auths {\n\t\t\tif auth.FileName == name {\n\t\t\t\ttargetAuth = auth\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif targetAuth == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"auth file not found\"})\n\t\treturn\n\t}\n\n\t// Update disabled state\n\ttargetAuth.Disabled = *req.Disabled\n\tif *req.Disabled {\n\t\ttargetAuth.Status = coreauth.StatusDisabled\n\t\ttargetAuth.StatusMessage = \"disabled via management API\"\n\t} else {\n\t\ttargetAuth.Status = coreauth.StatusActive\n\t\ttargetAuth.StatusMessage = \"\"\n\t}\n\ttargetAuth.UpdatedAt = time.Now()\n\n\tif _, err := h.authManager.Update(ctx, targetAuth); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to update auth: %v\", err)})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\", \"disabled\": *req.Disabled})\n}\n\n// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority, note) of an auth file.\nfunc (h *Handler) PatchAuthFileFields(c *gin.Context) {\n\tif h.authManager == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"core auth manager unavailable\"})\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tName     string  `json:\"name\"`\n\t\tPrefix   *string `json:\"prefix\"`\n\t\tProxyURL *string `json:\"proxy_url\"`\n\t\tPriority *int    `json:\"priority\"`\n\t\tNote     *string `json:\"note\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid request body\"})\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"name is required\"})\n\t\treturn\n\t}\n\n\tctx := c.Request.Context()\n\n\t// Find auth by name or ID\n\tvar targetAuth *coreauth.Auth\n\tif auth, ok := h.authManager.GetByID(name); ok {\n\t\ttargetAuth = auth\n\t} else {\n\t\tauths := h.authManager.List()\n\t\tfor _, auth := range auths {\n\t\t\tif auth.FileName == name {\n\t\t\t\ttargetAuth = auth\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif targetAuth == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"auth file not found\"})\n\t\treturn\n\t}\n\n\tchanged := false\n\tif req.Prefix != nil {\n\t\ttargetAuth.Prefix = *req.Prefix\n\t\tchanged = true\n\t}\n\tif req.ProxyURL != nil {\n\t\ttargetAuth.ProxyURL = *req.ProxyURL\n\t\tchanged = true\n\t}\n\tif req.Priority != nil || req.Note != nil {\n\t\tif targetAuth.Metadata == nil {\n\t\t\ttargetAuth.Metadata = make(map[string]any)\n\t\t}\n\t\tif targetAuth.Attributes == nil {\n\t\t\ttargetAuth.Attributes = make(map[string]string)\n\t\t}\n\n\t\tif req.Priority != nil {\n\t\t\tif *req.Priority == 0 {\n\t\t\t\tdelete(targetAuth.Metadata, \"priority\")\n\t\t\t\tdelete(targetAuth.Attributes, \"priority\")\n\t\t\t} else {\n\t\t\t\ttargetAuth.Metadata[\"priority\"] = *req.Priority\n\t\t\t\ttargetAuth.Attributes[\"priority\"] = strconv.Itoa(*req.Priority)\n\t\t\t}\n\t\t}\n\t\tif req.Note != nil {\n\t\t\ttrimmedNote := strings.TrimSpace(*req.Note)\n\t\t\tif trimmedNote == \"\" {\n\t\t\t\tdelete(targetAuth.Metadata, \"note\")\n\t\t\t\tdelete(targetAuth.Attributes, \"note\")\n\t\t\t} else {\n\t\t\t\ttargetAuth.Metadata[\"note\"] = trimmedNote\n\t\t\t\ttargetAuth.Attributes[\"note\"] = trimmedNote\n\t\t\t}\n\t\t}\n\t\tchanged = true\n\t}\n\n\tif !changed {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"no fields to update\"})\n\t\treturn\n\t}\n\n\ttargetAuth.UpdatedAt = time.Now()\n\n\tif _, err := h.authManager.Update(ctx, targetAuth); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to update auth: %v\", err)})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n}\n\nfunc (h *Handler) disableAuth(ctx context.Context, id string) {\n\tif h == nil || h.authManager == nil {\n\t\treturn\n\t}\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn\n\t}\n\tif auth, ok := h.authManager.GetByID(id); ok {\n\t\tauth.Disabled = true\n\t\tauth.Status = coreauth.StatusDisabled\n\t\tauth.StatusMessage = \"removed via management API\"\n\t\tauth.UpdatedAt = time.Now()\n\t\t_, _ = h.authManager.Update(ctx, auth)\n\t\treturn\n\t}\n\tauthID := h.authIDForPath(id)\n\tif authID == \"\" {\n\t\treturn\n\t}\n\tif auth, ok := h.authManager.GetByID(authID); ok {\n\t\tauth.Disabled = true\n\t\tauth.Status = coreauth.StatusDisabled\n\t\tauth.StatusMessage = \"removed via management API\"\n\t\tauth.UpdatedAt = time.Now()\n\t\t_, _ = h.authManager.Update(ctx, auth)\n\t}\n}\n\nfunc (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn fmt.Errorf(\"auth path is empty\")\n\t}\n\tstore := h.tokenStoreWithBaseDir()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"token store unavailable\")\n\t}\n\treturn store.Delete(ctx, path)\n}\n\nfunc (h *Handler) tokenStoreWithBaseDir() coreauth.Store {\n\tif h == nil {\n\t\treturn nil\n\t}\n\tstore := h.tokenStore\n\tif store == nil {\n\t\tstore = sdkAuth.GetTokenStore()\n\t\th.tokenStore = store\n\t}\n\tif h.cfg != nil {\n\t\tif dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {\n\t\t\tdirSetter.SetBaseDir(h.cfg.AuthDir)\n\t\t}\n\t}\n\treturn store\n}\n\nfunc (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {\n\tif record == nil {\n\t\treturn \"\", fmt.Errorf(\"token record is nil\")\n\t}\n\tstore := h.tokenStoreWithBaseDir()\n\tif store == nil {\n\t\treturn \"\", fmt.Errorf(\"token store unavailable\")\n\t}\n\tif h.postAuthHook != nil {\n\t\tif err := h.postAuthHook(ctx, record); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"post-auth hook failed: %w\", err)\n\t\t}\n\t}\n\treturn store.Save(ctx, record)\n}\n\nfunc (h *Handler) RequestAnthropicToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing Claude authentication...\")\n\n\t// Generate PKCE codes\n\tpkceCodes, err := claude.GeneratePKCECodes()\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate PKCE codes: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate PKCE codes\"})\n\t\treturn\n\t}\n\n\t// Generate random state parameter\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate state parameter: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate state parameter\"})\n\t\treturn\n\t}\n\n\t// Initialize Claude auth service\n\tanthropicAuth := claude.NewClaudeAuth(h.cfg)\n\n\t// Generate authorization URL (then override redirect_uri to reuse server port)\n\tauthURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate authorization URL: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate authorization url\"})\n\t\treturn\n\t}\n\n\tRegisterOAuthSession(state, \"anthropic\")\n\n\tisWebUI := isWebUIRequest(c)\n\tvar forwarder *callbackForwarder\n\tif isWebUI {\n\t\ttargetURL, errTarget := h.managementCallbackURL(\"/anthropic/callback\")\n\t\tif errTarget != nil {\n\t\t\tlog.WithError(errTarget).Error(\"failed to compute anthropic callback target\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"callback server unavailable\"})\n\t\t\treturn\n\t\t}\n\t\tvar errStart error\n\t\tif forwarder, errStart = startCallbackForwarder(anthropicCallbackPort, \"anthropic\", targetURL); errStart != nil {\n\t\t\tlog.WithError(errStart).Error(\"failed to start anthropic callback forwarder\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to start callback server\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo func() {\n\t\tif isWebUI {\n\t\t\tdefer stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)\n\t\t}\n\n\t\t// Helper: wait for callback file\n\t\twaitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(\".oauth-anthropic-%s.oauth\", state))\n\t\twaitForFile := func(path string, timeout time.Duration) (map[string]string, error) {\n\t\t\tdeadline := time.Now().Add(timeout)\n\t\t\tfor {\n\t\t\t\tif !IsOAuthSessionPending(state, \"anthropic\") {\n\t\t\t\t\treturn nil, errOAuthSessionNotPending\n\t\t\t\t}\n\t\t\t\tif time.Now().After(deadline) {\n\t\t\t\t\tSetOAuthSessionError(state, \"Timeout waiting for OAuth callback\")\n\t\t\t\t\treturn nil, fmt.Errorf(\"timeout waiting for OAuth callback\")\n\t\t\t\t}\n\t\t\t\tdata, errRead := os.ReadFile(path)\n\t\t\t\tif errRead == nil {\n\t\t\t\t\tvar m map[string]string\n\t\t\t\t\t_ = json.Unmarshal(data, &m)\n\t\t\t\t\t_ = os.Remove(path)\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\t\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\t}\n\t\t}\n\n\t\tfmt.Println(\"Waiting for authentication callback...\")\n\t\t// Wait up to 5 minutes\n\t\tresultMap, errWait := waitForFile(waitFile, 5*time.Minute)\n\t\tif errWait != nil {\n\t\t\tif errors.Is(errWait, errOAuthSessionNotPending) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tauthErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)\n\t\t\tlog.Error(claude.GetUserFriendlyMessage(authErr))\n\t\t\treturn\n\t\t}\n\t\tif errStr := resultMap[\"error\"]; errStr != \"\" {\n\t\t\toauthErr := claude.NewOAuthError(errStr, \"\", http.StatusBadRequest)\n\t\t\tlog.Error(claude.GetUserFriendlyMessage(oauthErr))\n\t\t\tSetOAuthSessionError(state, \"Bad request\")\n\t\t\treturn\n\t\t}\n\t\tif resultMap[\"state\"] != state {\n\t\t\tauthErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf(\"expected %s, got %s\", state, resultMap[\"state\"]))\n\t\t\tlog.Error(claude.GetUserFriendlyMessage(authErr))\n\t\t\tSetOAuthSessionError(state, \"State code error\")\n\t\t\treturn\n\t\t}\n\n\t\t// Parse code (Claude may append state after '#')\n\t\trawCode := resultMap[\"code\"]\n\t\tcode := strings.Split(rawCode, \"#\")[0]\n\n\t\t// Exchange code for tokens using internal auth service\n\t\tbundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)\n\t\tif errExchange != nil {\n\t\t\tauthErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)\n\t\t\tlog.Errorf(\"Failed to exchange authorization code for tokens: %v\", authErr)\n\t\t\tSetOAuthSessionError(state, \"Failed to exchange authorization code for tokens\")\n\t\t\treturn\n\t\t}\n\n\t\t// Create token storage\n\t\ttokenStorage := anthropicAuth.CreateTokenStorage(bundle)\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fmt.Sprintf(\"claude-%s.json\", tokenStorage.Email),\n\t\t\tProvider: \"claude\",\n\t\t\tFileName: fmt.Sprintf(\"claude-%s.json\", tokenStorage.Email),\n\t\t\tStorage:  tokenStorage,\n\t\t\tMetadata: map[string]any{\"email\": tokenStorage.Email},\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tlog.Errorf(\"Failed to save authentication tokens: %v\", errSave)\n\t\t\tSetOAuthSessionError(state, \"Failed to save authentication tokens\")\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tif bundle.APIKey != \"\" {\n\t\t\tfmt.Println(\"API key obtained and saved\")\n\t\t}\n\t\tfmt.Println(\"You can now use Claude services through this CLI\")\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"anthropic\")\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestGeminiCLIToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\tproxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})\n\tctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)\n\n\t// Optional project ID from query\n\tprojectID := c.Query(\"project_id\")\n\n\tfmt.Println(\"Initializing Google authentication...\")\n\n\t// OAuth2 configuration using exported constants from internal/auth/gemini\n\tconf := &oauth2.Config{\n\t\tClientID:     geminiAuth.ClientID,\n\t\tClientSecret: geminiAuth.ClientSecret,\n\t\tRedirectURL:  fmt.Sprintf(\"http://localhost:%d/oauth2callback\", geminiAuth.DefaultCallbackPort),\n\t\tScopes:       geminiAuth.Scopes,\n\t\tEndpoint:     google.Endpoint,\n\t}\n\n\t// Build authorization URL and return it immediately\n\tstate := fmt.Sprintf(\"gem-%d\", time.Now().UnixNano())\n\tauthURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam(\"prompt\", \"consent\"))\n\n\tRegisterOAuthSession(state, \"gemini\")\n\n\tisWebUI := isWebUIRequest(c)\n\tvar forwarder *callbackForwarder\n\tif isWebUI {\n\t\ttargetURL, errTarget := h.managementCallbackURL(\"/google/callback\")\n\t\tif errTarget != nil {\n\t\t\tlog.WithError(errTarget).Error(\"failed to compute gemini callback target\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"callback server unavailable\"})\n\t\t\treturn\n\t\t}\n\t\tvar errStart error\n\t\tif forwarder, errStart = startCallbackForwarder(geminiCallbackPort, \"gemini\", targetURL); errStart != nil {\n\t\t\tlog.WithError(errStart).Error(\"failed to start gemini callback forwarder\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to start callback server\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo func() {\n\t\tif isWebUI {\n\t\t\tdefer stopCallbackForwarderInstance(geminiCallbackPort, forwarder)\n\t\t}\n\n\t\t// Wait for callback file written by server route\n\t\twaitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(\".oauth-gemini-%s.oauth\", state))\n\t\tfmt.Println(\"Waiting for authentication callback...\")\n\t\tdeadline := time.Now().Add(5 * time.Minute)\n\t\tvar authCode string\n\t\tfor {\n\t\t\tif !IsOAuthSessionPending(state, \"gemini\") {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\tlog.Error(\"oauth flow timed out\")\n\t\t\t\tSetOAuthSessionError(state, \"OAuth flow timed out\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif data, errR := os.ReadFile(waitFile); errR == nil {\n\t\t\t\tvar m map[string]string\n\t\t\t\t_ = json.Unmarshal(data, &m)\n\t\t\t\t_ = os.Remove(waitFile)\n\t\t\t\tif errStr := m[\"error\"]; errStr != \"\" {\n\t\t\t\t\tlog.Errorf(\"Authentication failed: %s\", errStr)\n\t\t\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tauthCode = m[\"code\"]\n\t\t\t\tif authCode == \"\" {\n\t\t\t\t\tlog.Errorf(\"Authentication failed: code not found\")\n\t\t\t\t\tSetOAuthSessionError(state, \"Authentication failed: code not found\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t}\n\n\t\t// Exchange authorization code for token\n\t\ttoken, err := conf.Exchange(ctx, authCode)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to exchange token: %v\", err)\n\t\t\tSetOAuthSessionError(state, \"Failed to exchange token\")\n\t\t\treturn\n\t\t}\n\n\t\trequestedProjectID := strings.TrimSpace(projectID)\n\n\t\t// Create token storage (mirrors internal/auth/gemini createTokenStorage)\n\t\tauthHTTPClient := conf.Client(ctx, token)\n\t\treq, errNewRequest := http.NewRequestWithContext(ctx, \"GET\", \"https://www.googleapis.com/oauth2/v1/userinfo?alt=json\", nil)\n\t\tif errNewRequest != nil {\n\t\t\tlog.Errorf(\"Could not get user info: %v\", errNewRequest)\n\t\t\tSetOAuthSessionError(state, \"Could not get user info\")\n\t\t\treturn\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token.AccessToken))\n\n\t\tresp, errDo := authHTTPClient.Do(req)\n\t\tif errDo != nil {\n\t\t\tlog.Errorf(\"Failed to execute request: %v\", errDo)\n\t\t\tSetOAuthSessionError(state, \"Failed to execute request\")\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Printf(\"warn: failed to close response body: %v\", errClose)\n\t\t\t}\n\t\t}()\n\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t\tlog.Errorf(\"Get user info request failed with status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Get user info request failed with status %d\", resp.StatusCode))\n\t\t\treturn\n\t\t}\n\n\t\temail := gjson.GetBytes(bodyBytes, \"email\").String()\n\t\tif email != \"\" {\n\t\t\tfmt.Printf(\"Authenticated user email: %s\\n\", email)\n\t\t} else {\n\t\t\tfmt.Println(\"Failed to get user email from token\")\n\t\t}\n\n\t\t// Marshal/unmarshal oauth2.Token to generic map and enrich fields\n\t\tvar ifToken map[string]any\n\t\tjsonData, _ := json.Marshal(token)\n\t\tif errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {\n\t\t\tlog.Errorf(\"Failed to unmarshal token: %v\", errUnmarshal)\n\t\t\tSetOAuthSessionError(state, \"Failed to unmarshal token\")\n\t\t\treturn\n\t\t}\n\n\t\tifToken[\"token_uri\"] = \"https://oauth2.googleapis.com/token\"\n\t\tifToken[\"client_id\"] = geminiAuth.ClientID\n\t\tifToken[\"client_secret\"] = geminiAuth.ClientSecret\n\t\tifToken[\"scopes\"] = geminiAuth.Scopes\n\t\tifToken[\"universe_domain\"] = \"googleapis.com\"\n\n\t\tts := geminiAuth.GeminiTokenStorage{\n\t\t\tToken:     ifToken,\n\t\t\tProjectID: requestedProjectID,\n\t\t\tEmail:     email,\n\t\t\tAuto:      requestedProjectID == \"\",\n\t\t}\n\n\t\t// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings\n\t\tgemAuth := geminiAuth.NewGeminiAuth()\n\t\tgemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, &geminiAuth.WebLoginOptions{\n\t\t\tNoBrowser: true,\n\t\t})\n\t\tif errGetClient != nil {\n\t\t\tlog.Errorf(\"failed to get authenticated client: %v\", errGetClient)\n\t\t\tSetOAuthSessionError(state, \"Failed to get authenticated client\")\n\t\t\treturn\n\t\t}\n\t\tfmt.Println(\"Authentication successful.\")\n\n\t\tif strings.EqualFold(requestedProjectID, \"ALL\") {\n\t\t\tts.Auto = false\n\t\t\tprojects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)\n\t\t\tif errAll != nil {\n\t\t\t\tlog.Errorf(\"Failed to complete Gemini CLI onboarding: %v\", errAll)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Failed to complete Gemini CLI onboarding: %v\", errAll))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {\n\t\t\t\tlog.Errorf(\"Failed to verify Cloud AI API status: %v\", errVerify)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Failed to verify Cloud AI API status: %v\", errVerify))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tts.ProjectID = strings.Join(projects, \",\")\n\t\t\tts.Checked = true\n\t\t} else if strings.EqualFold(requestedProjectID, \"GOOGLE_ONE\") {\n\t\t\tts.Auto = false\n\t\t\tif errSetup := performGeminiCLISetup(ctx, gemClient, &ts, \"\"); errSetup != nil {\n\t\t\t\tlog.Errorf(\"Google One auto-discovery failed: %v\", errSetup)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Google One auto-discovery failed: %v\", errSetup))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif strings.TrimSpace(ts.ProjectID) == \"\" {\n\t\t\t\tlog.Error(\"Google One auto-discovery returned empty project ID\")\n\t\t\t\tSetOAuthSessionError(state, \"Google One auto-discovery returned empty project ID\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tisChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)\n\t\t\tif errCheck != nil {\n\t\t\t\tlog.Errorf(\"Failed to verify Cloud AI API status: %v\", errCheck)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Failed to verify Cloud AI API status: %v\", errCheck))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tts.Checked = isChecked\n\t\t\tif !isChecked {\n\t\t\t\tlog.Error(\"Cloud AI API is not enabled for the auto-discovered project\")\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Cloud AI API not enabled for project %s\", ts.ProjectID))\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tif errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {\n\t\t\t\tlog.Errorf(\"Failed to complete Gemini CLI onboarding: %v\", errEnsure)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Failed to complete Gemini CLI onboarding: %v\", errEnsure))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif strings.TrimSpace(ts.ProjectID) == \"\" {\n\t\t\t\tlog.Error(\"Onboarding did not return a project ID\")\n\t\t\t\tSetOAuthSessionError(state, \"Failed to resolve project ID\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tisChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)\n\t\t\tif errCheck != nil {\n\t\t\t\tlog.Errorf(\"Failed to verify Cloud AI API status: %v\", errCheck)\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Failed to verify Cloud AI API status: %v\", errCheck))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tts.Checked = isChecked\n\t\t\tif !isChecked {\n\t\t\t\tlog.Error(\"Cloud AI API is not enabled for the selected project\")\n\t\t\t\tSetOAuthSessionError(state, fmt.Sprintf(\"Cloud AI API not enabled for project %s\", ts.ProjectID))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\trecordMetadata := map[string]any{\n\t\t\t\"email\":      ts.Email,\n\t\t\t\"project_id\": ts.ProjectID,\n\t\t\t\"auto\":       ts.Auto,\n\t\t\t\"checked\":    ts.Checked,\n\t\t}\n\n\t\tfileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true)\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fileName,\n\t\t\tProvider: \"gemini\",\n\t\t\tFileName: fileName,\n\t\t\tStorage:  &ts,\n\t\t\tMetadata: recordMetadata,\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tlog.Errorf(\"Failed to save token to file: %v\", errSave)\n\t\t\tSetOAuthSessionError(state, \"Failed to save token to file\")\n\t\t\treturn\n\t\t}\n\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"gemini\")\n\t\tfmt.Printf(\"You can now use Gemini CLI services through this CLI; token saved to %s\\n\", savedPath)\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestCodexToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing Codex authentication...\")\n\n\t// Generate PKCE codes\n\tpkceCodes, err := codex.GeneratePKCECodes()\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate PKCE codes: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate PKCE codes\"})\n\t\treturn\n\t}\n\n\t// Generate random state parameter\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate state parameter: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate state parameter\"})\n\t\treturn\n\t}\n\n\t// Initialize Codex auth service\n\topenaiAuth := codex.NewCodexAuth(h.cfg)\n\n\t// Generate authorization URL\n\tauthURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate authorization URL: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate authorization url\"})\n\t\treturn\n\t}\n\n\tRegisterOAuthSession(state, \"codex\")\n\n\tisWebUI := isWebUIRequest(c)\n\tvar forwarder *callbackForwarder\n\tif isWebUI {\n\t\ttargetURL, errTarget := h.managementCallbackURL(\"/codex/callback\")\n\t\tif errTarget != nil {\n\t\t\tlog.WithError(errTarget).Error(\"failed to compute codex callback target\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"callback server unavailable\"})\n\t\t\treturn\n\t\t}\n\t\tvar errStart error\n\t\tif forwarder, errStart = startCallbackForwarder(codexCallbackPort, \"codex\", targetURL); errStart != nil {\n\t\t\tlog.WithError(errStart).Error(\"failed to start codex callback forwarder\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to start callback server\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo func() {\n\t\tif isWebUI {\n\t\t\tdefer stopCallbackForwarderInstance(codexCallbackPort, forwarder)\n\t\t}\n\n\t\t// Wait for callback file\n\t\twaitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(\".oauth-codex-%s.oauth\", state))\n\t\tdeadline := time.Now().Add(5 * time.Minute)\n\t\tvar code string\n\t\tfor {\n\t\t\tif !IsOAuthSessionPending(state, \"codex\") {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\tauthErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf(\"timeout waiting for OAuth callback\"))\n\t\t\t\tlog.Error(codex.GetUserFriendlyMessage(authErr))\n\t\t\t\tSetOAuthSessionError(state, \"Timeout waiting for OAuth callback\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif data, errR := os.ReadFile(waitFile); errR == nil {\n\t\t\t\tvar m map[string]string\n\t\t\t\t_ = json.Unmarshal(data, &m)\n\t\t\t\t_ = os.Remove(waitFile)\n\t\t\t\tif errStr := m[\"error\"]; errStr != \"\" {\n\t\t\t\t\toauthErr := codex.NewOAuthError(errStr, \"\", http.StatusBadRequest)\n\t\t\t\t\tlog.Error(codex.GetUserFriendlyMessage(oauthErr))\n\t\t\t\t\tSetOAuthSessionError(state, \"Bad Request\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif m[\"state\"] != state {\n\t\t\t\t\tauthErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf(\"expected %s, got %s\", state, m[\"state\"]))\n\t\t\t\t\tSetOAuthSessionError(state, \"State code error\")\n\t\t\t\t\tlog.Error(codex.GetUserFriendlyMessage(authErr))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcode = m[\"code\"]\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t}\n\n\t\tlog.Debug(\"Authorization code received, exchanging for tokens...\")\n\t\t// Exchange code for tokens using internal auth service\n\t\tbundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)\n\t\tif errExchange != nil {\n\t\t\tauthErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)\n\t\t\tSetOAuthSessionError(state, \"Failed to exchange authorization code for tokens\")\n\t\t\tlog.Errorf(\"Failed to exchange authorization code for tokens: %v\", authErr)\n\t\t\treturn\n\t\t}\n\n\t\t// Extract additional info for filename generation\n\t\tclaims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)\n\t\tplanType := \"\"\n\t\thashAccountID := \"\"\n\t\tif claims != nil {\n\t\t\tplanType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)\n\t\t\tif accountID := claims.GetAccountID(); accountID != \"\" {\n\t\t\t\tdigest := sha256.Sum256([]byte(accountID))\n\t\t\t\thashAccountID = hex.EncodeToString(digest[:])[:8]\n\t\t\t}\n\t\t}\n\n\t\t// Create token storage and persist\n\t\ttokenStorage := openaiAuth.CreateTokenStorage(bundle)\n\t\tfileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fileName,\n\t\t\tProvider: \"codex\",\n\t\t\tFileName: fileName,\n\t\t\tStorage:  tokenStorage,\n\t\t\tMetadata: map[string]any{\n\t\t\t\t\"email\":      tokenStorage.Email,\n\t\t\t\t\"account_id\": tokenStorage.AccountID,\n\t\t\t},\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tSetOAuthSessionError(state, \"Failed to save authentication tokens\")\n\t\t\tlog.Errorf(\"Failed to save authentication tokens: %v\", errSave)\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tif bundle.APIKey != \"\" {\n\t\t\tfmt.Println(\"API key obtained and saved\")\n\t\t}\n\t\tfmt.Println(\"You can now use Codex services through this CLI\")\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"codex\")\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestAntigravityToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing Antigravity authentication...\")\n\n\tauthSvc := antigravity.NewAntigravityAuth(h.cfg, nil)\n\n\tstate, errState := misc.GenerateRandomState()\n\tif errState != nil {\n\t\tlog.Errorf(\"Failed to generate state parameter: %v\", errState)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate state parameter\"})\n\t\treturn\n\t}\n\n\tredirectURI := fmt.Sprintf(\"http://localhost:%d/oauth-callback\", antigravity.CallbackPort)\n\tauthURL := authSvc.BuildAuthURL(state, redirectURI)\n\n\tRegisterOAuthSession(state, \"antigravity\")\n\n\tisWebUI := isWebUIRequest(c)\n\tvar forwarder *callbackForwarder\n\tif isWebUI {\n\t\ttargetURL, errTarget := h.managementCallbackURL(\"/antigravity/callback\")\n\t\tif errTarget != nil {\n\t\t\tlog.WithError(errTarget).Error(\"failed to compute antigravity callback target\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"callback server unavailable\"})\n\t\t\treturn\n\t\t}\n\t\tvar errStart error\n\t\tif forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, \"antigravity\", targetURL); errStart != nil {\n\t\t\tlog.WithError(errStart).Error(\"failed to start antigravity callback forwarder\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to start callback server\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo func() {\n\t\tif isWebUI {\n\t\t\tdefer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)\n\t\t}\n\n\t\twaitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(\".oauth-antigravity-%s.oauth\", state))\n\t\tdeadline := time.Now().Add(5 * time.Minute)\n\t\tvar authCode string\n\t\tfor {\n\t\t\tif !IsOAuthSessionPending(state, \"antigravity\") {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\tlog.Error(\"oauth flow timed out\")\n\t\t\t\tSetOAuthSessionError(state, \"OAuth flow timed out\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {\n\t\t\t\tvar payload map[string]string\n\t\t\t\t_ = json.Unmarshal(data, &payload)\n\t\t\t\t_ = os.Remove(waitFile)\n\t\t\t\tif errStr := strings.TrimSpace(payload[\"error\"]); errStr != \"\" {\n\t\t\t\t\tlog.Errorf(\"Authentication failed: %s\", errStr)\n\t\t\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif payloadState := strings.TrimSpace(payload[\"state\"]); payloadState != \"\" && payloadState != state {\n\t\t\t\t\tlog.Errorf(\"Authentication failed: state mismatch\")\n\t\t\t\t\tSetOAuthSessionError(state, \"Authentication failed: state mismatch\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tauthCode = strings.TrimSpace(payload[\"code\"])\n\t\t\t\tif authCode == \"\" {\n\t\t\t\t\tlog.Error(\"Authentication failed: code not found\")\n\t\t\t\t\tSetOAuthSessionError(state, \"Authentication failed: code not found\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t}\n\n\t\ttokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)\n\t\tif errToken != nil {\n\t\t\tlog.Errorf(\"Failed to exchange token: %v\", errToken)\n\t\t\tSetOAuthSessionError(state, \"Failed to exchange token\")\n\t\t\treturn\n\t\t}\n\n\t\taccessToken := strings.TrimSpace(tokenResp.AccessToken)\n\t\tif accessToken == \"\" {\n\t\t\tlog.Error(\"antigravity: token exchange returned empty access token\")\n\t\t\tSetOAuthSessionError(state, \"Failed to exchange token\")\n\t\t\treturn\n\t\t}\n\n\t\temail, errInfo := authSvc.FetchUserInfo(ctx, accessToken)\n\t\tif errInfo != nil {\n\t\t\tlog.Errorf(\"Failed to fetch user info: %v\", errInfo)\n\t\t\tSetOAuthSessionError(state, \"Failed to fetch user info\")\n\t\t\treturn\n\t\t}\n\t\temail = strings.TrimSpace(email)\n\t\tif email == \"\" {\n\t\t\tlog.Error(\"antigravity: user info returned empty email\")\n\t\t\tSetOAuthSessionError(state, \"Failed to fetch user info\")\n\t\t\treturn\n\t\t}\n\n\t\tprojectID := \"\"\n\t\tif accessToken != \"\" {\n\t\t\tfetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)\n\t\t\tif errProject != nil {\n\t\t\t\tlog.Warnf(\"antigravity: failed to fetch project ID: %v\", errProject)\n\t\t\t} else {\n\t\t\t\tprojectID = fetchedProjectID\n\t\t\t\tlog.Infof(\"antigravity: obtained project ID %s\", projectID)\n\t\t\t}\n\t\t}\n\n\t\tnow := time.Now()\n\t\tmetadata := map[string]any{\n\t\t\t\"type\":          \"antigravity\",\n\t\t\t\"access_token\":  tokenResp.AccessToken,\n\t\t\t\"refresh_token\": tokenResp.RefreshToken,\n\t\t\t\"expires_in\":    tokenResp.ExpiresIn,\n\t\t\t\"timestamp\":     now.UnixMilli(),\n\t\t\t\"expired\":       now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t\t}\n\t\tif email != \"\" {\n\t\t\tmetadata[\"email\"] = email\n\t\t}\n\t\tif projectID != \"\" {\n\t\t\tmetadata[\"project_id\"] = projectID\n\t\t}\n\n\t\tfileName := antigravity.CredentialFileName(email)\n\t\tlabel := strings.TrimSpace(email)\n\t\tif label == \"\" {\n\t\t\tlabel = \"antigravity\"\n\t\t}\n\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fileName,\n\t\t\tProvider: \"antigravity\",\n\t\t\tFileName: fileName,\n\t\t\tLabel:    label,\n\t\t\tMetadata: metadata,\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tlog.Errorf(\"Failed to save token to file: %v\", errSave)\n\t\t\tSetOAuthSessionError(state, \"Failed to save token to file\")\n\t\t\treturn\n\t\t}\n\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"antigravity\")\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tif projectID != \"\" {\n\t\t\tfmt.Printf(\"Using GCP project: %s\\n\", projectID)\n\t\t}\n\t\tfmt.Println(\"You can now use Antigravity services through this CLI\")\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestQwenToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing Qwen authentication...\")\n\n\tstate := fmt.Sprintf(\"gem-%d\", time.Now().UnixNano())\n\t// Initialize Qwen auth service\n\tqwenAuth := qwen.NewQwenAuth(h.cfg)\n\n\t// Generate authorization URL\n\tdeviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to generate authorization URL: %v\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate authorization url\"})\n\t\treturn\n\t}\n\tauthURL := deviceFlow.VerificationURIComplete\n\n\tRegisterOAuthSession(state, \"qwen\")\n\n\tgo func() {\n\t\tfmt.Println(\"Waiting for authentication...\")\n\t\ttokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)\n\t\tif errPollForToken != nil {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Printf(\"Authentication failed: %v\\n\", errPollForToken)\n\t\t\treturn\n\t\t}\n\n\t\t// Create token storage\n\t\ttokenStorage := qwenAuth.CreateTokenStorage(tokenData)\n\n\t\ttokenStorage.Email = fmt.Sprintf(\"%d\", time.Now().UnixMilli())\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fmt.Sprintf(\"qwen-%s.json\", tokenStorage.Email),\n\t\t\tProvider: \"qwen\",\n\t\t\tFileName: fmt.Sprintf(\"qwen-%s.json\", tokenStorage.Email),\n\t\t\tStorage:  tokenStorage,\n\t\t\tMetadata: map[string]any{\"email\": tokenStorage.Email},\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tlog.Errorf(\"Failed to save authentication tokens: %v\", errSave)\n\t\t\tSetOAuthSessionError(state, \"Failed to save authentication tokens\")\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tfmt.Println(\"You can now use Qwen services through this CLI\")\n\t\tCompleteOAuthSession(state)\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestKimiToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing Kimi authentication...\")\n\n\tstate := fmt.Sprintf(\"kmi-%d\", time.Now().UnixNano())\n\t// Initialize Kimi auth service\n\tkimiAuth := kimi.NewKimiAuth(h.cfg)\n\n\t// Generate authorization URL\n\tdeviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)\n\tif errStartDeviceFlow != nil {\n\t\tlog.Errorf(\"Failed to generate authorization URL: %v\", errStartDeviceFlow)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate authorization url\"})\n\t\treturn\n\t}\n\tauthURL := deviceFlow.VerificationURIComplete\n\tif authURL == \"\" {\n\t\tauthURL = deviceFlow.VerificationURI\n\t}\n\n\tRegisterOAuthSession(state, \"kimi\")\n\n\tgo func() {\n\t\tfmt.Println(\"Waiting for authentication...\")\n\t\tauthBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow)\n\t\tif errWaitForAuthorization != nil {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Printf(\"Authentication failed: %v\\n\", errWaitForAuthorization)\n\t\t\treturn\n\t\t}\n\n\t\t// Create token storage\n\t\ttokenStorage := kimiAuth.CreateTokenStorage(authBundle)\n\n\t\tmetadata := map[string]any{\n\t\t\t\"type\":          \"kimi\",\n\t\t\t\"access_token\":  authBundle.TokenData.AccessToken,\n\t\t\t\"refresh_token\": authBundle.TokenData.RefreshToken,\n\t\t\t\"token_type\":    authBundle.TokenData.TokenType,\n\t\t\t\"scope\":         authBundle.TokenData.Scope,\n\t\t\t\"timestamp\":     time.Now().UnixMilli(),\n\t\t}\n\t\tif authBundle.TokenData.ExpiresAt > 0 {\n\t\t\texpired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)\n\t\t\tmetadata[\"expired\"] = expired\n\t\t}\n\t\tif strings.TrimSpace(authBundle.DeviceID) != \"\" {\n\t\t\tmetadata[\"device_id\"] = strings.TrimSpace(authBundle.DeviceID)\n\t\t}\n\n\t\tfileName := fmt.Sprintf(\"kimi-%d.json\", time.Now().UnixMilli())\n\t\trecord := &coreauth.Auth{\n\t\t\tID:       fileName,\n\t\t\tProvider: \"kimi\",\n\t\t\tFileName: fileName,\n\t\t\tLabel:    \"Kimi User\",\n\t\t\tStorage:  tokenStorage,\n\t\t\tMetadata: metadata,\n\t\t}\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tlog.Errorf(\"Failed to save authentication tokens: %v\", errSave)\n\t\t\tSetOAuthSessionError(state, \"Failed to save authentication tokens\")\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tfmt.Println(\"You can now use Kimi services through this CLI\")\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"kimi\")\n\t}()\n\n\tc.JSON(200, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestIFlowToken(c *gin.Context) {\n\tctx := context.Background()\n\tctx = PopulateAuthContext(ctx, c)\n\n\tfmt.Println(\"Initializing iFlow authentication...\")\n\n\tstate := fmt.Sprintf(\"ifl-%d\", time.Now().UnixNano())\n\tauthSvc := iflowauth.NewIFlowAuth(h.cfg)\n\tauthURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort)\n\n\tRegisterOAuthSession(state, \"iflow\")\n\n\tisWebUI := isWebUIRequest(c)\n\tvar forwarder *callbackForwarder\n\tif isWebUI {\n\t\ttargetURL, errTarget := h.managementCallbackURL(\"/iflow/callback\")\n\t\tif errTarget != nil {\n\t\t\tlog.WithError(errTarget).Error(\"failed to compute iflow callback target\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"callback server unavailable\"})\n\t\t\treturn\n\t\t}\n\t\tvar errStart error\n\t\tif forwarder, errStart = startCallbackForwarder(iflowauth.CallbackPort, \"iflow\", targetURL); errStart != nil {\n\t\t\tlog.WithError(errStart).Error(\"failed to start iflow callback forwarder\")\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"failed to start callback server\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo func() {\n\t\tif isWebUI {\n\t\t\tdefer stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder)\n\t\t}\n\t\tfmt.Println(\"Waiting for authentication...\")\n\n\t\twaitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(\".oauth-iflow-%s.oauth\", state))\n\t\tdeadline := time.Now().Add(5 * time.Minute)\n\t\tvar resultMap map[string]string\n\t\tfor {\n\t\t\tif !IsOAuthSessionPending(state, \"iflow\") {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\t\tfmt.Println(\"Authentication failed: timeout waiting for callback\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif data, errR := os.ReadFile(waitFile); errR == nil {\n\t\t\t\t_ = os.Remove(waitFile)\n\t\t\t\t_ = json.Unmarshal(data, &resultMap)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t}\n\n\t\tif errStr := strings.TrimSpace(resultMap[\"error\"]); errStr != \"\" {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Printf(\"Authentication failed: %s\\n\", errStr)\n\t\t\treturn\n\t\t}\n\t\tif resultState := strings.TrimSpace(resultMap[\"state\"]); resultState != state {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Println(\"Authentication failed: state mismatch\")\n\t\t\treturn\n\t\t}\n\n\t\tcode := strings.TrimSpace(resultMap[\"code\"])\n\t\tif code == \"\" {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Println(\"Authentication failed: code missing\")\n\t\t\treturn\n\t\t}\n\n\t\ttokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)\n\t\tif errExchange != nil {\n\t\t\tSetOAuthSessionError(state, \"Authentication failed\")\n\t\t\tfmt.Printf(\"Authentication failed: %v\\n\", errExchange)\n\t\t\treturn\n\t\t}\n\n\t\ttokenStorage := authSvc.CreateTokenStorage(tokenData)\n\t\tidentifier := strings.TrimSpace(tokenStorage.Email)\n\t\tif identifier == \"\" {\n\t\t\tidentifier = fmt.Sprintf(\"%d\", time.Now().UnixMilli())\n\t\t\ttokenStorage.Email = identifier\n\t\t}\n\t\trecord := &coreauth.Auth{\n\t\t\tID:         fmt.Sprintf(\"iflow-%s.json\", identifier),\n\t\t\tProvider:   \"iflow\",\n\t\t\tFileName:   fmt.Sprintf(\"iflow-%s.json\", identifier),\n\t\t\tStorage:    tokenStorage,\n\t\t\tMetadata:   map[string]any{\"email\": identifier, \"api_key\": tokenStorage.APIKey},\n\t\t\tAttributes: map[string]string{\"api_key\": tokenStorage.APIKey},\n\t\t}\n\n\t\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\t\tif errSave != nil {\n\t\t\tSetOAuthSessionError(state, \"Failed to save authentication tokens\")\n\t\t\tlog.Errorf(\"Failed to save authentication tokens: %v\", errSave)\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Printf(\"Authentication successful! Token saved to %s\\n\", savedPath)\n\t\tif tokenStorage.APIKey != \"\" {\n\t\t\tfmt.Println(\"API key obtained and saved\")\n\t\t}\n\t\tfmt.Println(\"You can now use iFlow services through this CLI\")\n\t\tCompleteOAuthSession(state)\n\t\tCompleteOAuthSessionsByProvider(\"iflow\")\n\t}()\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\", \"url\": authURL, \"state\": state})\n}\n\nfunc (h *Handler) RequestIFlowCookieToken(c *gin.Context) {\n\tctx := context.Background()\n\n\tvar payload struct {\n\t\tCookie string `json:\"cookie\"`\n\t}\n\tif err := c.ShouldBindJSON(&payload); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"cookie is required\"})\n\t\treturn\n\t}\n\n\tcookieValue := strings.TrimSpace(payload.Cookie)\n\n\tif cookieValue == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"cookie is required\"})\n\t\treturn\n\t}\n\n\tcookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue)\n\tif errNormalize != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": errNormalize.Error()})\n\t\treturn\n\t}\n\n\t// Check for duplicate BXAuth before authentication\n\tbxAuth := iflowauth.ExtractBXAuth(cookieValue)\n\tif existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"failed to check duplicate\"})\n\t\treturn\n\t} else if existingFile != \"\" {\n\t\texistingFileName := filepath.Base(existingFile)\n\t\tc.JSON(http.StatusConflict, gin.H{\"status\": \"error\", \"error\": \"duplicate BXAuth found\", \"existing_file\": existingFileName})\n\t\treturn\n\t}\n\n\tauthSvc := iflowauth.NewIFlowAuth(h.cfg)\n\ttokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue)\n\tif errAuth != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": errAuth.Error()})\n\t\treturn\n\t}\n\n\ttokenData.Cookie = cookieValue\n\n\ttokenStorage := authSvc.CreateCookieTokenStorage(tokenData)\n\temail := strings.TrimSpace(tokenStorage.Email)\n\tif email == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"failed to extract email from token\"})\n\t\treturn\n\t}\n\n\tfileName := iflowauth.SanitizeIFlowFileName(email)\n\tif fileName == \"\" {\n\t\tfileName = fmt.Sprintf(\"iflow-%d\", time.Now().UnixMilli())\n\t} else {\n\t\tfileName = fmt.Sprintf(\"iflow-%s\", fileName)\n\t}\n\n\ttokenStorage.Email = email\n\ttimestamp := time.Now().Unix()\n\n\trecord := &coreauth.Auth{\n\t\tID:       fmt.Sprintf(\"%s-%d.json\", fileName, timestamp),\n\t\tProvider: \"iflow\",\n\t\tFileName: fmt.Sprintf(\"%s-%d.json\", fileName, timestamp),\n\t\tStorage:  tokenStorage,\n\t\tMetadata: map[string]any{\n\t\t\t\"email\":        email,\n\t\t\t\"api_key\":      tokenStorage.APIKey,\n\t\t\t\"expired\":      tokenStorage.Expire,\n\t\t\t\"cookie\":       tokenStorage.Cookie,\n\t\t\t\"type\":         tokenStorage.Type,\n\t\t\t\"last_refresh\": tokenStorage.LastRefresh,\n\t\t},\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": tokenStorage.APIKey,\n\t\t},\n\t}\n\n\tsavedPath, errSave := h.saveTokenRecord(ctx, record)\n\tif errSave != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"failed to save authentication tokens\"})\n\t\treturn\n\t}\n\n\tfmt.Printf(\"iFlow cookie authentication successful. Token saved to %s\\n\", savedPath)\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\":     \"ok\",\n\t\t\"saved_path\": savedPath,\n\t\t\"email\":      email,\n\t\t\"expired\":    tokenStorage.Expire,\n\t\t\"type\":       tokenStorage.Type,\n\t})\n}\n\ntype projectSelectionRequiredError struct{}\n\nfunc (e *projectSelectionRequiredError) Error() string {\n\treturn \"gemini cli: project selection required\"\n}\n\nfunc ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {\n\tif storage == nil {\n\t\treturn fmt.Errorf(\"gemini storage is nil\")\n\t}\n\n\ttrimmedRequest := strings.TrimSpace(requestedProject)\n\tif trimmedRequest == \"\" {\n\t\tprojects, errProjects := fetchGCPProjects(ctx, httpClient)\n\t\tif errProjects != nil {\n\t\t\treturn fmt.Errorf(\"fetch project list: %w\", errProjects)\n\t\t}\n\t\tif len(projects) == 0 {\n\t\t\treturn fmt.Errorf(\"no Google Cloud projects available for this account\")\n\t\t}\n\t\ttrimmedRequest = strings.TrimSpace(projects[0].ProjectID)\n\t\tif trimmedRequest == \"\" {\n\t\t\treturn fmt.Errorf(\"resolved project id is empty\")\n\t\t}\n\t\tstorage.Auto = true\n\t} else {\n\t\tstorage.Auto = false\n\t}\n\n\tif err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {\n\t\treturn err\n\t}\n\n\tif strings.TrimSpace(storage.ProjectID) == \"\" {\n\t\tstorage.ProjectID = trimmedRequest\n\t}\n\n\treturn nil\n}\n\nfunc onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {\n\tprojects, errProjects := fetchGCPProjects(ctx, httpClient)\n\tif errProjects != nil {\n\t\treturn nil, fmt.Errorf(\"fetch project list: %w\", errProjects)\n\t}\n\tif len(projects) == 0 {\n\t\treturn nil, fmt.Errorf(\"no Google Cloud projects available for this account\")\n\t}\n\tactivated := make([]string, 0, len(projects))\n\tseen := make(map[string]struct{}, len(projects))\n\tfor _, project := range projects {\n\t\tcandidate := strings.TrimSpace(project.ProjectID)\n\t\tif candidate == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, dup := seen[candidate]; dup {\n\t\t\tcontinue\n\t\t}\n\t\tif err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"onboard project %s: %w\", candidate, err)\n\t\t}\n\t\tfinalID := strings.TrimSpace(storage.ProjectID)\n\t\tif finalID == \"\" {\n\t\t\tfinalID = candidate\n\t\t}\n\t\tactivated = append(activated, finalID)\n\t\tseen[candidate] = struct{}{}\n\t}\n\tif len(activated) == 0 {\n\t\treturn nil, fmt.Errorf(\"no Google Cloud projects available for this account\")\n\t}\n\treturn activated, nil\n}\n\nfunc ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {\n\tfor _, pid := range projectIDs {\n\t\ttrimmed := strings.TrimSpace(pid)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tisChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)\n\t\tif errCheck != nil {\n\t\t\treturn fmt.Errorf(\"project %s: %w\", trimmed, errCheck)\n\t\t}\n\t\tif !isChecked {\n\t\t\treturn fmt.Errorf(\"project %s: Cloud AI API not enabled\", trimmed)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {\n\tmetadata := map[string]string{\n\t\t\"ideType\":    \"IDE_UNSPECIFIED\",\n\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\"pluginType\": \"GEMINI\",\n\t}\n\n\ttrimmedRequest := strings.TrimSpace(requestedProject)\n\texplicitProject := trimmedRequest != \"\"\n\n\tloadReqBody := map[string]any{\n\t\t\"metadata\": metadata,\n\t}\n\tif explicitProject {\n\t\tloadReqBody[\"cloudaicompanionProject\"] = trimmedRequest\n\t}\n\n\tvar loadResp map[string]any\n\tif errLoad := callGeminiCLI(ctx, httpClient, \"loadCodeAssist\", loadReqBody, &loadResp); errLoad != nil {\n\t\treturn fmt.Errorf(\"load code assist: %w\", errLoad)\n\t}\n\n\ttierID := \"legacy-tier\"\n\tif tiers, okTiers := loadResp[\"allowedTiers\"].([]any); okTiers {\n\t\tfor _, rawTier := range tiers {\n\t\t\ttier, okTier := rawTier.(map[string]any)\n\t\t\tif !okTier {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif isDefault, okDefault := tier[\"isDefault\"].(bool); okDefault && isDefault {\n\t\t\t\tif id, okID := tier[\"id\"].(string); okID && strings.TrimSpace(id) != \"\" {\n\t\t\t\t\ttierID = strings.TrimSpace(id)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprojectID := trimmedRequest\n\tif projectID == \"\" {\n\t\tif id, okProject := loadResp[\"cloudaicompanionProject\"].(string); okProject {\n\t\t\tprojectID = strings.TrimSpace(id)\n\t\t}\n\t\tif projectID == \"\" {\n\t\t\tif projectMap, okProject := loadResp[\"cloudaicompanionProject\"].(map[string]any); okProject {\n\t\t\t\tif id, okID := projectMap[\"id\"].(string); okID {\n\t\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif projectID == \"\" {\n\t\t// Auto-discovery: try onboardUser without specifying a project\n\t\t// to let Google auto-provision one (matches Gemini CLI headless behavior\n\t\t// and Antigravity's FetchProjectID pattern).\n\t\tautoOnboardReq := map[string]any{\n\t\t\t\"tierId\":   tierID,\n\t\t\t\"metadata\": metadata,\n\t\t}\n\n\t\tautoCtx, autoCancel := context.WithTimeout(ctx, 30*time.Second)\n\t\tdefer autoCancel()\n\t\tfor attempt := 1; ; attempt++ {\n\t\t\tvar onboardResp map[string]any\n\t\t\tif errOnboard := callGeminiCLI(autoCtx, httpClient, \"onboardUser\", autoOnboardReq, &onboardResp); errOnboard != nil {\n\t\t\t\treturn fmt.Errorf(\"auto-discovery onboardUser: %w\", errOnboard)\n\t\t\t}\n\n\t\t\tif done, okDone := onboardResp[\"done\"].(bool); okDone && done {\n\t\t\t\tif resp, okResp := onboardResp[\"response\"].(map[string]any); okResp {\n\t\t\t\t\tswitch v := resp[\"cloudaicompanionProject\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tprojectID = strings.TrimSpace(v)\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tif id, okID := v[\"id\"].(string); okID {\n\t\t\t\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tlog.Debugf(\"Auto-discovery: onboarding in progress, attempt %d...\", attempt)\n\t\t\tselect {\n\t\t\tcase <-autoCtx.Done():\n\t\t\t\treturn &projectSelectionRequiredError{}\n\t\t\tcase <-time.After(2 * time.Second):\n\t\t\t}\n\t\t}\n\n\t\tif projectID == \"\" {\n\t\t\treturn &projectSelectionRequiredError{}\n\t\t}\n\t\tlog.Infof(\"Auto-discovered project ID via onboarding: %s\", projectID)\n\t}\n\n\tonboardReqBody := map[string]any{\n\t\t\"tierId\":                  tierID,\n\t\t\"metadata\":                metadata,\n\t\t\"cloudaicompanionProject\": projectID,\n\t}\n\n\tstorage.ProjectID = projectID\n\n\tfor {\n\t\tvar onboardResp map[string]any\n\t\tif errOnboard := callGeminiCLI(ctx, httpClient, \"onboardUser\", onboardReqBody, &onboardResp); errOnboard != nil {\n\t\t\treturn fmt.Errorf(\"onboard user: %w\", errOnboard)\n\t\t}\n\n\t\tif done, okDone := onboardResp[\"done\"].(bool); okDone && done {\n\t\t\tresponseProjectID := \"\"\n\t\t\tif resp, okResp := onboardResp[\"response\"].(map[string]any); okResp {\n\t\t\t\tswitch projectValue := resp[\"cloudaicompanionProject\"].(type) {\n\t\t\t\tcase map[string]any:\n\t\t\t\t\tif id, okID := projectValue[\"id\"].(string); okID {\n\t\t\t\t\t\tresponseProjectID = strings.TrimSpace(id)\n\t\t\t\t\t}\n\t\t\t\tcase string:\n\t\t\t\t\tresponseProjectID = strings.TrimSpace(projectValue)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfinalProjectID := projectID\n\t\t\tif responseProjectID != \"\" {\n\t\t\t\tif explicitProject && !strings.EqualFold(responseProjectID, projectID) {\n\t\t\t\t\t// Check if this is a free user (gen-lang-client projects or free/legacy tier)\n\t\t\t\t\tisFreeUser := strings.HasPrefix(projectID, \"gen-lang-client-\") ||\n\t\t\t\t\t\tstrings.EqualFold(tierID, \"FREE\") ||\n\t\t\t\t\t\tstrings.EqualFold(tierID, \"LEGACY\")\n\n\t\t\t\t\tif isFreeUser {\n\t\t\t\t\t\t// For free users, use backend project ID for preview model access\n\t\t\t\t\t\tlog.Infof(\"Gemini onboarding: frontend project %s maps to backend project %s\", projectID, responseProjectID)\n\t\t\t\t\t\tlog.Infof(\"Using backend project ID: %s (recommended for preview model access)\", responseProjectID)\n\t\t\t\t\t\tfinalProjectID = responseProjectID\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Pro users: keep requested project ID (original behavior)\n\t\t\t\t\t\tlog.Warnf(\"Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.\", responseProjectID, projectID)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfinalProjectID = responseProjectID\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tstorage.ProjectID = strings.TrimSpace(finalProjectID)\n\t\t\tif storage.ProjectID == \"\" {\n\t\t\t\tstorage.ProjectID = strings.TrimSpace(projectID)\n\t\t\t}\n\t\t\tif storage.ProjectID == \"\" {\n\t\t\t\treturn fmt.Errorf(\"onboard user completed without project id\")\n\t\t\t}\n\t\t\tlog.Infof(\"Onboarding complete. Using Project ID: %s\", storage.ProjectID)\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Println(\"Onboarding in progress, waiting 5 seconds...\")\n\t\ttime.Sleep(5 * time.Second)\n\t}\n}\n\nfunc callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {\n\tendPointURL := fmt.Sprintf(\"%s/%s:%s\", geminiCLIEndpoint, geminiCLIVersion, endpoint)\n\tif strings.HasPrefix(endpoint, \"operations/\") {\n\t\tendPointURL = fmt.Sprintf(\"%s/%s\", geminiCLIEndpoint, endpoint)\n\t}\n\n\tvar reader io.Reader\n\tif body != nil {\n\t\trawBody, errMarshal := json.Marshal(body)\n\t\tif errMarshal != nil {\n\t\t\treturn fmt.Errorf(\"marshal request body: %w\", errMarshal)\n\t\t}\n\t\treader = bytes.NewReader(rawBody)\n\t}\n\n\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)\n\tif errRequest != nil {\n\t\treturn fmt.Errorf(\"create request: %w\", errRequest)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn fmt.Errorf(\"execute request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"api request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tif result == nil {\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\treturn nil\n\t}\n\n\tif errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {\n\t\treturn fmt.Errorf(\"decode response body: %w\", errDecode)\n\t}\n\n\treturn nil\n}\n\nfunc fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {\n\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, \"https://cloudresourcemanager.googleapis.com/v1/projects\", nil)\n\tif errRequest != nil {\n\t\treturn nil, fmt.Errorf(\"could not create project list request: %w\", errRequest)\n\t}\n\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn nil, fmt.Errorf(\"failed to execute project list request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"project list request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tvar projects interfaces.GCPProject\n\tif errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal project list: %w\", errDecode)\n\t}\n\n\treturn projects.Projects, nil\n}\n\nfunc checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {\n\tserviceUsageURL := \"https://serviceusage.googleapis.com\"\n\trequiredServices := []string{\n\t\t\"cloudaicompanion.googleapis.com\",\n\t}\n\tfor _, service := range requiredServices {\n\t\tcheckURL := fmt.Sprintf(\"%s/v1/projects/%s/services/%s\", serviceUsageURL, projectID, service)\n\t\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)\n\t\tif errRequest != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to create request: %w\", errRequest)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\t\tresp, errDo := httpClient.Do(req)\n\t\tif errDo != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to execute request: %w\", errDo)\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\t\tif gjson.GetBytes(bodyBytes, \"state\").String() == \"ENABLED\" {\n\t\t\t\t_ = resp.Body.Close()\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t_ = resp.Body.Close()\n\n\t\tenableURL := fmt.Sprintf(\"%s/v1/projects/%s/services/%s:enable\", serviceUsageURL, projectID, service)\n\t\treq, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader(\"{}\"))\n\t\tif errRequest != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to create request: %w\", errRequest)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\t\tresp, errDo = httpClient.Do(req)\n\t\tif errDo != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to execute request: %w\", errDo)\n\t\t}\n\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\terrMessage := string(bodyBytes)\n\t\terrMessageResult := gjson.GetBytes(bodyBytes, \"error.message\")\n\t\tif errMessageResult.Exists() {\n\t\t\terrMessage = errMessageResult.String()\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {\n\t\t\t_ = resp.Body.Close()\n\t\t\tcontinue\n\t\t} else if resp.StatusCode == http.StatusBadRequest {\n\t\t\t_ = resp.Body.Close()\n\t\t\tif strings.Contains(strings.ToLower(errMessage), \"already enabled\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t_ = resp.Body.Close()\n\t\treturn false, fmt.Errorf(\"project activation required: %s\", errMessage)\n\t}\n\treturn true, nil\n}\n\nfunc (h *Handler) GetAuthStatus(c *gin.Context) {\n\tstate := strings.TrimSpace(c.Query(\"state\"))\n\tif state == \"\" {\n\t\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n\t\treturn\n\t}\n\tif err := ValidateOAuthState(state); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"invalid state\"})\n\t\treturn\n\t}\n\n\t_, status, ok := GetOAuthSession(state)\n\tif !ok {\n\t\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n\t\treturn\n\t}\n\tif status != \"\" {\n\t\tc.JSON(http.StatusOK, gin.H{\"status\": \"error\", \"error\": status})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"wait\"})\n}\n\n// PopulateAuthContext extracts request info and adds it to the context\nfunc PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {\n\tinfo := &coreauth.RequestInfo{\n\t\tQuery:   c.Request.URL.Query(),\n\t\tHeaders: c.Request.Header,\n\t}\n\treturn coreauth.WithRequestInfo(ctx, info)\n}\n"
  },
  {
    "path": "internal/api/handlers/management/auth_files_delete_test.go",
    "content": "package management\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {\n\tt.Setenv(\"MANAGEMENT_PASSWORD\", \"\")\n\tgin.SetMode(gin.TestMode)\n\n\ttempDir := t.TempDir()\n\tauthDir := filepath.Join(tempDir, \"auth\")\n\texternalDir := filepath.Join(tempDir, \"external\")\n\tif errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", errMkdirAuth)\n\t}\n\tif errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {\n\t\tt.Fatalf(\"failed to create external dir: %v\", errMkdirExternal)\n\t}\n\n\tfileName := \"codex-user@example.com-plus.json\"\n\tshadowPath := filepath.Join(authDir, fileName)\n\trealPath := filepath.Join(externalDir, fileName)\n\tif errWriteShadow := os.WriteFile(shadowPath, []byte(`{\"type\":\"codex\",\"email\":\"shadow@example.com\"}`), 0o600); errWriteShadow != nil {\n\t\tt.Fatalf(\"failed to write shadow file: %v\", errWriteShadow)\n\t}\n\tif errWriteReal := os.WriteFile(realPath, []byte(`{\"type\":\"codex\",\"email\":\"real@example.com\"}`), 0o600); errWriteReal != nil {\n\t\tt.Fatalf(\"failed to write real file: %v\", errWriteReal)\n\t}\n\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\trecord := &coreauth.Auth{\n\t\tID:          \"legacy/\" + fileName,\n\t\tFileName:    fileName,\n\t\tProvider:    \"codex\",\n\t\tStatus:      coreauth.StatusError,\n\t\tUnavailable: true,\n\t\tAttributes: map[string]string{\n\t\t\t\"path\": realPath,\n\t\t},\n\t\tMetadata: map[string]any{\n\t\t\t\"type\":  \"codex\",\n\t\t\t\"email\": \"real@example.com\",\n\t\t},\n\t}\n\tif _, errRegister := manager.Register(context.Background(), record); errRegister != nil {\n\t\tt.Fatalf(\"failed to register auth record: %v\", errRegister)\n\t}\n\n\th := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)\n\th.tokenStore = &memoryAuthStore{}\n\n\tdeleteRec := httptest.NewRecorder()\n\tdeleteCtx, _ := gin.CreateTestContext(deleteRec)\n\tdeleteReq := httptest.NewRequest(http.MethodDelete, \"/v0/management/auth-files?name=\"+url.QueryEscape(fileName), nil)\n\tdeleteCtx.Request = deleteReq\n\th.DeleteAuthFile(deleteCtx)\n\n\tif deleteRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected delete status %d, got %d with body %s\", http.StatusOK, deleteRec.Code, deleteRec.Body.String())\n\t}\n\tif _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {\n\t\tt.Fatalf(\"expected managed auth file to be removed, stat err: %v\", errStatReal)\n\t}\n\tif _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {\n\t\tt.Fatalf(\"expected shadow auth file to remain, stat err: %v\", errStatShadow)\n\t}\n\n\tlistRec := httptest.NewRecorder()\n\tlistCtx, _ := gin.CreateTestContext(listRec)\n\tlistReq := httptest.NewRequest(http.MethodGet, \"/v0/management/auth-files\", nil)\n\tlistCtx.Request = listReq\n\th.ListAuthFiles(listCtx)\n\n\tif listRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected list status %d, got %d with body %s\", http.StatusOK, listRec.Code, listRec.Body.String())\n\t}\n\tvar listPayload map[string]any\n\tif errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {\n\t\tt.Fatalf(\"failed to decode list payload: %v\", errUnmarshal)\n\t}\n\tfilesRaw, ok := listPayload[\"files\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected files array, payload: %#v\", listPayload)\n\t}\n\tif len(filesRaw) != 0 {\n\t\tt.Fatalf(\"expected removed auth to be hidden from list, got %d entries\", len(filesRaw))\n\t}\n}\n\nfunc TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {\n\tt.Setenv(\"MANAGEMENT_PASSWORD\", \"\")\n\tgin.SetMode(gin.TestMode)\n\n\tauthDir := t.TempDir()\n\tfileName := \"fallback-user.json\"\n\tfilePath := filepath.Join(authDir, fileName)\n\tif errWrite := os.WriteFile(filePath, []byte(`{\"type\":\"codex\"}`), 0o600); errWrite != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", errWrite)\n\t}\n\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\th := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)\n\th.tokenStore = &memoryAuthStore{}\n\n\tdeleteRec := httptest.NewRecorder()\n\tdeleteCtx, _ := gin.CreateTestContext(deleteRec)\n\tdeleteReq := httptest.NewRequest(http.MethodDelete, \"/v0/management/auth-files?name=\"+url.QueryEscape(fileName), nil)\n\tdeleteCtx.Request = deleteReq\n\th.DeleteAuthFile(deleteCtx)\n\n\tif deleteRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected delete status %d, got %d with body %s\", http.StatusOK, deleteRec.Code, deleteRec.Body.String())\n\t}\n\tif _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {\n\t\tt.Fatalf(\"expected auth file to be removed from auth dir, stat err: %v\", errStat)\n\t}\n}\n"
  },
  {
    "path": "internal/api/handlers/management/config_basic.go",
    "content": "package management\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tlatestReleaseURL       = \"https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest\"\n\tlatestReleaseUserAgent = \"CLIProxyAPI\"\n)\n\nfunc (h *Handler) GetConfig(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{})\n\t\treturn\n\t}\n\tc.JSON(200, new(*h.cfg))\n}\n\ntype releaseInfo struct {\n\tTagName string `json:\"tag_name\"`\n\tName    string `json:\"name\"`\n}\n\n// GetLatestVersion returns the latest release version from GitHub without downloading assets.\nfunc (h *Handler) GetLatestVersion(c *gin.Context) {\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tproxyURL := \"\"\n\tif h != nil && h.cfg != nil {\n\t\tproxyURL = strings.TrimSpace(h.cfg.ProxyURL)\n\t}\n\tif proxyURL != \"\" {\n\t\tsdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}\n\t\tutil.SetProxy(sdkCfg, client)\n\t}\n\n\treq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"request_create_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Set(\"User-Agent\", latestReleaseUserAgent)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"request_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Debug(\"failed to close latest version response body\")\n\t\t}\n\t}()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"unexpected_status\", \"message\": fmt.Sprintf(\"status %d: %s\", resp.StatusCode, strings.TrimSpace(string(body)))})\n\t\treturn\n\t}\n\n\tvar info releaseInfo\n\tif errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"decode_failed\", \"message\": errDecode.Error()})\n\t\treturn\n\t}\n\n\tversion := strings.TrimSpace(info.TagName)\n\tif version == \"\" {\n\t\tversion = strings.TrimSpace(info.Name)\n\t}\n\tif version == \"\" {\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"invalid_response\", \"message\": \"missing release version\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"latest-version\": version})\n}\n\nfunc WriteConfig(path string, data []byte) error {\n\tdata = config.NormalizeCommentIndentation(data)\n\tf, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, errWrite := f.Write(data); errWrite != nil {\n\t\t_ = f.Close()\n\t\treturn errWrite\n\t}\n\tif errSync := f.Sync(); errSync != nil {\n\t\t_ = f.Close()\n\t\treturn errSync\n\t}\n\treturn f.Close()\n}\n\nfunc (h *Handler) PutConfigYAML(c *gin.Context) {\n\tbody, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid_yaml\", \"message\": \"cannot read request body\"})\n\t\treturn\n\t}\n\tvar cfg config.Config\n\tif err = yaml.Unmarshal(body, &cfg); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid_yaml\", \"message\": err.Error()})\n\t\treturn\n\t}\n\t// Validate config using LoadConfigOptional with optional=false to enforce parsing\n\ttmpDir := filepath.Dir(h.configFilePath)\n\ttmpFile, err := os.CreateTemp(tmpDir, \"config-validate-*.yaml\")\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"write_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\ttempFile := tmpFile.Name()\n\tif _, errWrite := tmpFile.Write(body); errWrite != nil {\n\t\t_ = tmpFile.Close()\n\t\t_ = os.Remove(tempFile)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"write_failed\", \"message\": errWrite.Error()})\n\t\treturn\n\t}\n\tif errClose := tmpFile.Close(); errClose != nil {\n\t\t_ = os.Remove(tempFile)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"write_failed\", \"message\": errClose.Error()})\n\t\treturn\n\t}\n\tdefer func() {\n\t\t_ = os.Remove(tempFile)\n\t}()\n\t_, err = config.LoadConfigOptional(tempFile, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusUnprocessableEntity, gin.H{\"error\": \"invalid_config\", \"message\": err.Error()})\n\t\treturn\n\t}\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\tif WriteConfig(h.configFilePath, body) != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"write_failed\", \"message\": \"failed to write config\"})\n\t\treturn\n\t}\n\t// Reload into handler to keep memory in sync\n\tnewCfg, err := config.LoadConfig(h.configFilePath)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"reload_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\th.cfg = newCfg\n\tc.JSON(http.StatusOK, gin.H{\"ok\": true, \"changed\": []string{\"config\"}})\n}\n\n// GetConfigYAML returns the raw config.yaml file bytes without re-encoding.\n// It preserves comments and original formatting/styles.\nfunc (h *Handler) GetConfigYAML(c *gin.Context) {\n\tdata, err := os.ReadFile(h.configFilePath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"not_found\", \"message\": \"config file not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"read_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\tc.Header(\"Content-Type\", \"application/yaml; charset=utf-8\")\n\tc.Header(\"Cache-Control\", \"no-store\")\n\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t// Write raw bytes as-is\n\t_, _ = c.Writer.Write(data)\n}\n\n// Debug\nfunc (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{\"debug\": h.cfg.Debug}) }\nfunc (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }\n\n// UsageStatisticsEnabled\nfunc (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {\n\tc.JSON(200, gin.H{\"usage-statistics-enabled\": h.cfg.UsageStatisticsEnabled})\n}\nfunc (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })\n}\n\n// UsageStatisticsEnabled\nfunc (h *Handler) GetLoggingToFile(c *gin.Context) {\n\tc.JSON(200, gin.H{\"logging-to-file\": h.cfg.LoggingToFile})\n}\nfunc (h *Handler) PutLoggingToFile(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })\n}\n\n// LogsMaxTotalSizeMB\nfunc (h *Handler) GetLogsMaxTotalSizeMB(c *gin.Context) {\n\tc.JSON(200, gin.H{\"logs-max-total-size-mb\": h.cfg.LogsMaxTotalSizeMB})\n}\nfunc (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {\n\tvar body struct {\n\t\tValue *int `json:\"value\"`\n\t}\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tvalue := *body.Value\n\tif value < 0 {\n\t\tvalue = 0\n\t}\n\th.cfg.LogsMaxTotalSizeMB = value\n\th.persist(c)\n}\n\n// ErrorLogsMaxFiles\nfunc (h *Handler) GetErrorLogsMaxFiles(c *gin.Context) {\n\tc.JSON(200, gin.H{\"error-logs-max-files\": h.cfg.ErrorLogsMaxFiles})\n}\nfunc (h *Handler) PutErrorLogsMaxFiles(c *gin.Context) {\n\tvar body struct {\n\t\tValue *int `json:\"value\"`\n\t}\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tvalue := *body.Value\n\tif value < 0 {\n\t\tvalue = 10\n\t}\n\th.cfg.ErrorLogsMaxFiles = value\n\th.persist(c)\n}\n\n// Request log\nfunc (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{\"request-log\": h.cfg.RequestLog}) }\nfunc (h *Handler) PutRequestLog(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })\n}\n\n// Websocket auth\nfunc (h *Handler) GetWebsocketAuth(c *gin.Context) {\n\tc.JSON(200, gin.H{\"ws-auth\": h.cfg.WebsocketAuth})\n}\nfunc (h *Handler) PutWebsocketAuth(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })\n}\n\n// Request retry\nfunc (h *Handler) GetRequestRetry(c *gin.Context) {\n\tc.JSON(200, gin.H{\"request-retry\": h.cfg.RequestRetry})\n}\nfunc (h *Handler) PutRequestRetry(c *gin.Context) {\n\th.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })\n}\n\n// Max retry interval\nfunc (h *Handler) GetMaxRetryInterval(c *gin.Context) {\n\tc.JSON(200, gin.H{\"max-retry-interval\": h.cfg.MaxRetryInterval})\n}\nfunc (h *Handler) PutMaxRetryInterval(c *gin.Context) {\n\th.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })\n}\n\n// ForceModelPrefix\nfunc (h *Handler) GetForceModelPrefix(c *gin.Context) {\n\tc.JSON(200, gin.H{\"force-model-prefix\": h.cfg.ForceModelPrefix})\n}\nfunc (h *Handler) PutForceModelPrefix(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.ForceModelPrefix = v })\n}\n\nfunc normalizeRoutingStrategy(strategy string) (string, bool) {\n\tnormalized := strings.ToLower(strings.TrimSpace(strategy))\n\tswitch normalized {\n\tcase \"\", \"round-robin\", \"roundrobin\", \"rr\":\n\t\treturn \"round-robin\", true\n\tcase \"fill-first\", \"fillfirst\", \"ff\":\n\t\treturn \"fill-first\", true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\n// RoutingStrategy\nfunc (h *Handler) GetRoutingStrategy(c *gin.Context) {\n\tstrategy, ok := normalizeRoutingStrategy(h.cfg.Routing.Strategy)\n\tif !ok {\n\t\tc.JSON(200, gin.H{\"strategy\": strings.TrimSpace(h.cfg.Routing.Strategy)})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"strategy\": strategy})\n}\nfunc (h *Handler) PutRoutingStrategy(c *gin.Context) {\n\tvar body struct {\n\t\tValue *string `json:\"value\"`\n\t}\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tnormalized, ok := normalizeRoutingStrategy(*body.Value)\n\tif !ok {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid strategy\"})\n\t\treturn\n\t}\n\th.cfg.Routing.Strategy = normalized\n\th.persist(c)\n}\n\n// Proxy URL\nfunc (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{\"proxy-url\": h.cfg.ProxyURL}) }\nfunc (h *Handler) PutProxyURL(c *gin.Context) {\n\th.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })\n}\nfunc (h *Handler) DeleteProxyURL(c *gin.Context) {\n\th.cfg.ProxyURL = \"\"\n\th.persist(c)\n}\n"
  },
  {
    "path": "internal/api/handlers/management/config_lists.go",
    "content": "package management\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// Generic helpers for list[string]\nfunc (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []string\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []string `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\tset(arr)\n\tif after != nil {\n\t\tafter()\n\t}\n\th.persist(c)\n}\n\nfunc (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {\n\tvar body struct {\n\t\tOld   *string `json:\"old\"`\n\t\tNew   *string `json:\"new\"`\n\t\tIndex *int    `json:\"index\"`\n\t\tValue *string `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tif body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {\n\t\t(*target)[*body.Index] = *body.Value\n\t\tif after != nil {\n\t\t\tafter()\n\t\t}\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif body.Old != nil && body.New != nil {\n\t\tfor i := range *target {\n\t\t\tif (*target)[i] == *body.Old {\n\t\t\t\t(*target)[i] = *body.New\n\t\t\t\tif after != nil {\n\t\t\t\t\tafter()\n\t\t\t\t}\n\t\t\t\th.persist(c)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t*target = append(*target, *body.New)\n\t\tif after != nil {\n\t\t\tafter()\n\t\t}\n\t\th.persist(c)\n\t\treturn\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing fields\"})\n}\n\nfunc (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\t_, err := fmt.Sscanf(idxStr, \"%d\", &idx)\n\t\tif err == nil && idx >= 0 && idx < len(*target) {\n\t\t\t*target = append((*target)[:idx], (*target)[idx+1:]...)\n\t\t\tif after != nil {\n\t\t\t\tafter()\n\t\t\t}\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tif val := strings.TrimSpace(c.Query(\"value\")); val != \"\" {\n\t\tout := make([]string, 0, len(*target))\n\t\tfor _, v := range *target {\n\t\t\tif strings.TrimSpace(v) != val {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\t*target = out\n\t\tif after != nil {\n\t\t\tafter()\n\t\t}\n\t\th.persist(c)\n\t\treturn\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing index or value\"})\n}\n\n// api-keys\nfunc (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{\"api-keys\": h.cfg.APIKeys}) }\nfunc (h *Handler) PutAPIKeys(c *gin.Context) {\n\th.putStringList(c, func(v []string) {\n\t\th.cfg.APIKeys = append([]string(nil), v...)\n\t}, nil)\n}\nfunc (h *Handler) PatchAPIKeys(c *gin.Context) {\n\th.patchStringList(c, &h.cfg.APIKeys, func() {})\n}\nfunc (h *Handler) DeleteAPIKeys(c *gin.Context) {\n\th.deleteFromStringList(c, &h.cfg.APIKeys, func() {})\n}\n\n// gemini-api-key: []GeminiKey\nfunc (h *Handler) GetGeminiKeys(c *gin.Context) {\n\tc.JSON(200, gin.H{\"gemini-api-key\": h.cfg.GeminiKey})\n}\nfunc (h *Handler) PutGeminiKeys(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []config.GeminiKey\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []config.GeminiKey `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\th.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)\n\th.cfg.SanitizeGeminiKeys()\n\th.persist(c)\n}\nfunc (h *Handler) PatchGeminiKey(c *gin.Context) {\n\ttype geminiKeyPatch struct {\n\t\tAPIKey         *string            `json:\"api-key\"`\n\t\tPrefix         *string            `json:\"prefix\"`\n\t\tBaseURL        *string            `json:\"base-url\"`\n\t\tProxyURL       *string            `json:\"proxy-url\"`\n\t\tHeaders        *map[string]string `json:\"headers\"`\n\t\tExcludedModels *[]string          `json:\"excluded-models\"`\n\t}\n\tvar body struct {\n\t\tIndex *int            `json:\"index\"`\n\t\tMatch *string         `json:\"match\"`\n\t\tValue *geminiKeyPatch `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\ttargetIndex := -1\n\tif body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {\n\t\ttargetIndex = *body.Index\n\t}\n\tif targetIndex == -1 && body.Match != nil {\n\t\tmatch := strings.TrimSpace(*body.Match)\n\t\tif match != \"\" {\n\t\t\tfor i := range h.cfg.GeminiKey {\n\t\t\t\tif h.cfg.GeminiKey[i].APIKey == match {\n\t\t\t\t\ttargetIndex = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif targetIndex == -1 {\n\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\treturn\n\t}\n\n\tentry := h.cfg.GeminiKey[targetIndex]\n\tif body.Value.APIKey != nil {\n\t\ttrimmed := strings.TrimSpace(*body.Value.APIKey)\n\t\tif trimmed == \"\" {\n\t\t\th.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)\n\t\t\th.cfg.SanitizeGeminiKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t\tentry.APIKey = trimmed\n\t}\n\tif body.Value.Prefix != nil {\n\t\tentry.Prefix = strings.TrimSpace(*body.Value.Prefix)\n\t}\n\tif body.Value.BaseURL != nil {\n\t\tentry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)\n\t}\n\tif body.Value.ProxyURL != nil {\n\t\tentry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)\n\t}\n\tif body.Value.Headers != nil {\n\t\tentry.Headers = config.NormalizeHeaders(*body.Value.Headers)\n\t}\n\tif body.Value.ExcludedModels != nil {\n\t\tentry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)\n\t}\n\th.cfg.GeminiKey[targetIndex] = entry\n\th.cfg.SanitizeGeminiKeys()\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteGeminiKey(c *gin.Context) {\n\tif val := strings.TrimSpace(c.Query(\"api-key\")); val != \"\" {\n\t\tout := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))\n\t\tfor _, v := range h.cfg.GeminiKey {\n\t\t\tif v.APIKey != val {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\tif len(out) != len(h.cfg.GeminiKey) {\n\t\t\th.cfg.GeminiKey = out\n\t\t\th.cfg.SanitizeGeminiKeys()\n\t\t\th.persist(c)\n\t\t} else {\n\t\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\t}\n\t\treturn\n\t}\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\tif _, err := fmt.Sscanf(idxStr, \"%d\", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {\n\t\t\th.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)\n\t\t\th.cfg.SanitizeGeminiKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing api-key or index\"})\n}\n\n// claude-api-key: []ClaudeKey\nfunc (h *Handler) GetClaudeKeys(c *gin.Context) {\n\tc.JSON(200, gin.H{\"claude-api-key\": h.cfg.ClaudeKey})\n}\nfunc (h *Handler) PutClaudeKeys(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []config.ClaudeKey\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []config.ClaudeKey `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\tfor i := range arr {\n\t\tnormalizeClaudeKey(&arr[i])\n\t}\n\th.cfg.ClaudeKey = arr\n\th.cfg.SanitizeClaudeKeys()\n\th.persist(c)\n}\nfunc (h *Handler) PatchClaudeKey(c *gin.Context) {\n\ttype claudeKeyPatch struct {\n\t\tAPIKey         *string               `json:\"api-key\"`\n\t\tPrefix         *string               `json:\"prefix\"`\n\t\tBaseURL        *string               `json:\"base-url\"`\n\t\tProxyURL       *string               `json:\"proxy-url\"`\n\t\tModels         *[]config.ClaudeModel `json:\"models\"`\n\t\tHeaders        *map[string]string    `json:\"headers\"`\n\t\tExcludedModels *[]string             `json:\"excluded-models\"`\n\t}\n\tvar body struct {\n\t\tIndex *int            `json:\"index\"`\n\t\tMatch *string         `json:\"match\"`\n\t\tValue *claudeKeyPatch `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\ttargetIndex := -1\n\tif body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {\n\t\ttargetIndex = *body.Index\n\t}\n\tif targetIndex == -1 && body.Match != nil {\n\t\tmatch := strings.TrimSpace(*body.Match)\n\t\tfor i := range h.cfg.ClaudeKey {\n\t\t\tif h.cfg.ClaudeKey[i].APIKey == match {\n\t\t\t\ttargetIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif targetIndex == -1 {\n\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\treturn\n\t}\n\n\tentry := h.cfg.ClaudeKey[targetIndex]\n\tif body.Value.APIKey != nil {\n\t\tentry.APIKey = strings.TrimSpace(*body.Value.APIKey)\n\t}\n\tif body.Value.Prefix != nil {\n\t\tentry.Prefix = strings.TrimSpace(*body.Value.Prefix)\n\t}\n\tif body.Value.BaseURL != nil {\n\t\tentry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)\n\t}\n\tif body.Value.ProxyURL != nil {\n\t\tentry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)\n\t}\n\tif body.Value.Models != nil {\n\t\tentry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)\n\t}\n\tif body.Value.Headers != nil {\n\t\tentry.Headers = config.NormalizeHeaders(*body.Value.Headers)\n\t}\n\tif body.Value.ExcludedModels != nil {\n\t\tentry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)\n\t}\n\tnormalizeClaudeKey(&entry)\n\th.cfg.ClaudeKey[targetIndex] = entry\n\th.cfg.SanitizeClaudeKeys()\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteClaudeKey(c *gin.Context) {\n\tif val := c.Query(\"api-key\"); val != \"\" {\n\t\tout := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))\n\t\tfor _, v := range h.cfg.ClaudeKey {\n\t\t\tif v.APIKey != val {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\th.cfg.ClaudeKey = out\n\t\th.cfg.SanitizeClaudeKeys()\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\t_, err := fmt.Sscanf(idxStr, \"%d\", &idx)\n\t\tif err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {\n\t\t\th.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)\n\t\t\th.cfg.SanitizeClaudeKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing api-key or index\"})\n}\n\n// openai-compatibility: []OpenAICompatibility\nfunc (h *Handler) GetOpenAICompat(c *gin.Context) {\n\tc.JSON(200, gin.H{\"openai-compatibility\": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})\n}\nfunc (h *Handler) PutOpenAICompat(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []config.OpenAICompatibility\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []config.OpenAICompatibility `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\tfiltered := make([]config.OpenAICompatibility, 0, len(arr))\n\tfor i := range arr {\n\t\tnormalizeOpenAICompatibilityEntry(&arr[i])\n\t\tif strings.TrimSpace(arr[i].BaseURL) != \"\" {\n\t\t\tfiltered = append(filtered, arr[i])\n\t\t}\n\t}\n\th.cfg.OpenAICompatibility = filtered\n\th.cfg.SanitizeOpenAICompatibility()\n\th.persist(c)\n}\nfunc (h *Handler) PatchOpenAICompat(c *gin.Context) {\n\ttype openAICompatPatch struct {\n\t\tName          *string                             `json:\"name\"`\n\t\tPrefix        *string                             `json:\"prefix\"`\n\t\tBaseURL       *string                             `json:\"base-url\"`\n\t\tAPIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:\"api-key-entries\"`\n\t\tModels        *[]config.OpenAICompatibilityModel  `json:\"models\"`\n\t\tHeaders       *map[string]string                  `json:\"headers\"`\n\t}\n\tvar body struct {\n\t\tName  *string            `json:\"name\"`\n\t\tIndex *int               `json:\"index\"`\n\t\tValue *openAICompatPatch `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\ttargetIndex := -1\n\tif body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {\n\t\ttargetIndex = *body.Index\n\t}\n\tif targetIndex == -1 && body.Name != nil {\n\t\tmatch := strings.TrimSpace(*body.Name)\n\t\tfor i := range h.cfg.OpenAICompatibility {\n\t\t\tif h.cfg.OpenAICompatibility[i].Name == match {\n\t\t\t\ttargetIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif targetIndex == -1 {\n\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\treturn\n\t}\n\n\tentry := h.cfg.OpenAICompatibility[targetIndex]\n\tif body.Value.Name != nil {\n\t\tentry.Name = strings.TrimSpace(*body.Value.Name)\n\t}\n\tif body.Value.Prefix != nil {\n\t\tentry.Prefix = strings.TrimSpace(*body.Value.Prefix)\n\t}\n\tif body.Value.BaseURL != nil {\n\t\ttrimmed := strings.TrimSpace(*body.Value.BaseURL)\n\t\tif trimmed == \"\" {\n\t\t\th.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)\n\t\t\th.cfg.SanitizeOpenAICompatibility()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t\tentry.BaseURL = trimmed\n\t}\n\tif body.Value.APIKeyEntries != nil {\n\t\tentry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)\n\t}\n\tif body.Value.Models != nil {\n\t\tentry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)\n\t}\n\tif body.Value.Headers != nil {\n\t\tentry.Headers = config.NormalizeHeaders(*body.Value.Headers)\n\t}\n\tnormalizeOpenAICompatibilityEntry(&entry)\n\th.cfg.OpenAICompatibility[targetIndex] = entry\n\th.cfg.SanitizeOpenAICompatibility()\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteOpenAICompat(c *gin.Context) {\n\tif name := c.Query(\"name\"); name != \"\" {\n\t\tout := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))\n\t\tfor _, v := range h.cfg.OpenAICompatibility {\n\t\t\tif v.Name != name {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\th.cfg.OpenAICompatibility = out\n\t\th.cfg.SanitizeOpenAICompatibility()\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\t_, err := fmt.Sscanf(idxStr, \"%d\", &idx)\n\t\tif err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {\n\t\t\th.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)\n\t\t\th.cfg.SanitizeOpenAICompatibility()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing name or index\"})\n}\n\n// vertex-api-key: []VertexCompatKey\nfunc (h *Handler) GetVertexCompatKeys(c *gin.Context) {\n\tc.JSON(200, gin.H{\"vertex-api-key\": h.cfg.VertexCompatAPIKey})\n}\nfunc (h *Handler) PutVertexCompatKeys(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []config.VertexCompatKey\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []config.VertexCompatKey `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\tfor i := range arr {\n\t\tnormalizeVertexCompatKey(&arr[i])\n\t\tif arr[i].APIKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": fmt.Sprintf(\"vertex-api-key[%d].api-key is required\", i)})\n\t\t\treturn\n\t\t}\n\t}\n\th.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)\n\th.cfg.SanitizeVertexCompatKeys()\n\th.persist(c)\n}\nfunc (h *Handler) PatchVertexCompatKey(c *gin.Context) {\n\ttype vertexCompatPatch struct {\n\t\tAPIKey         *string                     `json:\"api-key\"`\n\t\tPrefix         *string                     `json:\"prefix\"`\n\t\tBaseURL        *string                     `json:\"base-url\"`\n\t\tProxyURL       *string                     `json:\"proxy-url\"`\n\t\tHeaders        *map[string]string          `json:\"headers\"`\n\t\tModels         *[]config.VertexCompatModel `json:\"models\"`\n\t\tExcludedModels *[]string                   `json:\"excluded-models\"`\n\t}\n\tvar body struct {\n\t\tIndex *int               `json:\"index\"`\n\t\tMatch *string            `json:\"match\"`\n\t\tValue *vertexCompatPatch `json:\"value\"`\n\t}\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\ttargetIndex := -1\n\tif body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {\n\t\ttargetIndex = *body.Index\n\t}\n\tif targetIndex == -1 && body.Match != nil {\n\t\tmatch := strings.TrimSpace(*body.Match)\n\t\tif match != \"\" {\n\t\t\tfor i := range h.cfg.VertexCompatAPIKey {\n\t\t\t\tif h.cfg.VertexCompatAPIKey[i].APIKey == match {\n\t\t\t\t\ttargetIndex = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif targetIndex == -1 {\n\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\treturn\n\t}\n\n\tentry := h.cfg.VertexCompatAPIKey[targetIndex]\n\tif body.Value.APIKey != nil {\n\t\ttrimmed := strings.TrimSpace(*body.Value.APIKey)\n\t\tif trimmed == \"\" {\n\t\t\th.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)\n\t\t\th.cfg.SanitizeVertexCompatKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t\tentry.APIKey = trimmed\n\t}\n\tif body.Value.Prefix != nil {\n\t\tentry.Prefix = strings.TrimSpace(*body.Value.Prefix)\n\t}\n\tif body.Value.BaseURL != nil {\n\t\ttrimmed := strings.TrimSpace(*body.Value.BaseURL)\n\t\tif trimmed == \"\" {\n\t\t\th.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)\n\t\t\th.cfg.SanitizeVertexCompatKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t\tentry.BaseURL = trimmed\n\t}\n\tif body.Value.ProxyURL != nil {\n\t\tentry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)\n\t}\n\tif body.Value.Headers != nil {\n\t\tentry.Headers = config.NormalizeHeaders(*body.Value.Headers)\n\t}\n\tif body.Value.Models != nil {\n\t\tentry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)\n\t}\n\tif body.Value.ExcludedModels != nil {\n\t\tentry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)\n\t}\n\tnormalizeVertexCompatKey(&entry)\n\th.cfg.VertexCompatAPIKey[targetIndex] = entry\n\th.cfg.SanitizeVertexCompatKeys()\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteVertexCompatKey(c *gin.Context) {\n\tif val := strings.TrimSpace(c.Query(\"api-key\")); val != \"\" {\n\t\tout := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))\n\t\tfor _, v := range h.cfg.VertexCompatAPIKey {\n\t\t\tif v.APIKey != val {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\th.cfg.VertexCompatAPIKey = out\n\t\th.cfg.SanitizeVertexCompatKeys()\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\t_, errScan := fmt.Sscanf(idxStr, \"%d\", &idx)\n\t\tif errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {\n\t\t\th.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)\n\t\t\th.cfg.SanitizeVertexCompatKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing api-key or index\"})\n}\n\n// oauth-excluded-models: map[string][]string\nfunc (h *Handler) GetOAuthExcludedModels(c *gin.Context) {\n\tc.JSON(200, gin.H{\"oauth-excluded-models\": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})\n}\n\nfunc (h *Handler) PutOAuthExcludedModels(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar entries map[string][]string\n\tif err = json.Unmarshal(data, &entries); err != nil {\n\t\tvar wrapper struct {\n\t\t\tItems map[string][]string `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &wrapper); err2 != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tentries = wrapper.Items\n\t}\n\th.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)\n\th.persist(c)\n}\n\nfunc (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {\n\tvar body struct {\n\t\tProvider *string  `json:\"provider\"`\n\t\tModels   []string `json:\"models\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tprovider := strings.ToLower(strings.TrimSpace(*body.Provider))\n\tif provider == \"\" {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid provider\"})\n\t\treturn\n\t}\n\tnormalized := config.NormalizeExcludedModels(body.Models)\n\tif len(normalized) == 0 {\n\t\tif h.cfg.OAuthExcludedModels == nil {\n\t\t\tc.JSON(404, gin.H{\"error\": \"provider not found\"})\n\t\t\treturn\n\t\t}\n\t\tif _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {\n\t\t\tc.JSON(404, gin.H{\"error\": \"provider not found\"})\n\t\t\treturn\n\t\t}\n\t\tdelete(h.cfg.OAuthExcludedModels, provider)\n\t\tif len(h.cfg.OAuthExcludedModels) == 0 {\n\t\t\th.cfg.OAuthExcludedModels = nil\n\t\t}\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif h.cfg.OAuthExcludedModels == nil {\n\t\th.cfg.OAuthExcludedModels = make(map[string][]string)\n\t}\n\th.cfg.OAuthExcludedModels[provider] = normalized\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {\n\tprovider := strings.ToLower(strings.TrimSpace(c.Query(\"provider\")))\n\tif provider == \"\" {\n\t\tc.JSON(400, gin.H{\"error\": \"missing provider\"})\n\t\treturn\n\t}\n\tif h.cfg.OAuthExcludedModels == nil {\n\t\tc.JSON(404, gin.H{\"error\": \"provider not found\"})\n\t\treturn\n\t}\n\tif _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {\n\t\tc.JSON(404, gin.H{\"error\": \"provider not found\"})\n\t\treturn\n\t}\n\tdelete(h.cfg.OAuthExcludedModels, provider)\n\tif len(h.cfg.OAuthExcludedModels) == 0 {\n\t\th.cfg.OAuthExcludedModels = nil\n\t}\n\th.persist(c)\n}\n\n// oauth-model-alias: map[string][]OAuthModelAlias\nfunc (h *Handler) GetOAuthModelAlias(c *gin.Context) {\n\tc.JSON(200, gin.H{\"oauth-model-alias\": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})\n}\n\nfunc (h *Handler) PutOAuthModelAlias(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar entries map[string][]config.OAuthModelAlias\n\tif err = json.Unmarshal(data, &entries); err != nil {\n\t\tvar wrapper struct {\n\t\t\tItems map[string][]config.OAuthModelAlias `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &wrapper); err2 != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tentries = wrapper.Items\n\t}\n\th.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)\n\th.persist(c)\n}\n\nfunc (h *Handler) PatchOAuthModelAlias(c *gin.Context) {\n\tvar body struct {\n\t\tProvider *string                  `json:\"provider\"`\n\t\tChannel  *string                  `json:\"channel\"`\n\t\tAliases  []config.OAuthModelAlias `json:\"aliases\"`\n\t}\n\tif errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tchannelRaw := \"\"\n\tif body.Channel != nil {\n\t\tchannelRaw = *body.Channel\n\t} else if body.Provider != nil {\n\t\tchannelRaw = *body.Provider\n\t}\n\tchannel := strings.ToLower(strings.TrimSpace(channelRaw))\n\tif channel == \"\" {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid channel\"})\n\t\treturn\n\t}\n\n\tnormalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})\n\tnormalized := normalizedMap[channel]\n\tif len(normalized) == 0 {\n\t\tif h.cfg.OAuthModelAlias == nil {\n\t\t\tc.JSON(404, gin.H{\"error\": \"channel not found\"})\n\t\t\treturn\n\t\t}\n\t\tif _, ok := h.cfg.OAuthModelAlias[channel]; !ok {\n\t\t\tc.JSON(404, gin.H{\"error\": \"channel not found\"})\n\t\t\treturn\n\t\t}\n\t\tdelete(h.cfg.OAuthModelAlias, channel)\n\t\tif len(h.cfg.OAuthModelAlias) == 0 {\n\t\t\th.cfg.OAuthModelAlias = nil\n\t\t}\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif h.cfg.OAuthModelAlias == nil {\n\t\th.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)\n\t}\n\th.cfg.OAuthModelAlias[channel] = normalized\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {\n\tchannel := strings.ToLower(strings.TrimSpace(c.Query(\"channel\")))\n\tif channel == \"\" {\n\t\tchannel = strings.ToLower(strings.TrimSpace(c.Query(\"provider\")))\n\t}\n\tif channel == \"\" {\n\t\tc.JSON(400, gin.H{\"error\": \"missing channel\"})\n\t\treturn\n\t}\n\tif h.cfg.OAuthModelAlias == nil {\n\t\tc.JSON(404, gin.H{\"error\": \"channel not found\"})\n\t\treturn\n\t}\n\tif _, ok := h.cfg.OAuthModelAlias[channel]; !ok {\n\t\tc.JSON(404, gin.H{\"error\": \"channel not found\"})\n\t\treturn\n\t}\n\tdelete(h.cfg.OAuthModelAlias, channel)\n\tif len(h.cfg.OAuthModelAlias) == 0 {\n\t\th.cfg.OAuthModelAlias = nil\n\t}\n\th.persist(c)\n}\n\n// codex-api-key: []CodexKey\nfunc (h *Handler) GetCodexKeys(c *gin.Context) {\n\tc.JSON(200, gin.H{\"codex-api-key\": h.cfg.CodexKey})\n}\nfunc (h *Handler) PutCodexKeys(c *gin.Context) {\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"failed to read body\"})\n\t\treturn\n\t}\n\tvar arr []config.CodexKey\n\tif err = json.Unmarshal(data, &arr); err != nil {\n\t\tvar obj struct {\n\t\t\tItems []config.CodexKey `json:\"items\"`\n\t\t}\n\t\tif err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {\n\t\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\t\treturn\n\t\t}\n\t\tarr = obj.Items\n\t}\n\t// Filter out codex entries with empty base-url (treat as removed)\n\tfiltered := make([]config.CodexKey, 0, len(arr))\n\tfor i := range arr {\n\t\tentry := arr[i]\n\t\tnormalizeCodexKey(&entry)\n\t\tif entry.BaseURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfiltered = append(filtered, entry)\n\t}\n\th.cfg.CodexKey = filtered\n\th.cfg.SanitizeCodexKeys()\n\th.persist(c)\n}\nfunc (h *Handler) PatchCodexKey(c *gin.Context) {\n\ttype codexKeyPatch struct {\n\t\tAPIKey         *string              `json:\"api-key\"`\n\t\tPrefix         *string              `json:\"prefix\"`\n\t\tBaseURL        *string              `json:\"base-url\"`\n\t\tProxyURL       *string              `json:\"proxy-url\"`\n\t\tModels         *[]config.CodexModel `json:\"models\"`\n\t\tHeaders        *map[string]string   `json:\"headers\"`\n\t\tExcludedModels *[]string            `json:\"excluded-models\"`\n\t}\n\tvar body struct {\n\t\tIndex *int           `json:\"index\"`\n\t\tMatch *string        `json:\"match\"`\n\t\tValue *codexKeyPatch `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\ttargetIndex := -1\n\tif body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {\n\t\ttargetIndex = *body.Index\n\t}\n\tif targetIndex == -1 && body.Match != nil {\n\t\tmatch := strings.TrimSpace(*body.Match)\n\t\tfor i := range h.cfg.CodexKey {\n\t\t\tif h.cfg.CodexKey[i].APIKey == match {\n\t\t\t\ttargetIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif targetIndex == -1 {\n\t\tc.JSON(404, gin.H{\"error\": \"item not found\"})\n\t\treturn\n\t}\n\n\tentry := h.cfg.CodexKey[targetIndex]\n\tif body.Value.APIKey != nil {\n\t\tentry.APIKey = strings.TrimSpace(*body.Value.APIKey)\n\t}\n\tif body.Value.Prefix != nil {\n\t\tentry.Prefix = strings.TrimSpace(*body.Value.Prefix)\n\t}\n\tif body.Value.BaseURL != nil {\n\t\ttrimmed := strings.TrimSpace(*body.Value.BaseURL)\n\t\tif trimmed == \"\" {\n\t\t\th.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)\n\t\t\th.cfg.SanitizeCodexKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t\tentry.BaseURL = trimmed\n\t}\n\tif body.Value.ProxyURL != nil {\n\t\tentry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)\n\t}\n\tif body.Value.Models != nil {\n\t\tentry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)\n\t}\n\tif body.Value.Headers != nil {\n\t\tentry.Headers = config.NormalizeHeaders(*body.Value.Headers)\n\t}\n\tif body.Value.ExcludedModels != nil {\n\t\tentry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)\n\t}\n\tnormalizeCodexKey(&entry)\n\th.cfg.CodexKey[targetIndex] = entry\n\th.cfg.SanitizeCodexKeys()\n\th.persist(c)\n}\n\nfunc (h *Handler) DeleteCodexKey(c *gin.Context) {\n\tif val := c.Query(\"api-key\"); val != \"\" {\n\t\tout := make([]config.CodexKey, 0, len(h.cfg.CodexKey))\n\t\tfor _, v := range h.cfg.CodexKey {\n\t\t\tif v.APIKey != val {\n\t\t\t\tout = append(out, v)\n\t\t\t}\n\t\t}\n\t\th.cfg.CodexKey = out\n\t\th.cfg.SanitizeCodexKeys()\n\t\th.persist(c)\n\t\treturn\n\t}\n\tif idxStr := c.Query(\"index\"); idxStr != \"\" {\n\t\tvar idx int\n\t\t_, err := fmt.Sscanf(idxStr, \"%d\", &idx)\n\t\tif err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {\n\t\t\th.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)\n\t\t\th.cfg.SanitizeCodexKeys()\n\t\t\th.persist(c)\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(400, gin.H{\"error\": \"missing api-key or index\"})\n}\n\nfunc normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {\n\tif entry == nil {\n\t\treturn\n\t}\n\t// Trim base-url; empty base-url indicates provider should be removed by sanitization\n\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\tentry.Headers = config.NormalizeHeaders(entry.Headers)\n\texisting := make(map[string]struct{}, len(entry.APIKeyEntries))\n\tfor i := range entry.APIKeyEntries {\n\t\ttrimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)\n\t\tentry.APIKeyEntries[i].APIKey = trimmed\n\t\tif trimmed != \"\" {\n\t\t\texisting[trimmed] = struct{}{}\n\t\t}\n\t}\n}\n\nfunc normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]config.OpenAICompatibility, len(entries))\n\tfor i := range entries {\n\t\tcopyEntry := entries[i]\n\t\tif len(copyEntry.APIKeyEntries) > 0 {\n\t\t\tcopyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)\n\t\t}\n\t\tnormalizeOpenAICompatibilityEntry(&copyEntry)\n\t\tout[i] = copyEntry\n\t}\n\treturn out\n}\n\nfunc normalizeClaudeKey(entry *config.ClaudeKey) {\n\tif entry == nil {\n\t\treturn\n\t}\n\tentry.APIKey = strings.TrimSpace(entry.APIKey)\n\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\tentry.ProxyURL = strings.TrimSpace(entry.ProxyURL)\n\tentry.Headers = config.NormalizeHeaders(entry.Headers)\n\tentry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)\n\tif len(entry.Models) == 0 {\n\t\treturn\n\t}\n\tnormalized := make([]config.ClaudeModel, 0, len(entry.Models))\n\tfor i := range entry.Models {\n\t\tmodel := entry.Models[i]\n\t\tmodel.Name = strings.TrimSpace(model.Name)\n\t\tmodel.Alias = strings.TrimSpace(model.Alias)\n\t\tif model.Name == \"\" && model.Alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized = append(normalized, model)\n\t}\n\tentry.Models = normalized\n}\n\nfunc normalizeCodexKey(entry *config.CodexKey) {\n\tif entry == nil {\n\t\treturn\n\t}\n\tentry.APIKey = strings.TrimSpace(entry.APIKey)\n\tentry.Prefix = strings.TrimSpace(entry.Prefix)\n\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\tentry.ProxyURL = strings.TrimSpace(entry.ProxyURL)\n\tentry.Headers = config.NormalizeHeaders(entry.Headers)\n\tentry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)\n\tif len(entry.Models) == 0 {\n\t\treturn\n\t}\n\tnormalized := make([]config.CodexModel, 0, len(entry.Models))\n\tfor i := range entry.Models {\n\t\tmodel := entry.Models[i]\n\t\tmodel.Name = strings.TrimSpace(model.Name)\n\t\tmodel.Alias = strings.TrimSpace(model.Alias)\n\t\tif model.Name == \"\" && model.Alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized = append(normalized, model)\n\t}\n\tentry.Models = normalized\n}\n\nfunc normalizeVertexCompatKey(entry *config.VertexCompatKey) {\n\tif entry == nil {\n\t\treturn\n\t}\n\tentry.APIKey = strings.TrimSpace(entry.APIKey)\n\tentry.Prefix = strings.TrimSpace(entry.Prefix)\n\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\tentry.ProxyURL = strings.TrimSpace(entry.ProxyURL)\n\tentry.Headers = config.NormalizeHeaders(entry.Headers)\n\tentry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)\n\tif len(entry.Models) == 0 {\n\t\treturn\n\t}\n\tnormalized := make([]config.VertexCompatModel, 0, len(entry.Models))\n\tfor i := range entry.Models {\n\t\tmodel := entry.Models[i]\n\t\tmodel.Name = strings.TrimSpace(model.Name)\n\t\tmodel.Alias = strings.TrimSpace(model.Alias)\n\t\tif model.Name == \"\" || model.Alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized = append(normalized, model)\n\t}\n\tentry.Models = normalized\n}\n\nfunc sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tcopied := make(map[string][]config.OAuthModelAlias, len(entries))\n\tfor channel, aliases := range entries {\n\t\tif len(aliases) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tcopied[channel] = append([]config.OAuthModelAlias(nil), aliases...)\n\t}\n\tif len(copied) == 0 {\n\t\treturn nil\n\t}\n\tcfg := config.Config{OAuthModelAlias: copied}\n\tcfg.SanitizeOAuthModelAlias()\n\tif len(cfg.OAuthModelAlias) == 0 {\n\t\treturn nil\n\t}\n\treturn cfg.OAuthModelAlias\n}\n\n// GetAmpCode returns the complete ampcode configuration.\nfunc (h *Handler) GetAmpCode(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"ampcode\": config.AmpCode{}})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"ampcode\": h.cfg.AmpCode})\n}\n\n// GetAmpUpstreamURL returns the ampcode upstream URL.\nfunc (h *Handler) GetAmpUpstreamURL(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"upstream-url\": \"\"})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"upstream-url\": h.cfg.AmpCode.UpstreamURL})\n}\n\n// PutAmpUpstreamURL updates the ampcode upstream URL.\nfunc (h *Handler) PutAmpUpstreamURL(c *gin.Context) {\n\th.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })\n}\n\n// DeleteAmpUpstreamURL clears the ampcode upstream URL.\nfunc (h *Handler) DeleteAmpUpstreamURL(c *gin.Context) {\n\th.cfg.AmpCode.UpstreamURL = \"\"\n\th.persist(c)\n}\n\n// GetAmpUpstreamAPIKey returns the ampcode upstream API key.\nfunc (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"upstream-api-key\": \"\"})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"upstream-api-key\": h.cfg.AmpCode.UpstreamAPIKey})\n}\n\n// PutAmpUpstreamAPIKey updates the ampcode upstream API key.\nfunc (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {\n\th.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })\n}\n\n// DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.\nfunc (h *Handler) DeleteAmpUpstreamAPIKey(c *gin.Context) {\n\th.cfg.AmpCode.UpstreamAPIKey = \"\"\n\th.persist(c)\n}\n\n// GetAmpRestrictManagementToLocalhost returns the localhost restriction setting.\nfunc (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"restrict-management-to-localhost\": true})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"restrict-management-to-localhost\": h.cfg.AmpCode.RestrictManagementToLocalhost})\n}\n\n// PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.\nfunc (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })\n}\n\n// GetAmpModelMappings returns the ampcode model mappings.\nfunc (h *Handler) GetAmpModelMappings(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"model-mappings\": []config.AmpModelMapping{}})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"model-mappings\": h.cfg.AmpCode.ModelMappings})\n}\n\n// PutAmpModelMappings replaces all ampcode model mappings.\nfunc (h *Handler) PutAmpModelMappings(c *gin.Context) {\n\tvar body struct {\n\t\tValue []config.AmpModelMapping `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\th.cfg.AmpCode.ModelMappings = body.Value\n\th.persist(c)\n}\n\n// PatchAmpModelMappings adds or updates model mappings.\nfunc (h *Handler) PatchAmpModelMappings(c *gin.Context) {\n\tvar body struct {\n\t\tValue []config.AmpModelMapping `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\n\texisting := make(map[string]int)\n\tfor i, m := range h.cfg.AmpCode.ModelMappings {\n\t\texisting[strings.TrimSpace(m.From)] = i\n\t}\n\n\tfor _, newMapping := range body.Value {\n\t\tfrom := strings.TrimSpace(newMapping.From)\n\t\tif idx, ok := existing[from]; ok {\n\t\t\th.cfg.AmpCode.ModelMappings[idx] = newMapping\n\t\t} else {\n\t\t\th.cfg.AmpCode.ModelMappings = append(h.cfg.AmpCode.ModelMappings, newMapping)\n\t\t\texisting[from] = len(h.cfg.AmpCode.ModelMappings) - 1\n\t\t}\n\t}\n\th.persist(c)\n}\n\n// DeleteAmpModelMappings removes specified model mappings by \"from\" field.\nfunc (h *Handler) DeleteAmpModelMappings(c *gin.Context) {\n\tvar body struct {\n\t\tValue []string `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {\n\t\th.cfg.AmpCode.ModelMappings = nil\n\t\th.persist(c)\n\t\treturn\n\t}\n\n\ttoRemove := make(map[string]bool)\n\tfor _, from := range body.Value {\n\t\ttoRemove[strings.TrimSpace(from)] = true\n\t}\n\n\tnewMappings := make([]config.AmpModelMapping, 0, len(h.cfg.AmpCode.ModelMappings))\n\tfor _, m := range h.cfg.AmpCode.ModelMappings {\n\t\tif !toRemove[strings.TrimSpace(m.From)] {\n\t\t\tnewMappings = append(newMappings, m)\n\t\t}\n\t}\n\th.cfg.AmpCode.ModelMappings = newMappings\n\th.persist(c)\n}\n\n// GetAmpForceModelMappings returns whether model mappings are forced.\nfunc (h *Handler) GetAmpForceModelMappings(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"force-model-mappings\": false})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"force-model-mappings\": h.cfg.AmpCode.ForceModelMappings})\n}\n\n// PutAmpForceModelMappings updates the force model mappings setting.\nfunc (h *Handler) PutAmpForceModelMappings(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })\n}\n\n// GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.\nfunc (h *Handler) GetAmpUpstreamAPIKeys(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(200, gin.H{\"upstream-api-keys\": []config.AmpUpstreamAPIKeyEntry{}})\n\t\treturn\n\t}\n\tc.JSON(200, gin.H{\"upstream-api-keys\": h.cfg.AmpCode.UpstreamAPIKeys})\n}\n\n// PutAmpUpstreamAPIKeys replaces all ampcode upstream API keys mappings.\nfunc (h *Handler) PutAmpUpstreamAPIKeys(c *gin.Context) {\n\tvar body struct {\n\t\tValue []config.AmpUpstreamAPIKeyEntry `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\t// Normalize entries: trim whitespace, filter empty\n\tnormalized := normalizeAmpUpstreamAPIKeyEntries(body.Value)\n\th.cfg.AmpCode.UpstreamAPIKeys = normalized\n\th.persist(c)\n}\n\n// PatchAmpUpstreamAPIKeys adds or updates upstream API keys entries.\n// Matching is done by upstream-api-key value.\nfunc (h *Handler) PatchAmpUpstreamAPIKeys(c *gin.Context) {\n\tvar body struct {\n\t\tValue []config.AmpUpstreamAPIKeyEntry `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\n\texisting := make(map[string]int)\n\tfor i, entry := range h.cfg.AmpCode.UpstreamAPIKeys {\n\t\texisting[strings.TrimSpace(entry.UpstreamAPIKey)] = i\n\t}\n\n\tfor _, newEntry := range body.Value {\n\t\tupstreamKey := strings.TrimSpace(newEntry.UpstreamAPIKey)\n\t\tif upstreamKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalizedEntry := config.AmpUpstreamAPIKeyEntry{\n\t\t\tUpstreamAPIKey: upstreamKey,\n\t\t\tAPIKeys:        normalizeAPIKeysList(newEntry.APIKeys),\n\t\t}\n\t\tif idx, ok := existing[upstreamKey]; ok {\n\t\t\th.cfg.AmpCode.UpstreamAPIKeys[idx] = normalizedEntry\n\t\t} else {\n\t\t\th.cfg.AmpCode.UpstreamAPIKeys = append(h.cfg.AmpCode.UpstreamAPIKeys, normalizedEntry)\n\t\t\texisting[upstreamKey] = len(h.cfg.AmpCode.UpstreamAPIKeys) - 1\n\t\t}\n\t}\n\th.persist(c)\n}\n\n// DeleteAmpUpstreamAPIKeys removes specified upstream API keys entries.\n// Body must be JSON: {\"value\": [\"<upstream-api-key>\", ...]}.\n// If \"value\" is an empty array, clears all entries.\n// If JSON is invalid or \"value\" is missing/null, returns 400 and does not persist any change.\nfunc (h *Handler) DeleteAmpUpstreamAPIKeys(c *gin.Context) {\n\tvar body struct {\n\t\tValue []string `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\n\tif body.Value == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"missing value\"})\n\t\treturn\n\t}\n\n\t// Empty array means clear all\n\tif len(body.Value) == 0 {\n\t\th.cfg.AmpCode.UpstreamAPIKeys = nil\n\t\th.persist(c)\n\t\treturn\n\t}\n\n\ttoRemove := make(map[string]bool)\n\tfor _, key := range body.Value {\n\t\ttrimmed := strings.TrimSpace(key)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ttoRemove[trimmed] = true\n\t}\n\tif len(toRemove) == 0 {\n\t\tc.JSON(400, gin.H{\"error\": \"empty value\"})\n\t\treturn\n\t}\n\n\tnewEntries := make([]config.AmpUpstreamAPIKeyEntry, 0, len(h.cfg.AmpCode.UpstreamAPIKeys))\n\tfor _, entry := range h.cfg.AmpCode.UpstreamAPIKeys {\n\t\tif !toRemove[strings.TrimSpace(entry.UpstreamAPIKey)] {\n\t\t\tnewEntries = append(newEntries, entry)\n\t\t}\n\t}\n\th.cfg.AmpCode.UpstreamAPIKeys = newEntries\n\th.persist(c)\n}\n\n// normalizeAmpUpstreamAPIKeyEntries normalizes a list of upstream API key entries.\nfunc normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tupstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)\n\t\tif upstreamKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tapiKeys := normalizeAPIKeysList(entry.APIKeys)\n\t\tout = append(out, config.AmpUpstreamAPIKeyEntry{\n\t\t\tUpstreamAPIKey: upstreamKey,\n\t\t\tAPIKeys:        apiKeys,\n\t\t})\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// normalizeAPIKeysList trims and filters empty strings from a list of API keys.\nfunc normalizeAPIKeysList(keys []string) []string {\n\tif len(keys) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(keys))\n\tfor _, k := range keys {\n\t\ttrimmed := strings.TrimSpace(k)\n\t\tif trimmed != \"\" {\n\t\t\tout = append(out, trimmed)\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "internal/api/handlers/management/handler.go",
    "content": "// Package management provides the management API handlers and middleware\n// for configuring the server and managing auth files.\npackage management\n\nimport (\n\t\"crypto/subtle\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/usage\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype attemptInfo struct {\n\tcount        int\n\tblockedUntil time.Time\n\tlastActivity time.Time // track last activity for cleanup\n}\n\n// attemptCleanupInterval controls how often stale IP entries are purged\nconst attemptCleanupInterval = 1 * time.Hour\n\n// attemptMaxIdleTime controls how long an IP can be idle before cleanup\nconst attemptMaxIdleTime = 2 * time.Hour\n\n// Handler aggregates config reference, persistence path and helpers.\ntype Handler struct {\n\tcfg                 *config.Config\n\tconfigFilePath      string\n\tmu                  sync.Mutex\n\tattemptsMu          sync.Mutex\n\tfailedAttempts      map[string]*attemptInfo // keyed by client IP\n\tauthManager         *coreauth.Manager\n\tusageStats          *usage.RequestStatistics\n\ttokenStore          coreauth.Store\n\tlocalPassword       string\n\tallowRemoteOverride bool\n\tenvSecret           string\n\tlogDir              string\n\tpostAuthHook        coreauth.PostAuthHook\n}\n\n// NewHandler creates a new management handler instance.\nfunc NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {\n\tenvSecret, _ := os.LookupEnv(\"MANAGEMENT_PASSWORD\")\n\tenvSecret = strings.TrimSpace(envSecret)\n\n\th := &Handler{\n\t\tcfg:                 cfg,\n\t\tconfigFilePath:      configFilePath,\n\t\tfailedAttempts:      make(map[string]*attemptInfo),\n\t\tauthManager:         manager,\n\t\tusageStats:          usage.GetRequestStatistics(),\n\t\ttokenStore:          sdkAuth.GetTokenStore(),\n\t\tallowRemoteOverride: envSecret != \"\",\n\t\tenvSecret:           envSecret,\n\t}\n\th.startAttemptCleanup()\n\treturn h\n}\n\n// startAttemptCleanup launches a background goroutine that periodically\n// removes stale IP entries from failedAttempts to prevent memory leaks.\nfunc (h *Handler) startAttemptCleanup() {\n\tgo func() {\n\t\tticker := time.NewTicker(attemptCleanupInterval)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\th.purgeStaleAttempts()\n\t\t}\n\t}()\n}\n\n// purgeStaleAttempts removes IP entries that have been idle beyond attemptMaxIdleTime\n// and whose ban (if any) has expired.\nfunc (h *Handler) purgeStaleAttempts() {\n\tnow := time.Now()\n\th.attemptsMu.Lock()\n\tdefer h.attemptsMu.Unlock()\n\tfor ip, ai := range h.failedAttempts {\n\t\t// Skip if still banned\n\t\tif !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) {\n\t\t\tcontinue\n\t\t}\n\t\t// Remove if idle too long\n\t\tif now.Sub(ai.lastActivity) > attemptMaxIdleTime {\n\t\t\tdelete(h.failedAttempts, ip)\n\t\t}\n\t}\n}\n\n// NewHandler creates a new management handler instance.\nfunc NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {\n\treturn NewHandler(cfg, \"\", manager)\n}\n\n// SetConfig updates the in-memory config reference when the server hot-reloads.\nfunc (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }\n\n// SetAuthManager updates the auth manager reference used by management endpoints.\nfunc (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }\n\n// SetUsageStatistics allows replacing the usage statistics reference.\nfunc (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }\n\n// SetLocalPassword configures the runtime-local password accepted for localhost requests.\nfunc (h *Handler) SetLocalPassword(password string) { h.localPassword = password }\n\n// SetLogDirectory updates the directory where main.log should be looked up.\nfunc (h *Handler) SetLogDirectory(dir string) {\n\tif dir == \"\" {\n\t\treturn\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\th.logDir = dir\n}\n\n// SetPostAuthHook registers a hook to be called after auth record creation but before persistence.\nfunc (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {\n\th.postAuthHook = hook\n}\n\n// Middleware enforces access control for management endpoints.\n// All requests (local and remote) require a valid management key.\n// Additionally, remote access requires allow-remote-management=true.\nfunc (h *Handler) Middleware() gin.HandlerFunc {\n\tconst maxFailures = 5\n\tconst banDuration = 30 * time.Minute\n\n\treturn func(c *gin.Context) {\n\t\tc.Header(\"X-CPA-VERSION\", buildinfo.Version)\n\t\tc.Header(\"X-CPA-COMMIT\", buildinfo.Commit)\n\t\tc.Header(\"X-CPA-BUILD-DATE\", buildinfo.BuildDate)\n\n\t\tclientIP := c.ClientIP()\n\t\tlocalClient := clientIP == \"127.0.0.1\" || clientIP == \"::1\"\n\t\tcfg := h.cfg\n\t\tvar (\n\t\t\tallowRemote bool\n\t\t\tsecretHash  string\n\t\t)\n\t\tif cfg != nil {\n\t\t\tallowRemote = cfg.RemoteManagement.AllowRemote\n\t\t\tsecretHash = cfg.RemoteManagement.SecretKey\n\t\t}\n\t\tif h.allowRemoteOverride {\n\t\t\tallowRemote = true\n\t\t}\n\t\tenvSecret := h.envSecret\n\n\t\tfail := func() {}\n\t\tif !localClient {\n\t\t\th.attemptsMu.Lock()\n\t\t\tai := h.failedAttempts[clientIP]\n\t\t\tif ai != nil {\n\t\t\t\tif !ai.blockedUntil.IsZero() {\n\t\t\t\t\tif time.Now().Before(ai.blockedUntil) {\n\t\t\t\t\t\tremaining := time.Until(ai.blockedUntil).Round(time.Second)\n\t\t\t\t\t\th.attemptsMu.Unlock()\n\t\t\t\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"error\": fmt.Sprintf(\"IP banned due to too many failed attempts. Try again in %s\", remaining)})\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t// Ban expired, reset state\n\t\t\t\t\tai.blockedUntil = time.Time{}\n\t\t\t\t\tai.count = 0\n\t\t\t\t}\n\t\t\t}\n\t\t\th.attemptsMu.Unlock()\n\n\t\t\tif !allowRemote {\n\t\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"error\": \"remote management disabled\"})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfail = func() {\n\t\t\t\th.attemptsMu.Lock()\n\t\t\t\taip := h.failedAttempts[clientIP]\n\t\t\t\tif aip == nil {\n\t\t\t\t\taip = &attemptInfo{}\n\t\t\t\t\th.failedAttempts[clientIP] = aip\n\t\t\t\t}\n\t\t\t\taip.count++\n\t\t\t\taip.lastActivity = time.Now()\n\t\t\t\tif aip.count >= maxFailures {\n\t\t\t\t\taip.blockedUntil = time.Now().Add(banDuration)\n\t\t\t\t\taip.count = 0\n\t\t\t\t}\n\t\t\t\th.attemptsMu.Unlock()\n\t\t\t}\n\t\t}\n\t\tif secretHash == \"\" && envSecret == \"\" {\n\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"error\": \"remote management key not set\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Accept either Authorization: Bearer <key> or X-Management-Key\n\t\tvar provided string\n\t\tif ah := c.GetHeader(\"Authorization\"); ah != \"\" {\n\t\t\tparts := strings.SplitN(ah, \" \", 2)\n\t\t\tif len(parts) == 2 && strings.ToLower(parts[0]) == \"bearer\" {\n\t\t\t\tprovided = parts[1]\n\t\t\t} else {\n\t\t\t\tprovided = ah\n\t\t\t}\n\t\t}\n\t\tif provided == \"\" {\n\t\t\tprovided = c.GetHeader(\"X-Management-Key\")\n\t\t}\n\n\t\tif provided == \"\" {\n\t\t\tif !localClient {\n\t\t\t\tfail()\n\t\t\t}\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"missing management key\"})\n\t\t\treturn\n\t\t}\n\n\t\tif localClient {\n\t\t\tif lp := h.localPassword; lp != \"\" {\n\t\t\t\tif subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {\n\t\t\t\t\tc.Next()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif envSecret != \"\" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {\n\t\t\tif !localClient {\n\t\t\t\th.attemptsMu.Lock()\n\t\t\t\tif ai := h.failedAttempts[clientIP]; ai != nil {\n\t\t\t\t\tai.count = 0\n\t\t\t\t\tai.blockedUntil = time.Time{}\n\t\t\t\t}\n\t\t\t\th.attemptsMu.Unlock()\n\t\t\t}\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tif secretHash == \"\" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {\n\t\t\tif !localClient {\n\t\t\t\tfail()\n\t\t\t}\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"invalid management key\"})\n\t\t\treturn\n\t\t}\n\n\t\tif !localClient {\n\t\t\th.attemptsMu.Lock()\n\t\t\tif ai := h.failedAttempts[clientIP]; ai != nil {\n\t\t\t\tai.count = 0\n\t\t\t\tai.blockedUntil = time.Time{}\n\t\t\t}\n\t\t\th.attemptsMu.Unlock()\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// persist saves the current in-memory config to disk.\nfunc (h *Handler) persist(c *gin.Context) bool {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\t// Preserve comments when writing\n\tif err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to save config: %v\", err)})\n\t\treturn false\n\t}\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n\treturn true\n}\n\n// Helper methods for simple types\nfunc (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {\n\tvar body struct {\n\t\tValue *bool `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tset(*body.Value)\n\th.persist(c)\n}\n\nfunc (h *Handler) updateIntField(c *gin.Context, set func(int)) {\n\tvar body struct {\n\t\tValue *int `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tset(*body.Value)\n\th.persist(c)\n}\n\nfunc (h *Handler) updateStringField(c *gin.Context, set func(string)) {\n\tvar body struct {\n\t\tValue *string `json:\"value\"`\n\t}\n\tif err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid body\"})\n\t\treturn\n\t}\n\tset(*body.Value)\n\th.persist(c)\n}\n"
  },
  {
    "path": "internal/api/handlers/management/logs.go",
    "content": "package management\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"math\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n)\n\nconst (\n\tdefaultLogFileName      = \"main.log\"\n\tlogScannerInitialBuffer = 64 * 1024\n\tlogScannerMaxBuffer     = 8 * 1024 * 1024\n)\n\n// GetLogs returns log lines with optional incremental loading.\nfunc (h *Handler) GetLogs(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"handler unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"configuration unavailable\"})\n\t\treturn\n\t}\n\tif !h.cfg.LoggingToFile {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"logging to file disabled\"})\n\t\treturn\n\t}\n\n\tlogDir := h.logDirectory()\n\tif strings.TrimSpace(logDir) == \"\" {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"log directory not configured\"})\n\t\treturn\n\t}\n\n\tfiles, err := h.collectLogFiles(logDir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tcutoff := parseCutoff(c.Query(\"after\"))\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"lines\":            []string{},\n\t\t\t\t\"line-count\":       0,\n\t\t\t\t\"latest-timestamp\": cutoff,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to list log files: %v\", err)})\n\t\treturn\n\t}\n\n\tlimit, errLimit := parseLimit(c.Query(\"limit\"))\n\tif errLimit != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": fmt.Sprintf(\"invalid limit: %v\", errLimit)})\n\t\treturn\n\t}\n\n\tcutoff := parseCutoff(c.Query(\"after\"))\n\tacc := newLogAccumulator(cutoff, limit)\n\tfor i := range files {\n\t\tif errProcess := acc.consumeFile(files[i]); errProcess != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to read log file %s: %v\", files[i], errProcess)})\n\t\t\treturn\n\t\t}\n\t}\n\n\tlines, total, latest := acc.result()\n\tif latest == 0 || latest < cutoff {\n\t\tlatest = cutoff\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"lines\":            lines,\n\t\t\"line-count\":       total,\n\t\t\"latest-timestamp\": latest,\n\t})\n}\n\n// DeleteLogs removes all rotated log files and truncates the active log.\nfunc (h *Handler) DeleteLogs(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"handler unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"configuration unavailable\"})\n\t\treturn\n\t}\n\tif !h.cfg.LoggingToFile {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"logging to file disabled\"})\n\t\treturn\n\t}\n\n\tdir := h.logDirectory()\n\tif strings.TrimSpace(dir) == \"\" {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"log directory not configured\"})\n\t\treturn\n\t}\n\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log directory not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to list log directory: %v\", err)})\n\t\treturn\n\t}\n\n\tremoved := 0\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tfullPath := filepath.Join(dir, name)\n\t\tif name == defaultLogFileName {\n\t\t\tif errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to truncate log file: %v\", errTrunc)})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif isRotatedLogFile(name) {\n\t\t\tif errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to remove %s: %v\", name, errRemove)})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tremoved++\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"Logs cleared successfully\",\n\t\t\"removed\": removed,\n\t})\n}\n\n// GetRequestErrorLogs lists error request log files when RequestLog is disabled.\n// It returns an empty list when RequestLog is enabled.\nfunc (h *Handler) GetRequestErrorLogs(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"handler unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"configuration unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg.RequestLog {\n\t\tc.JSON(http.StatusOK, gin.H{\"files\": []any{}})\n\t\treturn\n\t}\n\n\tdir := h.logDirectory()\n\tif strings.TrimSpace(dir) == \"\" {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"log directory not configured\"})\n\t\treturn\n\t}\n\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tc.JSON(http.StatusOK, gin.H{\"files\": []any{}})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to list request error logs: %v\", err)})\n\t\treturn\n\t}\n\n\ttype errorLog struct {\n\t\tName     string `json:\"name\"`\n\t\tSize     int64  `json:\"size\"`\n\t\tModified int64  `json:\"modified\"`\n\t}\n\n\tfiles := make([]errorLog, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !strings.HasPrefix(name, \"error-\") || !strings.HasSuffix(name, \".log\") {\n\t\t\tcontinue\n\t\t}\n\t\tinfo, errInfo := entry.Info()\n\t\tif errInfo != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to read log info for %s: %v\", name, errInfo)})\n\t\t\treturn\n\t\t}\n\t\tfiles = append(files, errorLog{\n\t\t\tName:     name,\n\t\t\tSize:     info.Size(),\n\t\t\tModified: info.ModTime().Unix(),\n\t\t})\n\t}\n\n\tsort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })\n\n\tc.JSON(http.StatusOK, gin.H{\"files\": files})\n}\n\n// GetRequestLogByID finds and downloads a request log file by its request ID.\n// The ID is matched against the suffix of log file names (format: *-{requestID}.log).\nfunc (h *Handler) GetRequestLogByID(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"handler unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"configuration unavailable\"})\n\t\treturn\n\t}\n\n\tdir := h.logDirectory()\n\tif strings.TrimSpace(dir) == \"\" {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"log directory not configured\"})\n\t\treturn\n\t}\n\n\trequestID := strings.TrimSpace(c.Param(\"id\"))\n\tif requestID == \"\" {\n\t\trequestID = strings.TrimSpace(c.Query(\"id\"))\n\t}\n\tif requestID == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"missing request ID\"})\n\t\treturn\n\t}\n\tif strings.ContainsAny(requestID, \"/\\\\\") {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid request ID\"})\n\t\treturn\n\t}\n\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log directory not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to list log directory: %v\", err)})\n\t\treturn\n\t}\n\n\tsuffix := \"-\" + requestID + \".log\"\n\tvar matchedFile string\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif strings.HasSuffix(name, suffix) {\n\t\t\tmatchedFile = name\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif matchedFile == \"\" {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log file not found for the given request ID\"})\n\t\treturn\n\t}\n\n\tdirAbs, errAbs := filepath.Abs(dir)\n\tif errAbs != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to resolve log directory: %v\", errAbs)})\n\t\treturn\n\t}\n\tfullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile))\n\tprefix := dirAbs + string(os.PathSeparator)\n\tif !strings.HasPrefix(fullPath, prefix) {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid log file path\"})\n\t\treturn\n\t}\n\n\tinfo, errStat := os.Stat(fullPath)\n\tif errStat != nil {\n\t\tif os.IsNotExist(errStat) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log file not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to read log file: %v\", errStat)})\n\t\treturn\n\t}\n\tif info.IsDir() {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid log file\"})\n\t\treturn\n\t}\n\n\tc.FileAttachment(fullPath, matchedFile)\n}\n\n// DownloadRequestErrorLog downloads a specific error request log file by name.\nfunc (h *Handler) DownloadRequestErrorLog(c *gin.Context) {\n\tif h == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"handler unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"configuration unavailable\"})\n\t\treturn\n\t}\n\n\tdir := h.logDirectory()\n\tif strings.TrimSpace(dir) == \"\" {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"log directory not configured\"})\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(c.Param(\"name\"))\n\tif name == \"\" || strings.Contains(name, \"/\") || strings.Contains(name, \"\\\\\") {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid log file name\"})\n\t\treturn\n\t}\n\tif !strings.HasPrefix(name, \"error-\") || !strings.HasSuffix(name, \".log\") {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log file not found\"})\n\t\treturn\n\t}\n\n\tdirAbs, errAbs := filepath.Abs(dir)\n\tif errAbs != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to resolve log directory: %v\", errAbs)})\n\t\treturn\n\t}\n\tfullPath := filepath.Clean(filepath.Join(dirAbs, name))\n\tprefix := dirAbs + string(os.PathSeparator)\n\tif !strings.HasPrefix(fullPath, prefix) {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid log file path\"})\n\t\treturn\n\t}\n\n\tinfo, errStat := os.Stat(fullPath)\n\tif errStat != nil {\n\t\tif os.IsNotExist(errStat) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"log file not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"failed to read log file: %v\", errStat)})\n\t\treturn\n\t}\n\tif info.IsDir() {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid log file\"})\n\t\treturn\n\t}\n\n\tc.FileAttachment(fullPath, name)\n}\n\nfunc (h *Handler) logDirectory() string {\n\tif h == nil {\n\t\treturn \"\"\n\t}\n\tif h.logDir != \"\" {\n\t\treturn h.logDir\n\t}\n\treturn logging.ResolveLogDirectory(h.cfg)\n}\n\nfunc (h *Handler) collectLogFiles(dir string) ([]string, error) {\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttype candidate struct {\n\t\tpath  string\n\t\torder int64\n\t}\n\tcands := make([]candidate, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif name == defaultLogFileName {\n\t\t\tcands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})\n\t\t\tcontinue\n\t\t}\n\t\tif order, ok := rotationOrder(name); ok {\n\t\t\tcands = append(cands, candidate{path: filepath.Join(dir, name), order: order})\n\t\t}\n\t}\n\tif len(cands) == 0 {\n\t\treturn []string{}, nil\n\t}\n\tsort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })\n\tpaths := make([]string, 0, len(cands))\n\tfor i := len(cands) - 1; i >= 0; i-- {\n\t\tpaths = append(paths, cands[i].path)\n\t}\n\treturn paths, nil\n}\n\ntype logAccumulator struct {\n\tcutoff  int64\n\tlimit   int\n\tlines   []string\n\ttotal   int\n\tlatest  int64\n\tinclude bool\n}\n\nfunc newLogAccumulator(cutoff int64, limit int) *logAccumulator {\n\tcapacity := 256\n\tif limit > 0 && limit < capacity {\n\t\tcapacity = limit\n\t}\n\treturn &logAccumulator{\n\t\tcutoff: cutoff,\n\t\tlimit:  limit,\n\t\tlines:  make([]string, 0, capacity),\n\t}\n}\n\nfunc (acc *logAccumulator) consumeFile(path string) error {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\tdefer func() {\n\t\t_ = file.Close()\n\t}()\n\n\tscanner := bufio.NewScanner(file)\n\tbuf := make([]byte, 0, logScannerInitialBuffer)\n\tscanner.Buffer(buf, logScannerMaxBuffer)\n\tfor scanner.Scan() {\n\t\tacc.addLine(scanner.Text())\n\t}\n\tif errScan := scanner.Err(); errScan != nil {\n\t\treturn errScan\n\t}\n\treturn nil\n}\n\nfunc (acc *logAccumulator) addLine(raw string) {\n\tline := strings.TrimRight(raw, \"\\r\")\n\tacc.total++\n\tts := parseTimestamp(line)\n\tif ts > acc.latest {\n\t\tacc.latest = ts\n\t}\n\tif ts > 0 {\n\t\tacc.include = acc.cutoff == 0 || ts > acc.cutoff\n\t\tif acc.cutoff == 0 || acc.include {\n\t\t\tacc.append(line)\n\t\t}\n\t\treturn\n\t}\n\tif acc.cutoff == 0 || acc.include {\n\t\tacc.append(line)\n\t}\n}\n\nfunc (acc *logAccumulator) append(line string) {\n\tacc.lines = append(acc.lines, line)\n\tif acc.limit > 0 && len(acc.lines) > acc.limit {\n\t\tacc.lines = acc.lines[len(acc.lines)-acc.limit:]\n\t}\n}\n\nfunc (acc *logAccumulator) result() ([]string, int, int64) {\n\tif acc.lines == nil {\n\t\tacc.lines = []string{}\n\t}\n\treturn acc.lines, acc.total, acc.latest\n}\n\nfunc parseCutoff(raw string) int64 {\n\tvalue := strings.TrimSpace(raw)\n\tif value == \"\" {\n\t\treturn 0\n\t}\n\tts, err := strconv.ParseInt(value, 10, 64)\n\tif err != nil || ts <= 0 {\n\t\treturn 0\n\t}\n\treturn ts\n}\n\nfunc parseLimit(raw string) (int, error) {\n\tvalue := strings.TrimSpace(raw)\n\tif value == \"\" {\n\t\treturn 0, nil\n\t}\n\tlimit, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"must be a positive integer\")\n\t}\n\tif limit <= 0 {\n\t\treturn 0, fmt.Errorf(\"must be greater than zero\")\n\t}\n\treturn limit, nil\n}\n\nfunc parseTimestamp(line string) int64 {\n\tif strings.HasPrefix(line, \"[\") {\n\t\tline = line[1:]\n\t}\n\tif len(line) < 19 {\n\t\treturn 0\n\t}\n\tcandidate := line[:19]\n\tt, err := time.ParseInLocation(\"2006-01-02 15:04:05\", candidate, time.Local)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn t.Unix()\n}\n\nfunc isRotatedLogFile(name string) bool {\n\tif _, ok := rotationOrder(name); ok {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc rotationOrder(name string) (int64, bool) {\n\tif order, ok := numericRotationOrder(name); ok {\n\t\treturn order, true\n\t}\n\tif order, ok := timestampRotationOrder(name); ok {\n\t\treturn order, true\n\t}\n\treturn 0, false\n}\n\nfunc numericRotationOrder(name string) (int64, bool) {\n\tif !strings.HasPrefix(name, defaultLogFileName+\".\") {\n\t\treturn 0, false\n\t}\n\tsuffix := strings.TrimPrefix(name, defaultLogFileName+\".\")\n\tif suffix == \"\" {\n\t\treturn 0, false\n\t}\n\tn, err := strconv.Atoi(suffix)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\treturn int64(n), true\n}\n\nfunc timestampRotationOrder(name string) (int64, bool) {\n\text := filepath.Ext(defaultLogFileName)\n\tbase := strings.TrimSuffix(defaultLogFileName, ext)\n\tif base == \"\" {\n\t\treturn 0, false\n\t}\n\tprefix := base + \"-\"\n\tif !strings.HasPrefix(name, prefix) {\n\t\treturn 0, false\n\t}\n\tclean := strings.TrimPrefix(name, prefix)\n\tif strings.HasSuffix(clean, \".gz\") {\n\t\tclean = strings.TrimSuffix(clean, \".gz\")\n\t}\n\tif ext != \"\" {\n\t\tif !strings.HasSuffix(clean, ext) {\n\t\t\treturn 0, false\n\t\t}\n\t\tclean = strings.TrimSuffix(clean, ext)\n\t}\n\tif clean == \"\" {\n\t\treturn 0, false\n\t}\n\tif idx := strings.IndexByte(clean, '.'); idx != -1 {\n\t\tclean = clean[:idx]\n\t}\n\tparsed, err := time.ParseInLocation(\"2006-01-02T15-04-05\", clean, time.Local)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\treturn math.MaxInt64 - parsed.Unix(), true\n}\n"
  },
  {
    "path": "internal/api/handlers/management/model_definitions.go",
    "content": "package management\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n)\n\n// GetStaticModelDefinitions returns static model metadata for a given channel.\n// Channel is provided via path param (:channel) or query param (?channel=...).\nfunc (h *Handler) GetStaticModelDefinitions(c *gin.Context) {\n\tchannel := strings.TrimSpace(c.Param(\"channel\"))\n\tif channel == \"\" {\n\t\tchannel = strings.TrimSpace(c.Query(\"channel\"))\n\t}\n\tif channel == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"channel is required\"})\n\t\treturn\n\t}\n\n\tmodels := registry.GetStaticModelDefinitionsByChannel(channel)\n\tif models == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"unknown channel\", \"channel\": channel})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"channel\": strings.ToLower(strings.TrimSpace(channel)),\n\t\t\"models\":  models,\n\t})\n}\n"
  },
  {
    "path": "internal/api/handlers/management/oauth_callback.go",
    "content": "package management\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype oauthCallbackRequest struct {\n\tProvider    string `json:\"provider\"`\n\tRedirectURL string `json:\"redirect_url\"`\n\tCode        string `json:\"code\"`\n\tState       string `json:\"state\"`\n\tError       string `json:\"error\"`\n}\n\nfunc (h *Handler) PostOAuthCallback(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"handler not initialized\"})\n\t\treturn\n\t}\n\n\tvar req oauthCallbackRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"invalid body\"})\n\t\treturn\n\t}\n\n\tcanonicalProvider, err := NormalizeOAuthProvider(req.Provider)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"unsupported provider\"})\n\t\treturn\n\t}\n\n\tstate := strings.TrimSpace(req.State)\n\tcode := strings.TrimSpace(req.Code)\n\terrMsg := strings.TrimSpace(req.Error)\n\n\tif rawRedirect := strings.TrimSpace(req.RedirectURL); rawRedirect != \"\" {\n\t\tu, errParse := url.Parse(rawRedirect)\n\t\tif errParse != nil {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"invalid redirect_url\"})\n\t\t\treturn\n\t\t}\n\t\tq := u.Query()\n\t\tif state == \"\" {\n\t\t\tstate = strings.TrimSpace(q.Get(\"state\"))\n\t\t}\n\t\tif code == \"\" {\n\t\t\tcode = strings.TrimSpace(q.Get(\"code\"))\n\t\t}\n\t\tif errMsg == \"\" {\n\t\t\terrMsg = strings.TrimSpace(q.Get(\"error\"))\n\t\t\tif errMsg == \"\" {\n\t\t\t\terrMsg = strings.TrimSpace(q.Get(\"error_description\"))\n\t\t\t}\n\t\t}\n\t}\n\n\tif state == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"state is required\"})\n\t\treturn\n\t}\n\tif err := ValidateOAuthState(state); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"invalid state\"})\n\t\treturn\n\t}\n\tif code == \"\" && errMsg == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"code or error is required\"})\n\t\treturn\n\t}\n\n\tsessionProvider, sessionStatus, ok := GetOAuthSession(state)\n\tif !ok {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"status\": \"error\", \"error\": \"unknown or expired state\"})\n\t\treturn\n\t}\n\tif sessionStatus != \"\" {\n\t\tc.JSON(http.StatusConflict, gin.H{\"status\": \"error\", \"error\": \"oauth flow is not pending\"})\n\t\treturn\n\t}\n\tif !strings.EqualFold(sessionProvider, canonicalProvider) {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"status\": \"error\", \"error\": \"provider does not match state\"})\n\t\treturn\n\t}\n\n\tif _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {\n\t\tif errors.Is(errWrite, errOAuthSessionNotPending) {\n\t\t\tc.JSON(http.StatusConflict, gin.H{\"status\": \"error\", \"error\": \"oauth flow is not pending\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\", \"error\": \"failed to persist oauth callback\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n}\n"
  },
  {
    "path": "internal/api/handlers/management/oauth_sessions.go",
    "content": "package management\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\toauthSessionTTL     = 10 * time.Minute\n\tmaxOAuthStateLength = 128\n)\n\nvar (\n\terrInvalidOAuthState      = errors.New(\"invalid oauth state\")\n\terrUnsupportedOAuthFlow   = errors.New(\"unsupported oauth provider\")\n\terrOAuthSessionNotPending = errors.New(\"oauth session is not pending\")\n)\n\ntype oauthSession struct {\n\tProvider  string\n\tStatus    string\n\tCreatedAt time.Time\n\tExpiresAt time.Time\n}\n\ntype oauthSessionStore struct {\n\tmu       sync.RWMutex\n\tttl      time.Duration\n\tsessions map[string]oauthSession\n}\n\nfunc newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {\n\tif ttl <= 0 {\n\t\tttl = oauthSessionTTL\n\t}\n\treturn &oauthSessionStore{\n\t\tttl:      ttl,\n\t\tsessions: make(map[string]oauthSession),\n\t}\n}\n\nfunc (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {\n\tfor state, session := range s.sessions {\n\t\tif !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {\n\t\t\tdelete(s.sessions, state)\n\t\t}\n\t}\n}\n\nfunc (s *oauthSessionStore) Register(state, provider string) {\n\tstate = strings.TrimSpace(state)\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif state == \"\" || provider == \"\" {\n\t\treturn\n\t}\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\ts.sessions[state] = oauthSession{\n\t\tProvider:  provider,\n\t\tStatus:    \"\",\n\t\tCreatedAt: now,\n\t\tExpiresAt: now.Add(s.ttl),\n\t}\n}\n\nfunc (s *oauthSessionStore) SetError(state, message string) {\n\tstate = strings.TrimSpace(state)\n\tmessage = strings.TrimSpace(message)\n\tif state == \"\" {\n\t\treturn\n\t}\n\tif message == \"\" {\n\t\tmessage = \"Authentication failed\"\n\t}\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\tsession, ok := s.sessions[state]\n\tif !ok {\n\t\treturn\n\t}\n\tsession.Status = message\n\tsession.ExpiresAt = now.Add(s.ttl)\n\ts.sessions[state] = session\n}\n\nfunc (s *oauthSessionStore) Complete(state string) {\n\tstate = strings.TrimSpace(state)\n\tif state == \"\" {\n\t\treturn\n\t}\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\tdelete(s.sessions, state)\n}\n\nfunc (s *oauthSessionStore) CompleteProvider(provider string) int {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif provider == \"\" {\n\t\treturn 0\n\t}\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\tremoved := 0\n\tfor state, session := range s.sessions {\n\t\tif strings.EqualFold(session.Provider, provider) {\n\t\t\tdelete(s.sessions, state)\n\t\t\tremoved++\n\t\t}\n\t}\n\treturn removed\n}\n\nfunc (s *oauthSessionStore) Get(state string) (oauthSession, bool) {\n\tstate = strings.TrimSpace(state)\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\tsession, ok := s.sessions[state]\n\treturn session, ok\n}\n\nfunc (s *oauthSessionStore) IsPending(state, provider string) bool {\n\tstate = strings.TrimSpace(state)\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tnow := time.Now()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.purgeExpiredLocked(now)\n\tsession, ok := s.sessions[state]\n\tif !ok {\n\t\treturn false\n\t}\n\tif session.Status != \"\" {\n\t\treturn false\n\t}\n\tif provider == \"\" {\n\t\treturn true\n\t}\n\treturn strings.EqualFold(session.Provider, provider)\n}\n\nvar oauthSessions = newOAuthSessionStore(oauthSessionTTL)\n\nfunc RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }\n\nfunc SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }\n\nfunc CompleteOAuthSession(state string) { oauthSessions.Complete(state) }\n\nfunc CompleteOAuthSessionsByProvider(provider string) int {\n\treturn oauthSessions.CompleteProvider(provider)\n}\n\nfunc GetOAuthSession(state string) (provider string, status string, ok bool) {\n\tsession, ok := oauthSessions.Get(state)\n\tif !ok {\n\t\treturn \"\", \"\", false\n\t}\n\treturn session.Provider, session.Status, true\n}\n\nfunc IsOAuthSessionPending(state, provider string) bool {\n\treturn oauthSessions.IsPending(state, provider)\n}\n\nfunc ValidateOAuthState(state string) error {\n\ttrimmed := strings.TrimSpace(state)\n\tif trimmed == \"\" {\n\t\treturn fmt.Errorf(\"%w: empty\", errInvalidOAuthState)\n\t}\n\tif len(trimmed) > maxOAuthStateLength {\n\t\treturn fmt.Errorf(\"%w: too long\", errInvalidOAuthState)\n\t}\n\tif strings.Contains(trimmed, \"/\") || strings.Contains(trimmed, \"\\\\\") {\n\t\treturn fmt.Errorf(\"%w: contains path separator\", errInvalidOAuthState)\n\t}\n\tif strings.Contains(trimmed, \"..\") {\n\t\treturn fmt.Errorf(\"%w: contains '..'\", errInvalidOAuthState)\n\t}\n\tfor _, r := range trimmed {\n\t\tswitch {\n\t\tcase r >= 'a' && r <= 'z':\n\t\tcase r >= 'A' && r <= 'Z':\n\t\tcase r >= '0' && r <= '9':\n\t\tcase r == '-' || r == '_' || r == '.':\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"%w: invalid character\", errInvalidOAuthState)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc NormalizeOAuthProvider(provider string) (string, error) {\n\tswitch strings.ToLower(strings.TrimSpace(provider)) {\n\tcase \"anthropic\", \"claude\":\n\t\treturn \"anthropic\", nil\n\tcase \"codex\", \"openai\":\n\t\treturn \"codex\", nil\n\tcase \"gemini\", \"google\":\n\t\treturn \"gemini\", nil\n\tcase \"iflow\", \"i-flow\":\n\t\treturn \"iflow\", nil\n\tcase \"antigravity\", \"anti-gravity\":\n\t\treturn \"antigravity\", nil\n\tcase \"qwen\":\n\t\treturn \"qwen\", nil\n\tdefault:\n\t\treturn \"\", errUnsupportedOAuthFlow\n\t}\n}\n\ntype oauthCallbackFilePayload struct {\n\tCode  string `json:\"code\"`\n\tState string `json:\"state\"`\n\tError string `json:\"error\"`\n}\n\nfunc WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {\n\tif strings.TrimSpace(authDir) == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth dir is empty\")\n\t}\n\tcanonicalProvider, err := NormalizeOAuthProvider(provider)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := ValidateOAuthState(state); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfileName := fmt.Sprintf(\".oauth-%s-%s.oauth\", canonicalProvider, state)\n\tfilePath := filepath.Join(authDir, fileName)\n\tpayload := oauthCallbackFilePayload{\n\t\tCode:  strings.TrimSpace(code),\n\t\tState: strings.TrimSpace(state),\n\t\tError: strings.TrimSpace(errorMessage),\n\t}\n\tdata, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal oauth callback payload: %w\", err)\n\t}\n\tif err := os.WriteFile(filePath, data, 0o600); err != nil {\n\t\treturn \"\", fmt.Errorf(\"write oauth callback file: %w\", err)\n\t}\n\treturn filePath, nil\n}\n\nfunc WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {\n\tcanonicalProvider, err := NormalizeOAuthProvider(provider)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !IsOAuthSessionPending(state, canonicalProvider) {\n\t\treturn \"\", errOAuthSessionNotPending\n\t}\n\treturn WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage)\n}\n"
  },
  {
    "path": "internal/api/handlers/management/quota.go",
    "content": "package management\n\nimport \"github.com/gin-gonic/gin\"\n\n// Quota exceeded toggles\nfunc (h *Handler) GetSwitchProject(c *gin.Context) {\n\tc.JSON(200, gin.H{\"switch-project\": h.cfg.QuotaExceeded.SwitchProject})\n}\nfunc (h *Handler) PutSwitchProject(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })\n}\n\nfunc (h *Handler) GetSwitchPreviewModel(c *gin.Context) {\n\tc.JSON(200, gin.H{\"switch-preview-model\": h.cfg.QuotaExceeded.SwitchPreviewModel})\n}\nfunc (h *Handler) PutSwitchPreviewModel(c *gin.Context) {\n\th.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })\n}\n"
  },
  {
    "path": "internal/api/handlers/management/test_store_test.go",
    "content": "package management\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\ntype memoryAuthStore struct {\n\tmu    sync.Mutex\n\titems map[string]*coreauth.Auth\n}\n\nfunc (s *memoryAuthStore) List(_ context.Context) ([]*coreauth.Auth, error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tout := make([]*coreauth.Auth, 0, len(s.items))\n\tfor _, item := range s.items {\n\t\tout = append(out, item)\n\t}\n\treturn out, nil\n}\n\nfunc (s *memoryAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", nil\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.items == nil {\n\t\ts.items = make(map[string]*coreauth.Auth)\n\t}\n\ts.items[auth.ID] = auth\n\treturn auth.ID, nil\n}\n\nfunc (s *memoryAuthStore) Delete(_ context.Context, id string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tdelete(s.items, id)\n\treturn nil\n}\n\nfunc (s *memoryAuthStore) SetBaseDir(string) {}\n"
  },
  {
    "path": "internal/api/handlers/management/usage.go",
    "content": "package management\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/usage\"\n)\n\ntype usageExportPayload struct {\n\tVersion    int                      `json:\"version\"`\n\tExportedAt time.Time                `json:\"exported_at\"`\n\tUsage      usage.StatisticsSnapshot `json:\"usage\"`\n}\n\ntype usageImportPayload struct {\n\tVersion int                      `json:\"version\"`\n\tUsage   usage.StatisticsSnapshot `json:\"usage\"`\n}\n\n// GetUsageStatistics returns the in-memory request statistics snapshot.\nfunc (h *Handler) GetUsageStatistics(c *gin.Context) {\n\tvar snapshot usage.StatisticsSnapshot\n\tif h != nil && h.usageStats != nil {\n\t\tsnapshot = h.usageStats.Snapshot()\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"usage\":           snapshot,\n\t\t\"failed_requests\": snapshot.FailureCount,\n\t})\n}\n\n// ExportUsageStatistics returns a complete usage snapshot for backup/migration.\nfunc (h *Handler) ExportUsageStatistics(c *gin.Context) {\n\tvar snapshot usage.StatisticsSnapshot\n\tif h != nil && h.usageStats != nil {\n\t\tsnapshot = h.usageStats.Snapshot()\n\t}\n\tc.JSON(http.StatusOK, usageExportPayload{\n\t\tVersion:    1,\n\t\tExportedAt: time.Now().UTC(),\n\t\tUsage:      snapshot,\n\t})\n}\n\n// ImportUsageStatistics merges a previously exported usage snapshot into memory.\nfunc (h *Handler) ImportUsageStatistics(c *gin.Context) {\n\tif h == nil || h.usageStats == nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"usage statistics unavailable\"})\n\t\treturn\n\t}\n\n\tdata, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"failed to read request body\"})\n\t\treturn\n\t}\n\n\tvar payload usageImportPayload\n\tif err := json.Unmarshal(data, &payload); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid json\"})\n\t\treturn\n\t}\n\tif payload.Version != 0 && payload.Version != 1 {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"unsupported version\"})\n\t\treturn\n\t}\n\n\tresult := h.usageStats.MergeSnapshot(payload.Usage)\n\tsnapshot := h.usageStats.Snapshot()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"added\":           result.Added,\n\t\t\"skipped\":         result.Skipped,\n\t\t\"total_requests\":  snapshot.TotalRequests,\n\t\t\"failed_requests\": snapshot.FailureCount,\n\t})\n}\n"
  },
  {
    "path": "internal/api/handlers/management/vertex_import.go",
    "content": "package management\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.\nfunc (h *Handler) ImportVertexCredential(c *gin.Context) {\n\tif h == nil || h.cfg == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"config unavailable\"})\n\t\treturn\n\t}\n\tif h.cfg.AuthDir == \"\" {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"error\": \"auth directory not configured\"})\n\t\treturn\n\t}\n\n\tfileHeader, err := c.FormFile(\"file\")\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"file required\"})\n\t\treturn\n\t}\n\n\tfile, err := fileHeader.Open()\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": fmt.Sprintf(\"failed to read file: %v\", err)})\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\tdata, err := io.ReadAll(file)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": fmt.Sprintf(\"failed to read file: %v\", err)})\n\t\treturn\n\t}\n\n\tvar serviceAccount map[string]any\n\tif err := json.Unmarshal(data, &serviceAccount); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid json\", \"message\": err.Error()})\n\t\treturn\n\t}\n\n\tnormalizedSA, err := vertex.NormalizeServiceAccountMap(serviceAccount)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid service account\", \"message\": err.Error()})\n\t\treturn\n\t}\n\tserviceAccount = normalizedSA\n\n\tprojectID := strings.TrimSpace(valueAsString(serviceAccount[\"project_id\"]))\n\tif projectID == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"project_id missing\"})\n\t\treturn\n\t}\n\temail := strings.TrimSpace(valueAsString(serviceAccount[\"client_email\"]))\n\n\tlocation := strings.TrimSpace(c.PostForm(\"location\"))\n\tif location == \"\" {\n\t\tlocation = strings.TrimSpace(c.Query(\"location\"))\n\t}\n\tif location == \"\" {\n\t\tlocation = \"us-central1\"\n\t}\n\n\tfileName := fmt.Sprintf(\"vertex-%s.json\", sanitizeVertexFilePart(projectID))\n\tlabel := labelForVertex(projectID, email)\n\tstorage := &vertex.VertexCredentialStorage{\n\t\tServiceAccount: serviceAccount,\n\t\tProjectID:      projectID,\n\t\tEmail:          email,\n\t\tLocation:       location,\n\t\tType:           \"vertex\",\n\t}\n\tmetadata := map[string]any{\n\t\t\"service_account\": serviceAccount,\n\t\t\"project_id\":      projectID,\n\t\t\"email\":           email,\n\t\t\"location\":        location,\n\t\t\"type\":            \"vertex\",\n\t\t\"label\":           label,\n\t}\n\trecord := &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: \"vertex\",\n\t\tFileName: fileName,\n\t\tStorage:  storage,\n\t\tLabel:    label,\n\t\tMetadata: metadata,\n\t}\n\n\tctx := context.Background()\n\tif reqCtx := c.Request.Context(); reqCtx != nil {\n\t\tctx = reqCtx\n\t}\n\tsavedPath, err := h.saveTokenRecord(ctx, record)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"save_failed\", \"message\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\":     \"ok\",\n\t\t\"auth-file\":  savedPath,\n\t\t\"project_id\": projectID,\n\t\t\"email\":      email,\n\t\t\"location\":   location,\n\t})\n}\n\nfunc valueAsString(v any) string {\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\tswitch t := v.(type) {\n\tcase string:\n\t\treturn t\n\tdefault:\n\t\treturn fmt.Sprint(t)\n\t}\n}\n\nfunc sanitizeVertexFilePart(s string) string {\n\tout := strings.TrimSpace(s)\n\treplacers := []string{\"/\", \"_\", \"\\\\\", \"_\", \":\", \"_\", \" \", \"-\"}\n\tfor i := 0; i < len(replacers); i += 2 {\n\t\tout = strings.ReplaceAll(out, replacers[i], replacers[i+1])\n\t}\n\tif out == \"\" {\n\t\treturn \"vertex\"\n\t}\n\treturn out\n}\n\nfunc labelForVertex(projectID, email string) string {\n\tp := strings.TrimSpace(projectID)\n\te := strings.TrimSpace(email)\n\tif p != \"\" && e != \"\" {\n\t\treturn fmt.Sprintf(\"%s (%s)\", p, e)\n\t}\n\tif p != \"\" {\n\t\treturn p\n\t}\n\tif e != \"\" {\n\t\treturn e\n\t}\n\treturn \"vertex\"\n}\n"
  },
  {
    "path": "internal/api/middleware/request_logging.go",
    "content": "// Package middleware provides HTTP middleware components for the CLI Proxy API server.\n// This file contains the request logging middleware that captures comprehensive\n// request and response data when enabled through configuration.\npackage middleware\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n)\n\nconst maxErrorOnlyCapturedRequestBodyBytes int64 = 1 << 20 // 1 MiB\n\n// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.\n// It captures detailed information about the request and response, including headers and body,\n// and uses the provided RequestLogger to record this data. When full request logging is disabled,\n// body capture is limited to small known-size payloads to avoid large per-request memory spikes.\nfunc RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif logger == nil {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tif shouldSkipMethodForRequestLogging(c.Request) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tpath := c.Request.URL.Path\n\t\tif !shouldLogRequest(path) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tloggerEnabled := logger.IsEnabled()\n\n\t\t// Capture request information\n\t\trequestInfo, err := captureRequestInfo(c, shouldCaptureRequestBody(loggerEnabled, c.Request))\n\t\tif err != nil {\n\t\t\t// Log error but continue processing\n\t\t\t// In a real implementation, you might want to use a proper logger here\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Create response writer wrapper\n\t\twrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)\n\t\tif !loggerEnabled {\n\t\t\twrapper.logOnErrorOnly = true\n\t\t}\n\t\tc.Writer = wrapper\n\n\t\t// Process the request\n\t\tc.Next()\n\n\t\t// Finalize logging after request processing\n\t\tif err = wrapper.Finalize(c); err != nil {\n\t\t\t// Log error but don't interrupt the response\n\t\t\t// In a real implementation, you might want to use a proper logger here\n\t\t}\n\t}\n}\n\nfunc shouldSkipMethodForRequestLogging(req *http.Request) bool {\n\tif req == nil {\n\t\treturn true\n\t}\n\tif req.Method != http.MethodGet {\n\t\treturn false\n\t}\n\treturn !isResponsesWebsocketUpgrade(req)\n}\n\nfunc isResponsesWebsocketUpgrade(req *http.Request) bool {\n\tif req == nil || req.URL == nil {\n\t\treturn false\n\t}\n\tif req.URL.Path != \"/v1/responses\" {\n\t\treturn false\n\t}\n\treturn strings.EqualFold(strings.TrimSpace(req.Header.Get(\"Upgrade\")), \"websocket\")\n}\n\nfunc shouldCaptureRequestBody(loggerEnabled bool, req *http.Request) bool {\n\tif loggerEnabled {\n\t\treturn true\n\t}\n\tif req == nil || req.Body == nil {\n\t\treturn false\n\t}\n\tcontentType := strings.ToLower(strings.TrimSpace(req.Header.Get(\"Content-Type\")))\n\tif strings.HasPrefix(contentType, \"multipart/form-data\") {\n\t\treturn false\n\t}\n\tif req.ContentLength <= 0 {\n\t\treturn false\n\t}\n\treturn req.ContentLength <= maxErrorOnlyCapturedRequestBodyBytes\n}\n\n// captureRequestInfo extracts relevant information from the incoming HTTP request.\n// It captures the URL, method, headers, and body. The request body is read and then\n// restored so that it can be processed by subsequent handlers.\nfunc captureRequestInfo(c *gin.Context, captureBody bool) (*RequestInfo, error) {\n\t// Capture URL with sensitive query parameters masked\n\tmaskedQuery := util.MaskSensitiveQuery(c.Request.URL.RawQuery)\n\turl := c.Request.URL.Path\n\tif maskedQuery != \"\" {\n\t\turl += \"?\" + maskedQuery\n\t}\n\n\t// Capture method\n\tmethod := c.Request.Method\n\n\t// Capture headers\n\theaders := make(map[string][]string)\n\tfor key, values := range c.Request.Header {\n\t\theaders[key] = values\n\t}\n\n\t// Capture request body\n\tvar body []byte\n\tif captureBody && c.Request.Body != nil {\n\t\t// Read the body\n\t\tbodyBytes, err := io.ReadAll(c.Request.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Restore the body for the actual request processing\n\t\tc.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))\n\t\tbody = bodyBytes\n\t}\n\n\treturn &RequestInfo{\n\t\tURL:       url,\n\t\tMethod:    method,\n\t\tHeaders:   headers,\n\t\tBody:      body,\n\t\tRequestID: logging.GetGinRequestID(c),\n\t\tTimestamp: time.Now(),\n\t}, nil\n}\n\n// shouldLogRequest determines whether the request should be logged.\n// It skips management endpoints to avoid leaking secrets but allows\n// all other routes, including module-provided ones, to honor request-log.\nfunc shouldLogRequest(path string) bool {\n\tif strings.HasPrefix(path, \"/v0/management\") || strings.HasPrefix(path, \"/management\") {\n\t\treturn false\n\t}\n\n\tif strings.HasPrefix(path, \"/api\") {\n\t\treturn strings.HasPrefix(path, \"/api/provider\")\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/api/middleware/request_logging_test.go",
    "content": "package middleware\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestShouldSkipMethodForRequestLogging(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\treq  *http.Request\n\t\tskip bool\n\t}{\n\t\t{\n\t\t\tname: \"nil request\",\n\t\t\treq:  nil,\n\t\t\tskip: true,\n\t\t},\n\t\t{\n\t\t\tname: \"post request should not skip\",\n\t\t\treq: &http.Request{\n\t\t\t\tMethod: http.MethodPost,\n\t\t\t\tURL:    &url.URL{Path: \"/v1/responses\"},\n\t\t\t},\n\t\t\tskip: false,\n\t\t},\n\t\t{\n\t\t\tname: \"plain get should skip\",\n\t\t\treq: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL:    &url.URL{Path: \"/v1/models\"},\n\t\t\t\tHeader: http.Header{},\n\t\t\t},\n\t\t\tskip: true,\n\t\t},\n\t\t{\n\t\t\tname: \"responses websocket upgrade should not skip\",\n\t\t\treq: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL:    &url.URL{Path: \"/v1/responses\"},\n\t\t\t\tHeader: http.Header{\"Upgrade\": []string{\"websocket\"}},\n\t\t\t},\n\t\t\tskip: false,\n\t\t},\n\t\t{\n\t\t\tname: \"responses get without upgrade should skip\",\n\t\t\treq: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL:    &url.URL{Path: \"/v1/responses\"},\n\t\t\t\tHeader: http.Header{},\n\t\t\t},\n\t\t\tskip: true,\n\t\t},\n\t}\n\n\tfor i := range tests {\n\t\tgot := shouldSkipMethodForRequestLogging(tests[i].req)\n\t\tif got != tests[i].skip {\n\t\t\tt.Fatalf(\"%s: got skip=%t, want %t\", tests[i].name, got, tests[i].skip)\n\t\t}\n\t}\n}\n\nfunc TestShouldCaptureRequestBody(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tloggerEnabled bool\n\t\treq           *http.Request\n\t\twant          bool\n\t}{\n\t\t{\n\t\t\tname:          \"logger enabled always captures\",\n\t\t\tloggerEnabled: true,\n\t\t\treq: &http.Request{\n\t\t\t\tBody:          io.NopCloser(strings.NewReader(\"{}\")),\n\t\t\t\tContentLength: -1,\n\t\t\t\tHeader:        http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"nil request\",\n\t\t\tloggerEnabled: false,\n\t\t\treq:           nil,\n\t\t\twant:          false,\n\t\t},\n\t\t{\n\t\t\tname:          \"small known size json in error-only mode\",\n\t\t\tloggerEnabled: false,\n\t\t\treq: &http.Request{\n\t\t\t\tBody:          io.NopCloser(strings.NewReader(\"{}\")),\n\t\t\t\tContentLength: 2,\n\t\t\t\tHeader:        http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"large known size skipped in error-only mode\",\n\t\t\tloggerEnabled: false,\n\t\t\treq: &http.Request{\n\t\t\t\tBody:          io.NopCloser(strings.NewReader(\"x\")),\n\t\t\t\tContentLength: maxErrorOnlyCapturedRequestBodyBytes + 1,\n\t\t\t\tHeader:        http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"unknown size skipped in error-only mode\",\n\t\t\tloggerEnabled: false,\n\t\t\treq: &http.Request{\n\t\t\t\tBody:          io.NopCloser(strings.NewReader(\"x\")),\n\t\t\t\tContentLength: -1,\n\t\t\t\tHeader:        http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"multipart skipped in error-only mode\",\n\t\t\tloggerEnabled: false,\n\t\t\treq: &http.Request{\n\t\t\t\tBody:          io.NopCloser(strings.NewReader(\"x\")),\n\t\t\t\tContentLength: 1,\n\t\t\t\tHeader:        http.Header{\"Content-Type\": []string{\"multipart/form-data; boundary=abc\"}},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor i := range tests {\n\t\tgot := shouldCaptureRequestBody(tests[i].loggerEnabled, tests[i].req)\n\t\tif got != tests[i].want {\n\t\t\tt.Fatalf(\"%s: got %t, want %t\", tests[i].name, got, tests[i].want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/api/middleware/response_writer.go",
    "content": "// Package middleware provides Gin HTTP middleware for the CLI Proxy API server.\n// It includes a sophisticated response writer wrapper designed to capture and log request and response data,\n// including support for streaming responses, without impacting latency.\npackage middleware\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n)\n\nconst requestBodyOverrideContextKey = \"REQUEST_BODY_OVERRIDE\"\n\n// RequestInfo holds essential details of an incoming HTTP request for logging purposes.\ntype RequestInfo struct {\n\tURL       string              // URL is the request URL.\n\tMethod    string              // Method is the HTTP method (e.g., GET, POST).\n\tHeaders   map[string][]string // Headers contains the request headers.\n\tBody      []byte              // Body is the raw request body.\n\tRequestID string              // RequestID is the unique identifier for the request.\n\tTimestamp time.Time           // Timestamp is when the request was received.\n}\n\n// ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.\n// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.\ntype ResponseWriterWrapper struct {\n\tgin.ResponseWriter\n\tbody                *bytes.Buffer              // body is a buffer to store the response body for non-streaming responses.\n\tisStreaming         bool                       // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).\n\tstreamWriter        logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.\n\tchunkChannel        chan []byte                // chunkChannel is a channel for asynchronously passing response chunks to the logger.\n\tstreamDone          chan struct{}              // streamDone signals when the streaming goroutine completes.\n\tlogger              logging.RequestLogger      // logger is the instance of the request logger service.\n\trequestInfo         *RequestInfo               // requestInfo holds the details of the original request.\n\tstatusCode          int                        // statusCode stores the HTTP status code of the response.\n\theaders             map[string][]string        // headers stores the response headers.\n\tlogOnErrorOnly      bool                       // logOnErrorOnly enables logging only when an error response is detected.\n\tfirstChunkTimestamp time.Time                  // firstChunkTimestamp captures TTFB for streaming responses.\n}\n\n// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.\n// It takes the original gin.ResponseWriter, a logger instance, and request information.\n//\n// Parameters:\n//   - w: The original gin.ResponseWriter to wrap.\n//   - logger: The logging service to use for recording requests.\n//   - requestInfo: The pre-captured information about the incoming request.\n//\n// Returns:\n//   - A pointer to a new ResponseWriterWrapper.\nfunc NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {\n\treturn &ResponseWriterWrapper{\n\t\tResponseWriter: w,\n\t\tbody:           &bytes.Buffer{},\n\t\tlogger:         logger,\n\t\trequestInfo:    requestInfo,\n\t\theaders:        make(map[string][]string),\n\t}\n}\n\n// Write wraps the underlying ResponseWriter's Write method to capture response data.\n// For non-streaming responses, it writes to an internal buffer. For streaming responses,\n// it sends data chunks to a non-blocking channel for asynchronous logging.\n// CRITICAL: This method prioritizes writing to the client to ensure zero latency,\n// handling logging operations subsequently.\nfunc (w *ResponseWriterWrapper) Write(data []byte) (int, error) {\n\t// Ensure headers are captured before first write\n\t// This is critical because Write() may trigger WriteHeader() internally\n\tw.ensureHeadersCaptured()\n\n\t// CRITICAL: Write to client first (zero latency)\n\tn, err := w.ResponseWriter.Write(data)\n\n\t// THEN: Handle logging based on response type\n\tif w.isStreaming && w.chunkChannel != nil {\n\t\t// Capture TTFB on first chunk (synchronous, before async channel send)\n\t\tif w.firstChunkTimestamp.IsZero() {\n\t\t\tw.firstChunkTimestamp = time.Now()\n\t\t}\n\t\t// For streaming responses: Send to async logging channel (non-blocking)\n\t\tselect {\n\t\tcase w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy\n\t\tdefault: // Channel full, skip logging to avoid blocking\n\t\t}\n\t\treturn n, err\n\t}\n\n\tif w.shouldBufferResponseBody() {\n\t\tw.body.Write(data)\n\t}\n\n\treturn n, err\n}\n\nfunc (w *ResponseWriterWrapper) shouldBufferResponseBody() bool {\n\tif w.logger != nil && w.logger.IsEnabled() {\n\t\treturn true\n\t}\n\tif !w.logOnErrorOnly {\n\t\treturn false\n\t}\n\tstatus := w.statusCode\n\tif status == 0 {\n\t\tif statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok && statusWriter != nil {\n\t\t\tstatus = statusWriter.Status()\n\t\t} else {\n\t\t\tstatus = http.StatusOK\n\t\t}\n\t}\n\treturn status >= http.StatusBadRequest\n}\n\n// WriteString wraps the underlying ResponseWriter's WriteString method to capture response data.\n// Some handlers (and fmt/io helpers) write via io.StringWriter; without this override, those writes\n// bypass Write() and would be missing from request logs.\nfunc (w *ResponseWriterWrapper) WriteString(data string) (int, error) {\n\tw.ensureHeadersCaptured()\n\n\t// CRITICAL: Write to client first (zero latency)\n\tn, err := w.ResponseWriter.WriteString(data)\n\n\t// THEN: Capture for logging\n\tif w.isStreaming && w.chunkChannel != nil {\n\t\t// Capture TTFB on first chunk (synchronous, before async channel send)\n\t\tif w.firstChunkTimestamp.IsZero() {\n\t\t\tw.firstChunkTimestamp = time.Now()\n\t\t}\n\t\tselect {\n\t\tcase w.chunkChannel <- []byte(data):\n\t\tdefault:\n\t\t}\n\t\treturn n, err\n\t}\n\n\tif w.shouldBufferResponseBody() {\n\t\tw.body.WriteString(data)\n\t}\n\treturn n, err\n}\n\n// WriteHeader wraps the underlying ResponseWriter's WriteHeader method.\n// It captures the status code, detects if the response is streaming based on the Content-Type header,\n// and initializes the appropriate logging mechanism (standard or streaming).\nfunc (w *ResponseWriterWrapper) WriteHeader(statusCode int) {\n\tw.statusCode = statusCode\n\n\t// Capture response headers using the new method\n\tw.captureCurrentHeaders()\n\n\t// Detect streaming based on Content-Type\n\tcontentType := w.ResponseWriter.Header().Get(\"Content-Type\")\n\tw.isStreaming = w.detectStreaming(contentType)\n\n\t// If streaming, initialize streaming log writer\n\tif w.isStreaming && w.logger.IsEnabled() {\n\t\tstreamWriter, err := w.logger.LogStreamingRequest(\n\t\t\tw.requestInfo.URL,\n\t\t\tw.requestInfo.Method,\n\t\t\tw.requestInfo.Headers,\n\t\t\tw.requestInfo.Body,\n\t\t\tw.requestInfo.RequestID,\n\t\t)\n\t\tif err == nil {\n\t\t\tw.streamWriter = streamWriter\n\t\t\tw.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes\n\t\t\tdoneChan := make(chan struct{})\n\t\t\tw.streamDone = doneChan\n\n\t\t\t// Start async chunk processor\n\t\t\tgo w.processStreamingChunks(doneChan)\n\n\t\t\t// Write status immediately\n\t\t\t_ = streamWriter.WriteStatus(statusCode, w.headers)\n\t\t}\n\t}\n\n\t// Call original WriteHeader\n\tw.ResponseWriter.WriteHeader(statusCode)\n}\n\n// ensureHeadersCaptured is a helper function to make sure response headers are captured.\n// It is safe to call this method multiple times; it will always refresh the headers\n// with the latest state from the underlying ResponseWriter.\nfunc (w *ResponseWriterWrapper) ensureHeadersCaptured() {\n\t// Always capture the current headers to ensure we have the latest state\n\tw.captureCurrentHeaders()\n}\n\n// captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them\n// in the wrapper's headers map. It creates copies of the header values to prevent race conditions.\nfunc (w *ResponseWriterWrapper) captureCurrentHeaders() {\n\t// Initialize headers map if needed\n\tif w.headers == nil {\n\t\tw.headers = make(map[string][]string)\n\t}\n\n\t// Capture all current headers from the underlying ResponseWriter\n\tfor key, values := range w.ResponseWriter.Header() {\n\t\t// Make a copy of the values slice to avoid reference issues\n\t\theaderValues := make([]string, len(values))\n\t\tcopy(headerValues, values)\n\t\tw.headers[key] = headerValues\n\t}\n}\n\n// detectStreaming determines if a response should be treated as a streaming response.\n// It checks for a \"text/event-stream\" Content-Type or a '\"stream\": true'\n// field in the original request body.\nfunc (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {\n\t// Check Content-Type for Server-Sent Events\n\tif strings.Contains(contentType, \"text/event-stream\") {\n\t\treturn true\n\t}\n\n\t// If a concrete Content-Type is already set (e.g., application/json for error responses),\n\t// treat it as non-streaming instead of inferring from the request payload.\n\tif strings.TrimSpace(contentType) != \"\" {\n\t\treturn false\n\t}\n\n\t// Only fall back to request payload hints when Content-Type is not set yet.\n\tif w.requestInfo != nil && len(w.requestInfo.Body) > 0 {\n\t\treturn bytes.Contains(w.requestInfo.Body, []byte(`\"stream\": true`)) ||\n\t\t\tbytes.Contains(w.requestInfo.Body, []byte(`\"stream\":true`))\n\t}\n\n\treturn false\n}\n\n// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.\n// It asynchronously writes each chunk to the streaming log writer.\nfunc (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {\n\tif done == nil {\n\t\treturn\n\t}\n\n\tdefer close(done)\n\n\tif w.streamWriter == nil || w.chunkChannel == nil {\n\t\treturn\n\t}\n\n\tfor chunk := range w.chunkChannel {\n\t\tw.streamWriter.WriteChunkAsync(chunk)\n\t}\n}\n\n// Finalize completes the logging process for the request and response.\n// For streaming responses, it closes the chunk channel and the stream writer.\n// For non-streaming responses, it logs the complete request and response details,\n// including any API-specific request/response data stored in the Gin context.\nfunc (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {\n\tif w.logger == nil {\n\t\treturn nil\n\t}\n\n\tfinalStatusCode := w.statusCode\n\tif finalStatusCode == 0 {\n\t\tif statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {\n\t\t\tfinalStatusCode = statusWriter.Status()\n\t\t} else {\n\t\t\tfinalStatusCode = 200\n\t\t}\n\t}\n\n\tvar slicesAPIResponseError []*interfaces.ErrorMessage\n\tapiResponseError, isExist := c.Get(\"API_RESPONSE_ERROR\")\n\tif isExist {\n\t\tif apiErrors, ok := apiResponseError.([]*interfaces.ErrorMessage); ok {\n\t\t\tslicesAPIResponseError = apiErrors\n\t\t}\n\t}\n\n\thasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest\n\tforceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()\n\tif !w.logger.IsEnabled() && !forceLog {\n\t\treturn nil\n\t}\n\n\tif w.isStreaming && w.streamWriter != nil {\n\t\tif w.chunkChannel != nil {\n\t\t\tclose(w.chunkChannel)\n\t\t\tw.chunkChannel = nil\n\t\t}\n\n\t\tif w.streamDone != nil {\n\t\t\t<-w.streamDone\n\t\t\tw.streamDone = nil\n\t\t}\n\n\t\tw.streamWriter.SetFirstChunkTimestamp(w.firstChunkTimestamp)\n\n\t\t// Write API Request and Response to the streaming log before closing\n\t\tapiRequest := w.extractAPIRequest(c)\n\t\tif len(apiRequest) > 0 {\n\t\t\t_ = w.streamWriter.WriteAPIRequest(apiRequest)\n\t\t}\n\t\tapiResponse := w.extractAPIResponse(c)\n\t\tif len(apiResponse) > 0 {\n\t\t\t_ = w.streamWriter.WriteAPIResponse(apiResponse)\n\t\t}\n\t\tif err := w.streamWriter.Close(); err != nil {\n\t\t\tw.streamWriter = nil\n\t\t\treturn err\n\t\t}\n\t\tw.streamWriter = nil\n\t\treturn nil\n\t}\n\n\treturn w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)\n}\n\nfunc (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {\n\tw.ensureHeadersCaptured()\n\n\tfinalHeaders := make(map[string][]string, len(w.headers))\n\tfor key, values := range w.headers {\n\t\theaderValues := make([]string, len(values))\n\t\tcopy(headerValues, values)\n\t\tfinalHeaders[key] = headerValues\n\t}\n\n\treturn finalHeaders\n}\n\nfunc (w *ResponseWriterWrapper) extractAPIRequest(c *gin.Context) []byte {\n\tapiRequest, isExist := c.Get(\"API_REQUEST\")\n\tif !isExist {\n\t\treturn nil\n\t}\n\tdata, ok := apiRequest.([]byte)\n\tif !ok || len(data) == 0 {\n\t\treturn nil\n\t}\n\treturn data\n}\n\nfunc (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {\n\tapiResponse, isExist := c.Get(\"API_RESPONSE\")\n\tif !isExist {\n\t\treturn nil\n\t}\n\tdata, ok := apiResponse.([]byte)\n\tif !ok || len(data) == 0 {\n\t\treturn nil\n\t}\n\treturn data\n}\n\nfunc (w *ResponseWriterWrapper) extractAPIResponseTimestamp(c *gin.Context) time.Time {\n\tts, isExist := c.Get(\"API_RESPONSE_TIMESTAMP\")\n\tif !isExist {\n\t\treturn time.Time{}\n\t}\n\tif t, ok := ts.(time.Time); ok {\n\t\treturn t\n\t}\n\treturn time.Time{}\n}\n\nfunc (w *ResponseWriterWrapper) extractRequestBody(c *gin.Context) []byte {\n\tif c != nil {\n\t\tif bodyOverride, isExist := c.Get(requestBodyOverrideContextKey); isExist {\n\t\t\tswitch value := bodyOverride.(type) {\n\t\t\tcase []byte:\n\t\t\t\tif len(value) > 0 {\n\t\t\t\t\treturn bytes.Clone(value)\n\t\t\t\t}\n\t\t\tcase string:\n\t\t\t\tif strings.TrimSpace(value) != \"\" {\n\t\t\t\t\treturn []byte(value)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif w.requestInfo != nil && len(w.requestInfo.Body) > 0 {\n\t\treturn w.requestInfo.Body\n\t}\n\treturn nil\n}\n\nfunc (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body []byte, apiRequestBody, apiResponseBody []byte, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {\n\tif w.requestInfo == nil {\n\t\treturn nil\n\t}\n\n\tif loggerWithOptions, ok := w.logger.(interface {\n\t\tLogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error\n\t}); ok {\n\t\treturn loggerWithOptions.LogRequestWithOptions(\n\t\t\tw.requestInfo.URL,\n\t\t\tw.requestInfo.Method,\n\t\t\tw.requestInfo.Headers,\n\t\t\trequestBody,\n\t\t\tstatusCode,\n\t\t\theaders,\n\t\t\tbody,\n\t\t\tapiRequestBody,\n\t\t\tapiResponseBody,\n\t\t\tapiResponseErrors,\n\t\t\tforceLog,\n\t\t\tw.requestInfo.RequestID,\n\t\t\tw.requestInfo.Timestamp,\n\t\t\tapiResponseTimestamp,\n\t\t)\n\t}\n\n\treturn w.logger.LogRequest(\n\t\tw.requestInfo.URL,\n\t\tw.requestInfo.Method,\n\t\tw.requestInfo.Headers,\n\t\trequestBody,\n\t\tstatusCode,\n\t\theaders,\n\t\tbody,\n\t\tapiRequestBody,\n\t\tapiResponseBody,\n\t\tapiResponseErrors,\n\t\tw.requestInfo.RequestID,\n\t\tw.requestInfo.Timestamp,\n\t\tapiResponseTimestamp,\n\t)\n}\n"
  },
  {
    "path": "internal/api/middleware/response_writer_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestExtractRequestBodyPrefersOverride(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\n\twrapper := &ResponseWriterWrapper{\n\t\trequestInfo: &RequestInfo{Body: []byte(\"original-body\")},\n\t}\n\n\tbody := wrapper.extractRequestBody(c)\n\tif string(body) != \"original-body\" {\n\t\tt.Fatalf(\"request body = %q, want %q\", string(body), \"original-body\")\n\t}\n\n\tc.Set(requestBodyOverrideContextKey, []byte(\"override-body\"))\n\tbody = wrapper.extractRequestBody(c)\n\tif string(body) != \"override-body\" {\n\t\tt.Fatalf(\"request body = %q, want %q\", string(body), \"override-body\")\n\t}\n}\n\nfunc TestExtractRequestBodySupportsStringOverride(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\n\twrapper := &ResponseWriterWrapper{}\n\tc.Set(requestBodyOverrideContextKey, \"override-as-string\")\n\n\tbody := wrapper.extractRequestBody(c)\n\tif string(body) != \"override-as-string\" {\n\t\tt.Fatalf(\"request body = %q, want %q\", string(body), \"override-as-string\")\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/amp.go",
    "content": "// Package amp implements the Amp CLI routing module, providing OAuth-based\n// integration with Amp CLI for ChatGPT and Anthropic subscriptions.\npackage amp\n\nimport (\n\t\"fmt\"\n\t\"net/http/httputil\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// Option configures the AmpModule.\ntype Option func(*AmpModule)\n\n// AmpModule implements the RouteModuleV2 interface for Amp CLI integration.\n// It provides:\n//   - Reverse proxy to Amp control plane for OAuth/management\n//   - Provider-specific route aliases (/api/provider/{provider}/...)\n//   - Automatic gzip decompression for misconfigured upstreams\n//   - Model mapping for routing unavailable models to alternatives\ntype AmpModule struct {\n\tsecretSource    SecretSource\n\tproxy           *httputil.ReverseProxy\n\tproxyMu         sync.RWMutex // protects proxy for hot-reload\n\taccessManager   *sdkaccess.Manager\n\tauthMiddleware_ gin.HandlerFunc\n\tmodelMapper     *DefaultModelMapper\n\tenabled         bool\n\tregisterOnce    sync.Once\n\n\t// restrictToLocalhost controls localhost-only access for management routes (hot-reloadable)\n\trestrictToLocalhost bool\n\trestrictMu          sync.RWMutex\n\n\t// configMu protects lastConfig for partial reload comparison\n\tconfigMu   sync.RWMutex\n\tlastConfig *config.AmpCode\n}\n\n// New creates a new Amp routing module with the given options.\n// This is the preferred constructor using the Option pattern.\n//\n// Example:\n//\n//\tampModule := amp.New(\n//\t    amp.WithAccessManager(accessManager),\n//\t    amp.WithAuthMiddleware(authMiddleware),\n//\t    amp.WithSecretSource(customSecret),\n//\t)\nfunc New(opts ...Option) *AmpModule {\n\tm := &AmpModule{\n\t\tsecretSource: nil, // Will be created on demand if not provided\n\t}\n\tfor _, opt := range opts {\n\t\topt(m)\n\t}\n\treturn m\n}\n\n// NewLegacy creates a new Amp routing module using the legacy constructor signature.\n// This is provided for backwards compatibility.\n//\n// DEPRECATED: Use New with options instead.\nfunc NewLegacy(accessManager *sdkaccess.Manager, authMiddleware gin.HandlerFunc) *AmpModule {\n\treturn New(\n\t\tWithAccessManager(accessManager),\n\t\tWithAuthMiddleware(authMiddleware),\n\t)\n}\n\n// WithSecretSource sets a custom secret source for the module.\nfunc WithSecretSource(source SecretSource) Option {\n\treturn func(m *AmpModule) {\n\t\tm.secretSource = source\n\t}\n}\n\n// WithAccessManager sets the access manager for the module.\nfunc WithAccessManager(am *sdkaccess.Manager) Option {\n\treturn func(m *AmpModule) {\n\t\tm.accessManager = am\n\t}\n}\n\n// WithAuthMiddleware sets the authentication middleware for provider routes.\nfunc WithAuthMiddleware(middleware gin.HandlerFunc) Option {\n\treturn func(m *AmpModule) {\n\t\tm.authMiddleware_ = middleware\n\t}\n}\n\n// Name returns the module identifier\nfunc (m *AmpModule) Name() string {\n\treturn \"amp-routing\"\n}\n\n// forceModelMappings returns whether model mappings should take precedence over local API keys\nfunc (m *AmpModule) forceModelMappings() bool {\n\tm.configMu.RLock()\n\tdefer m.configMu.RUnlock()\n\tif m.lastConfig == nil {\n\t\treturn false\n\t}\n\treturn m.lastConfig.ForceModelMappings\n}\n\n// Register sets up Amp routes if configured.\n// This implements the RouteModuleV2 interface with Context.\n// Routes are registered only once via sync.Once for idempotent behavior.\nfunc (m *AmpModule) Register(ctx modules.Context) error {\n\tsettings := ctx.Config.AmpCode\n\tupstreamURL := strings.TrimSpace(settings.UpstreamURL)\n\n\t// Determine auth middleware (from module or context)\n\tauth := m.getAuthMiddleware(ctx)\n\n\t// Use registerOnce to ensure routes are only registered once\n\tvar regErr error\n\tm.registerOnce.Do(func() {\n\t\t// Initialize model mapper from config (for routing unavailable models to alternatives)\n\t\tm.modelMapper = NewModelMapper(settings.ModelMappings)\n\n\t\t// Store initial config for partial reload comparison\n\t\tm.lastConfig = new(settings)\n\n\t\t// Initialize localhost restriction setting (hot-reloadable)\n\t\tm.setRestrictToLocalhost(settings.RestrictManagementToLocalhost)\n\n\t\t// Always register provider aliases - these work without an upstream\n\t\tm.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)\n\n\t\t// Register management proxy routes once; middleware will gate access when upstream is unavailable.\n\t\t// Pass auth middleware to require valid API key for all management routes.\n\t\tm.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth)\n\n\t\t// If no upstream URL, skip proxy routes but provider aliases are still available\n\t\tif upstreamURL == \"\" {\n\t\t\tlog.Debug(\"amp upstream proxy disabled (no upstream URL configured)\")\n\t\t\tlog.Debug(\"amp provider alias routes registered\")\n\t\t\tm.enabled = false\n\t\t\treturn\n\t\t}\n\n\t\tif err := m.enableUpstreamProxy(upstreamURL, &settings); err != nil {\n\t\t\tregErr = fmt.Errorf(\"failed to create amp proxy: %w\", err)\n\t\t\treturn\n\t\t}\n\n\t\tlog.Debug(\"amp provider alias routes registered\")\n\t})\n\n\treturn regErr\n}\n\n// getAuthMiddleware returns the authentication middleware, preferring the\n// module's configured middleware, then the context middleware, then a fallback.\nfunc (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {\n\tif m.authMiddleware_ != nil {\n\t\treturn m.authMiddleware_\n\t}\n\tif ctx.AuthMiddleware != nil {\n\t\treturn ctx.AuthMiddleware\n\t}\n\t// Fallback: no authentication (should not happen in production)\n\tlog.Warn(\"amp module: no auth middleware provided, allowing all requests\")\n\treturn func(c *gin.Context) {\n\t\tc.Next()\n\t}\n}\n\n// OnConfigUpdated handles configuration updates with partial reload support.\n// Only updates components that have actually changed to avoid unnecessary work.\n// Supports hot-reload for: model-mappings, upstream-api-key, upstream-url, restrict-management-to-localhost.\nfunc (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {\n\tnewSettings := cfg.AmpCode\n\n\t// Get previous config for comparison\n\tm.configMu.RLock()\n\toldSettings := m.lastConfig\n\tm.configMu.RUnlock()\n\n\tif oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {\n\t\tm.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)\n\t}\n\n\tnewUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)\n\toldUpstreamURL := \"\"\n\tif oldSettings != nil {\n\t\toldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)\n\t}\n\n\tif !m.enabled && newUpstreamURL != \"\" {\n\t\tif err := m.enableUpstreamProxy(newUpstreamURL, &newSettings); err != nil {\n\t\t\tlog.Errorf(\"amp config: failed to enable upstream proxy for %s: %v\", newUpstreamURL, err)\n\t\t}\n\t}\n\n\t// Check model mappings change\n\tmodelMappingsChanged := m.hasModelMappingsChanged(oldSettings, &newSettings)\n\tif modelMappingsChanged {\n\t\tif m.modelMapper != nil {\n\t\t\tm.modelMapper.UpdateMappings(newSettings.ModelMappings)\n\t\t} else if m.enabled {\n\t\t\tlog.Warnf(\"amp model mapper not initialized, skipping model mapping update\")\n\t\t}\n\t}\n\n\tif m.enabled {\n\t\t// Check upstream URL change - now supports hot-reload\n\t\tif newUpstreamURL == \"\" && oldUpstreamURL != \"\" {\n\t\t\tm.setProxy(nil)\n\t\t\tm.enabled = false\n\t\t} else if oldUpstreamURL != \"\" && newUpstreamURL != oldUpstreamURL && newUpstreamURL != \"\" {\n\t\t\t// Recreate proxy with new URL\n\t\t\tproxy, err := createReverseProxy(newUpstreamURL, m.secretSource)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"amp config: failed to create proxy for new upstream URL %s: %v\", newUpstreamURL, err)\n\t\t\t} else {\n\t\t\t\tm.setProxy(proxy)\n\t\t\t}\n\t\t}\n\n\t\t// Check API key change (both default and per-client mappings)\n\t\tapiKeyChanged := m.hasAPIKeyChanged(oldSettings, &newSettings)\n\t\tupstreamAPIKeysChanged := m.hasUpstreamAPIKeysChanged(oldSettings, &newSettings)\n\t\tif apiKeyChanged || upstreamAPIKeysChanged {\n\t\t\tif m.secretSource != nil {\n\t\t\t\tif ms, ok := m.secretSource.(*MappedSecretSource); ok {\n\t\t\t\t\tif apiKeyChanged {\n\t\t\t\t\t\tms.UpdateDefaultExplicitKey(newSettings.UpstreamAPIKey)\n\t\t\t\t\t\tms.InvalidateCache()\n\t\t\t\t\t}\n\t\t\t\t\tif upstreamAPIKeysChanged {\n\t\t\t\t\t\tms.UpdateMappings(newSettings.UpstreamAPIKeys)\n\t\t\t\t\t}\n\t\t\t\t} else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {\n\t\t\t\t\tms.UpdateExplicitKey(newSettings.UpstreamAPIKey)\n\t\t\t\t\tms.InvalidateCache()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t}\n\n\t// Store current config for next comparison\n\tm.configMu.Lock()\n\tsettingsCopy := newSettings // copy struct\n\tm.lastConfig = &settingsCopy\n\tm.configMu.Unlock()\n\n\treturn nil\n}\n\nfunc (m *AmpModule) enableUpstreamProxy(upstreamURL string, settings *config.AmpCode) error {\n\tif m.secretSource == nil {\n\t\t// Create MultiSourceSecret as the default source, then wrap with MappedSecretSource\n\t\tdefaultSource := NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */)\n\t\tmappedSource := NewMappedSecretSource(defaultSource)\n\t\tmappedSource.UpdateMappings(settings.UpstreamAPIKeys)\n\t\tm.secretSource = mappedSource\n\t} else if ms, ok := m.secretSource.(*MappedSecretSource); ok {\n\t\tms.UpdateDefaultExplicitKey(settings.UpstreamAPIKey)\n\t\tms.InvalidateCache()\n\t\tms.UpdateMappings(settings.UpstreamAPIKeys)\n\t} else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {\n\t\t// Legacy path: wrap existing MultiSourceSecret with MappedSecretSource\n\t\tms.UpdateExplicitKey(settings.UpstreamAPIKey)\n\t\tms.InvalidateCache()\n\t\tmappedSource := NewMappedSecretSource(ms)\n\t\tmappedSource.UpdateMappings(settings.UpstreamAPIKeys)\n\t\tm.secretSource = mappedSource\n\t}\n\n\tproxy, err := createReverseProxy(upstreamURL, m.secretSource)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm.setProxy(proxy)\n\tm.enabled = true\n\n\tlog.Infof(\"amp upstream proxy enabled for: %s\", upstreamURL)\n\treturn nil\n}\n\n// hasModelMappingsChanged compares old and new model mappings.\nfunc (m *AmpModule) hasModelMappingsChanged(old *config.AmpCode, new *config.AmpCode) bool {\n\tif old == nil {\n\t\treturn len(new.ModelMappings) > 0\n\t}\n\n\tif len(old.ModelMappings) != len(new.ModelMappings) {\n\t\treturn true\n\t}\n\n\t// Build map for efficient and robust comparison\n\ttype mappingInfo struct {\n\t\tto    string\n\t\tregex bool\n\t}\n\toldMap := make(map[string]mappingInfo, len(old.ModelMappings))\n\tfor _, mapping := range old.ModelMappings {\n\t\toldMap[strings.TrimSpace(mapping.From)] = mappingInfo{\n\t\t\tto:    strings.TrimSpace(mapping.To),\n\t\t\tregex: mapping.Regex,\n\t\t}\n\t}\n\n\tfor _, mapping := range new.ModelMappings {\n\t\tfrom := strings.TrimSpace(mapping.From)\n\t\tto := strings.TrimSpace(mapping.To)\n\t\tif oldVal, exists := oldMap[from]; !exists || oldVal.to != to || oldVal.regex != mapping.Regex {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// hasAPIKeyChanged compares old and new API keys.\nfunc (m *AmpModule) hasAPIKeyChanged(old *config.AmpCode, new *config.AmpCode) bool {\n\toldKey := \"\"\n\tif old != nil {\n\t\toldKey = strings.TrimSpace(old.UpstreamAPIKey)\n\t}\n\tnewKey := strings.TrimSpace(new.UpstreamAPIKey)\n\treturn oldKey != newKey\n}\n\n// hasUpstreamAPIKeysChanged compares old and new per-client upstream API key mappings.\nfunc (m *AmpModule) hasUpstreamAPIKeysChanged(old *config.AmpCode, new *config.AmpCode) bool {\n\tif old == nil {\n\t\treturn len(new.UpstreamAPIKeys) > 0\n\t}\n\n\tif len(old.UpstreamAPIKeys) != len(new.UpstreamAPIKeys) {\n\t\treturn true\n\t}\n\n\t// Build map for comparison: upstreamKey -> set of clientKeys\n\ttype entryInfo struct {\n\t\tupstreamKey string\n\t\tclientKeys  map[string]struct{}\n\t}\n\toldEntries := make([]entryInfo, len(old.UpstreamAPIKeys))\n\tfor i, entry := range old.UpstreamAPIKeys {\n\t\tclientKeys := make(map[string]struct{}, len(entry.APIKeys))\n\t\tfor _, k := range entry.APIKeys {\n\t\t\ttrimmed := strings.TrimSpace(k)\n\t\t\tif trimmed == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tclientKeys[trimmed] = struct{}{}\n\t\t}\n\t\toldEntries[i] = entryInfo{\n\t\t\tupstreamKey: strings.TrimSpace(entry.UpstreamAPIKey),\n\t\t\tclientKeys:  clientKeys,\n\t\t}\n\t}\n\n\tfor i, newEntry := range new.UpstreamAPIKeys {\n\t\tif i >= len(oldEntries) {\n\t\t\treturn true\n\t\t}\n\t\toldE := oldEntries[i]\n\t\tif strings.TrimSpace(newEntry.UpstreamAPIKey) != oldE.upstreamKey {\n\t\t\treturn true\n\t\t}\n\t\tnewKeys := make(map[string]struct{}, len(newEntry.APIKeys))\n\t\tfor _, k := range newEntry.APIKeys {\n\t\t\ttrimmed := strings.TrimSpace(k)\n\t\t\tif trimmed == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnewKeys[trimmed] = struct{}{}\n\t\t}\n\t\tif len(newKeys) != len(oldE.clientKeys) {\n\t\t\treturn true\n\t\t}\n\t\tfor k := range newKeys {\n\t\t\tif _, ok := oldE.clientKeys[k]; !ok {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\n// GetModelMapper returns the model mapper instance (for testing/debugging).\nfunc (m *AmpModule) GetModelMapper() *DefaultModelMapper {\n\treturn m.modelMapper\n}\n\n// getProxy returns the current proxy instance (thread-safe for hot-reload).\nfunc (m *AmpModule) getProxy() *httputil.ReverseProxy {\n\tm.proxyMu.RLock()\n\tdefer m.proxyMu.RUnlock()\n\treturn m.proxy\n}\n\n// setProxy updates the proxy instance (thread-safe for hot-reload).\nfunc (m *AmpModule) setProxy(proxy *httputil.ReverseProxy) {\n\tm.proxyMu.Lock()\n\tdefer m.proxyMu.Unlock()\n\tm.proxy = proxy\n}\n\n// IsRestrictedToLocalhost returns whether management routes are restricted to localhost.\nfunc (m *AmpModule) IsRestrictedToLocalhost() bool {\n\tm.restrictMu.RLock()\n\tdefer m.restrictMu.RUnlock()\n\treturn m.restrictToLocalhost\n}\n\n// setRestrictToLocalhost updates the localhost restriction setting.\nfunc (m *AmpModule) setRestrictToLocalhost(restrict bool) {\n\tm.restrictMu.Lock()\n\tdefer m.restrictMu.Unlock()\n\tm.restrictToLocalhost = restrict\n}\n"
  },
  {
    "path": "internal/api/modules/amp/amp_test.go",
    "content": "package amp\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n)\n\nfunc TestAmpModule_Name(t *testing.T) {\n\tm := New()\n\tif m.Name() != \"amp-routing\" {\n\t\tt.Fatalf(\"want amp-routing, got %s\", m.Name())\n\t}\n}\n\nfunc TestAmpModule_New(t *testing.T) {\n\taccessManager := sdkaccess.NewManager()\n\tauthMiddleware := func(c *gin.Context) { c.Next() }\n\n\tm := NewLegacy(accessManager, authMiddleware)\n\n\tif m.accessManager != accessManager {\n\t\tt.Fatal(\"accessManager not set\")\n\t}\n\tif m.authMiddleware_ == nil {\n\t\tt.Fatal(\"authMiddleware not set\")\n\t}\n\tif m.enabled {\n\t\tt.Fatal(\"enabled should be false initially\")\n\t}\n\tif m.proxy != nil {\n\t\tt.Fatal(\"proxy should be nil initially\")\n\t}\n}\n\nfunc TestAmpModule_Register_WithUpstream(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Fake upstream to ensure URL is valid\n\tupstream := httptest.NewServer(nil)\n\tdefer upstream.Close()\n\n\taccessManager := sdkaccess.NewManager()\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })\n\n\tcfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:    upstream.URL,\n\t\t\tUpstreamAPIKey: \"test-key\",\n\t\t},\n\t}\n\n\tctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}\n\tif err := m.Register(ctx); err != nil {\n\t\tt.Fatalf(\"register error: %v\", err)\n\t}\n\n\tif !m.enabled {\n\t\tt.Fatal(\"module should be enabled with upstream URL\")\n\t}\n\tif m.proxy == nil {\n\t\tt.Fatal(\"proxy should be initialized\")\n\t}\n\tif m.secretSource == nil {\n\t\tt.Fatal(\"secretSource should be initialized\")\n\t}\n}\n\nfunc TestAmpModule_Register_WithoutUpstream(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\taccessManager := sdkaccess.NewManager()\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })\n\n\tcfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL: \"\", // No upstream\n\t\t},\n\t}\n\n\tctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}\n\tif err := m.Register(ctx); err != nil {\n\t\tt.Fatalf(\"register should not error without upstream: %v\", err)\n\t}\n\n\tif m.enabled {\n\t\tt.Fatal(\"module should be disabled without upstream URL\")\n\t}\n\tif m.proxy != nil {\n\t\tt.Fatal(\"proxy should not be initialized without upstream\")\n\t}\n\n\t// But provider aliases should still be registered\n\treq := httptest.NewRequest(\"GET\", \"/api/provider/openai/models\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code == 404 {\n\t\tt.Fatal(\"provider aliases should be registered even without upstream\")\n\t}\n}\n\nfunc TestAmpModule_Register_InvalidUpstream(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\taccessManager := sdkaccess.NewManager()\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })\n\n\tcfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL: \"://invalid-url\",\n\t\t},\n\t}\n\n\tctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}\n\tif err := m.Register(ctx); err == nil {\n\t\tt.Fatal(\"expected error for invalid upstream URL\")\n\t}\n}\n\nfunc TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tp := filepath.Join(tmpDir, \"secrets.json\")\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"v1\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tm := &AmpModule{enabled: true}\n\tms := NewMultiSourceSecretWithPath(\"\", p, time.Minute)\n\tm.secretSource = ms\n\tm.lastConfig = &config.AmpCode{\n\t\tUpstreamAPIKey: \"old-key\",\n\t}\n\n\t// Warm the cache\n\tif _, err := ms.Get(context.Background()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif ms.cache == nil {\n\t\tt.Fatal(\"expected cache to be set\")\n\t}\n\n\t// Update config - should invalidate cache\n\tif err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: \"http://x\", UpstreamAPIKey: \"new-key\"}}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif ms.cache != nil {\n\t\tt.Fatal(\"expected cache to be invalidated\")\n\t}\n}\n\nfunc TestAmpModule_OnConfigUpdated_NotEnabled(t *testing.T) {\n\tm := &AmpModule{enabled: false}\n\n\t// Should not error or panic when disabled\n\tif err := m.OnConfigUpdated(&config.Config{}); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestAmpModule_OnConfigUpdated_URLRemoved(t *testing.T) {\n\tm := &AmpModule{enabled: true}\n\tms := NewMultiSourceSecret(\"\", 0)\n\tm.secretSource = ms\n\n\t// Config update with empty URL - should log warning but not error\n\tcfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: \"\"}}\n\n\tif err := m.OnConfigUpdated(cfg); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestAmpModule_OnConfigUpdated_NonMultiSourceSecret(t *testing.T) {\n\t// Test that OnConfigUpdated doesn't panic with StaticSecretSource\n\tm := &AmpModule{enabled: true}\n\tm.secretSource = NewStaticSecretSource(\"static-key\")\n\n\tcfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: \"http://example.com\"}}\n\n\t// Should not error or panic\n\tif err := m.OnConfigUpdated(cfg); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Create module with no auth middleware\n\tm := &AmpModule{authMiddleware_: nil}\n\n\t// Get the fallback middleware via getAuthMiddleware\n\tctx := modules.Context{Engine: r, AuthMiddleware: nil}\n\tmiddleware := m.getAuthMiddleware(ctx)\n\n\tif middleware == nil {\n\t\tt.Fatal(\"getAuthMiddleware should return a fallback, not nil\")\n\t}\n\n\t// Test that it works\n\tcalled := false\n\tr.GET(\"/test\", middleware, func(c *gin.Context) {\n\t\tcalled = true\n\t\tc.String(200, \"ok\")\n\t})\n\n\treq := httptest.NewRequest(\"GET\", \"/test\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif !called {\n\t\tt.Fatal(\"fallback middleware should allow requests through\")\n\t}\n}\n\nfunc TestAmpModule_SecretSource_FromConfig(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\tupstream := httptest.NewServer(nil)\n\tdefer upstream.Close()\n\n\taccessManager := sdkaccess.NewManager()\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })\n\n\t// Config with explicit API key\n\tcfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:    upstream.URL,\n\t\t\tUpstreamAPIKey: \"config-key\",\n\t\t},\n\t}\n\n\tctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}\n\tif err := m.Register(ctx); err != nil {\n\t\tt.Fatalf(\"register error: %v\", err)\n\t}\n\n\t// Secret source should be MultiSourceSecret with config key\n\tif m.secretSource == nil {\n\t\tt.Fatal(\"secretSource should be set\")\n\t}\n\n\t// Verify it returns the config key\n\tkey, err := m.secretSource.Get(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"Get error: %v\", err)\n\t}\n\tif key != \"config-key\" {\n\t\tt.Fatalf(\"want config-key, got %s\", key)\n\t}\n}\n\nfunc TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tscenarios := []struct {\n\t\tname      string\n\t\tconfigURL string\n\t}{\n\t\t{\"with_upstream\", \"http://example.com\"},\n\t\t{\"without_upstream\", \"\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tr := gin.New()\n\t\t\taccessManager := sdkaccess.NewManager()\n\t\t\tbase := &handlers.BaseAPIHandler{}\n\n\t\t\tm := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })\n\n\t\t\tcfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: scenario.configURL}}\n\n\t\t\tctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}\n\t\t\tif err := m.Register(ctx); err != nil && scenario.configURL != \"\" {\n\t\t\t\tt.Fatalf(\"register error: %v\", err)\n\t\t\t}\n\n\t\t\t// Provider aliases should always be available\n\t\t\treq := httptest.NewRequest(\"GET\", \"/api/provider/openai/models\", nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code == 404 {\n\t\t\t\tt.Fatal(\"provider aliases should be registered\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAmpModule_hasUpstreamAPIKeysChanged_DetectsRemovedKeyWithDuplicateInput(t *testing.T) {\n\tm := &AmpModule{}\n\n\toldCfg := &config.AmpCode{\n\t\tUpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{\n\t\t\t{UpstreamAPIKey: \"u1\", APIKeys: []string{\"k1\", \"k2\"}},\n\t\t},\n\t}\n\tnewCfg := &config.AmpCode{\n\t\tUpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{\n\t\t\t{UpstreamAPIKey: \"u1\", APIKeys: []string{\"k1\", \"k1\"}},\n\t\t},\n\t}\n\n\tif !m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {\n\t\tt.Fatal(\"expected change to be detected when k2 is removed but new list contains duplicates\")\n\t}\n}\n\nfunc TestAmpModule_hasUpstreamAPIKeysChanged_IgnoresEmptyAndWhitespaceKeys(t *testing.T) {\n\tm := &AmpModule{}\n\n\toldCfg := &config.AmpCode{\n\t\tUpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{\n\t\t\t{UpstreamAPIKey: \"u1\", APIKeys: []string{\"k1\", \"k2\"}},\n\t\t},\n\t}\n\tnewCfg := &config.AmpCode{\n\t\tUpstreamAPIKeys: []config.AmpUpstreamAPIKeyEntry{\n\t\t\t{UpstreamAPIKey: \"u1\", APIKeys: []string{\"  k1  \", \"\", \"k2\", \"   \"}},\n\t\t},\n\t}\n\n\tif m.hasUpstreamAPIKeysChanged(oldCfg, newCfg) {\n\t\tt.Fatal(\"expected no change when only whitespace/empty entries differ\")\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/fallback_handlers.go",
    "content": "package amp\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http/httputil\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// AmpRouteType represents the type of routing decision made for an Amp request\ntype AmpRouteType string\n\nconst (\n\t// RouteTypeLocalProvider indicates the request is handled by a local OAuth provider (free)\n\tRouteTypeLocalProvider AmpRouteType = \"LOCAL_PROVIDER\"\n\t// RouteTypeModelMapping indicates the request was remapped to another available model (free)\n\tRouteTypeModelMapping AmpRouteType = \"MODEL_MAPPING\"\n\t// RouteTypeAmpCredits indicates the request is forwarded to ampcode.com (uses Amp credits)\n\tRouteTypeAmpCredits AmpRouteType = \"AMP_CREDITS\"\n\t// RouteTypeNoProvider indicates no provider or fallback available\n\tRouteTypeNoProvider AmpRouteType = \"NO_PROVIDER\"\n)\n\n// MappedModelContextKey is the Gin context key for passing mapped model names.\nconst MappedModelContextKey = \"mapped_model\"\n\n// logAmpRouting logs the routing decision for an Amp request with structured fields\nfunc logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {\n\tfields := log.Fields{\n\t\t\"component\":       \"amp-routing\",\n\t\t\"route_type\":      string(routeType),\n\t\t\"requested_model\": requestedModel,\n\t\t\"path\":            path,\n\t\t\"timestamp\":       time.Now().Format(time.RFC3339),\n\t}\n\n\tif resolvedModel != \"\" && resolvedModel != requestedModel {\n\t\tfields[\"resolved_model\"] = resolvedModel\n\t}\n\tif provider != \"\" {\n\t\tfields[\"provider\"] = provider\n\t}\n\n\tswitch routeType {\n\tcase RouteTypeLocalProvider:\n\t\tfields[\"cost\"] = \"free\"\n\t\tfields[\"source\"] = \"local_oauth\"\n\t\tlog.WithFields(fields).Debugf(\"amp using local provider for model: %s\", requestedModel)\n\n\tcase RouteTypeModelMapping:\n\t\tfields[\"cost\"] = \"free\"\n\t\tfields[\"source\"] = \"local_oauth\"\n\t\tfields[\"mapping\"] = requestedModel + \" -> \" + resolvedModel\n\t\t// model mapping already logged in mapper; avoid duplicate here\n\n\tcase RouteTypeAmpCredits:\n\t\tfields[\"cost\"] = \"amp_credits\"\n\t\tfields[\"source\"] = \"ampcode.com\"\n\t\tfields[\"model_id\"] = requestedModel // Explicit model_id for easy config reference\n\t\tlog.WithFields(fields).Warnf(\"forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local provider, add to config: ampcode.model-mappings: [{from: \\\"%s\\\", to: \\\"<your-local-model>\\\"}]\", requestedModel, requestedModel)\n\n\tcase RouteTypeNoProvider:\n\t\tfields[\"cost\"] = \"none\"\n\t\tfields[\"source\"] = \"error\"\n\t\tfields[\"model_id\"] = requestedModel // Explicit model_id for easy config reference\n\t\tlog.WithFields(fields).Warnf(\"no provider available for model_id: %s\", requestedModel)\n\t}\n}\n\n// FallbackHandler wraps a standard handler with fallback logic to ampcode.com\n// when the model's provider is not available in CLIProxyAPI\ntype FallbackHandler struct {\n\tgetProxy           func() *httputil.ReverseProxy\n\tmodelMapper        ModelMapper\n\tforceModelMappings func() bool\n}\n\n// NewFallbackHandler creates a new fallback handler wrapper\n// The getProxy function allows lazy evaluation of the proxy (useful when proxy is created after routes)\nfunc NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler {\n\treturn &FallbackHandler{\n\t\tgetProxy:           getProxy,\n\t\tforceModelMappings: func() bool { return false },\n\t}\n}\n\n// NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support\nfunc NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper, forceModelMappings func() bool) *FallbackHandler {\n\tif forceModelMappings == nil {\n\t\tforceModelMappings = func() bool { return false }\n\t}\n\treturn &FallbackHandler{\n\t\tgetProxy:           getProxy,\n\t\tmodelMapper:        mapper,\n\t\tforceModelMappings: forceModelMappings,\n\t}\n}\n\n// SetModelMapper sets the model mapper for this handler (allows late binding)\nfunc (fh *FallbackHandler) SetModelMapper(mapper ModelMapper) {\n\tfh.modelMapper = mapper\n}\n\n// WrapHandler wraps a gin.HandlerFunc with fallback logic\n// If the model's provider is not configured in CLIProxyAPI, it forwards to ampcode.com\nfunc (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\trequestPath := c.Request.URL.Path\n\n\t\t// Read the request body to extract the model name\n\t\tbodyBytes, err := io.ReadAll(c.Request.Body)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"amp fallback: failed to read request body: %v\", err)\n\t\t\thandler(c)\n\t\t\treturn\n\t\t}\n\n\t\t// Restore the body for the handler to read\n\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\n\t\t// Try to extract model from request body or URL path (for Gemini)\n\t\tmodelName := extractModelFromRequest(bodyBytes, c)\n\t\tif modelName == \"\" {\n\t\t\t// Can't determine model, proceed with normal handler\n\t\t\thandler(c)\n\t\t\treturn\n\t\t}\n\n\t\t// Normalize model (handles dynamic thinking suffixes)\n\t\tsuffixResult := thinking.ParseSuffix(modelName)\n\t\tnormalizedModel := suffixResult.ModelName\n\t\tthinkingSuffix := \"\"\n\t\tif suffixResult.HasSuffix {\n\t\t\tthinkingSuffix = \"(\" + suffixResult.RawSuffix + \")\"\n\t\t}\n\n\t\tresolveMappedModel := func() (string, []string) {\n\t\t\tif fh.modelMapper == nil {\n\t\t\t\treturn \"\", nil\n\t\t\t}\n\n\t\t\tmappedModel := fh.modelMapper.MapModel(modelName)\n\t\t\tif mappedModel == \"\" {\n\t\t\t\tmappedModel = fh.modelMapper.MapModel(normalizedModel)\n\t\t\t}\n\t\t\tmappedModel = strings.TrimSpace(mappedModel)\n\t\t\tif mappedModel == \"\" {\n\t\t\t\treturn \"\", nil\n\t\t\t}\n\n\t\t\t// Preserve dynamic thinking suffix (e.g. \"(xhigh)\") when mapping applies, unless the target\n\t\t\t// already specifies its own thinking suffix.\n\t\t\tif thinkingSuffix != \"\" {\n\t\t\t\tmappedSuffixResult := thinking.ParseSuffix(mappedModel)\n\t\t\t\tif !mappedSuffixResult.HasSuffix {\n\t\t\t\t\tmappedModel += thinkingSuffix\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName\n\t\t\tmappedProviders := util.GetProviderName(mappedBaseModel)\n\t\t\tif len(mappedProviders) == 0 {\n\t\t\t\treturn \"\", nil\n\t\t\t}\n\n\t\t\treturn mappedModel, mappedProviders\n\t\t}\n\n\t\t// Track resolved model for logging (may change if mapping is applied)\n\t\tresolvedModel := normalizedModel\n\t\tusedMapping := false\n\t\tvar providers []string\n\n\t\t// Check if model mappings should be forced ahead of local API keys\n\t\tforceMappings := fh.forceModelMappings != nil && fh.forceModelMappings()\n\n\t\tif forceMappings {\n\t\t\t// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)\n\t\t\t// This allows users to route Amp requests to their preferred OAuth providers\n\t\t\tif mappedModel, mappedProviders := resolveMappedModel(); mappedModel != \"\" {\n\t\t\t\t// Mapping found and provider available - rewrite the model in request body\n\t\t\t\tbodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)\n\t\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\t\t// Store mapped model in context for handlers that check it (like gemini bridge)\n\t\t\t\tc.Set(MappedModelContextKey, mappedModel)\n\t\t\t\tresolvedModel = mappedModel\n\t\t\t\tusedMapping = true\n\t\t\t\tproviders = mappedProviders\n\t\t\t}\n\n\t\t\t// If no mapping applied, check for local providers\n\t\t\tif !usedMapping {\n\t\t\t\tproviders = util.GetProviderName(normalizedModel)\n\t\t\t}\n\t\t} else {\n\t\t\t// DEFAULT MODE: Check local providers first, then mappings as fallback\n\t\t\tproviders = util.GetProviderName(normalizedModel)\n\n\t\t\tif len(providers) == 0 {\n\t\t\t\t// No providers configured - check if we have a model mapping\n\t\t\t\tif mappedModel, mappedProviders := resolveMappedModel(); mappedModel != \"\" {\n\t\t\t\t\t// Mapping found and provider available - rewrite the model in request body\n\t\t\t\t\tbodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)\n\t\t\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\t\t\t// Store mapped model in context for handlers that check it (like gemini bridge)\n\t\t\t\t\tc.Set(MappedModelContextKey, mappedModel)\n\t\t\t\t\tresolvedModel = mappedModel\n\t\t\t\t\tusedMapping = true\n\t\t\t\t\tproviders = mappedProviders\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// If no providers available, fallback to ampcode.com\n\t\tif len(providers) == 0 {\n\t\t\tproxy := fh.getProxy()\n\t\t\tif proxy != nil {\n\t\t\t\t// Log: Forwarding to ampcode.com (uses Amp credits)\n\t\t\t\tlogAmpRouting(RouteTypeAmpCredits, modelName, \"\", \"\", requestPath)\n\n\t\t\t\t// Restore body again for the proxy\n\t\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\n\t\t\t\t// Forward to ampcode.com\n\t\t\t\tproxy.ServeHTTP(c.Writer, c.Request)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// No proxy available, let the normal handler return the error\n\t\t\tlogAmpRouting(RouteTypeNoProvider, modelName, \"\", \"\", requestPath)\n\t\t}\n\n\t\t// Log the routing decision\n\t\tproviderName := \"\"\n\t\tif len(providers) > 0 {\n\t\t\tproviderName = providers[0]\n\t\t}\n\n\t\tif usedMapping {\n\t\t\t// Log: Model was mapped to another model\n\t\t\tlog.Debugf(\"amp model mapping: request %s -> %s\", normalizedModel, resolvedModel)\n\t\t\tlogAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)\n\t\t\trewriter := NewResponseRewriter(c.Writer, modelName)\n\t\t\tc.Writer = rewriter\n\t\t\t// Filter Anthropic-Beta header only for local handling paths\n\t\t\tfilterAntropicBetaHeader(c)\n\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\thandler(c)\n\t\t\trewriter.Flush()\n\t\t\tlog.Debugf(\"amp model mapping: response %s -> %s\", resolvedModel, modelName)\n\t\t} else if len(providers) > 0 {\n\t\t\t// Log: Using local provider (free)\n\t\t\tlogAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)\n\t\t\t// Filter Anthropic-Beta header only for local handling paths\n\t\t\tfilterAntropicBetaHeader(c)\n\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\thandler(c)\n\t\t} else {\n\t\t\t// No provider, no mapping, no proxy: fall back to the wrapped handler so it can return an error response\n\t\t\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\thandler(c)\n\t\t}\n\t}\n}\n\n// filterAntropicBetaHeader filters Anthropic-Beta header to remove features requiring special subscription\n// This is needed when using local providers (bypassing the Amp proxy)\nfunc filterAntropicBetaHeader(c *gin.Context) {\n\tif betaHeader := c.Request.Header.Get(\"Anthropic-Beta\"); betaHeader != \"\" {\n\t\tif filtered := filterBetaFeatures(betaHeader, \"context-1m-2025-08-07\"); filtered != \"\" {\n\t\t\tc.Request.Header.Set(\"Anthropic-Beta\", filtered)\n\t\t} else {\n\t\t\tc.Request.Header.Del(\"Anthropic-Beta\")\n\t\t}\n\t}\n}\n\n// rewriteModelInRequest replaces the model name in a JSON request body\nfunc rewriteModelInRequest(body []byte, newModel string) []byte {\n\tif !gjson.GetBytes(body, \"model\").Exists() {\n\t\treturn body\n\t}\n\tresult, err := sjson.SetBytes(body, \"model\", newModel)\n\tif err != nil {\n\t\tlog.Warnf(\"amp model mapping: failed to rewrite model in request body: %v\", err)\n\t\treturn body\n\t}\n\treturn result\n}\n\n// extractModelFromRequest attempts to extract the model name from various request formats\nfunc extractModelFromRequest(body []byte, c *gin.Context) string {\n\t// First try to parse from JSON body (OpenAI, Claude, etc.)\n\t// Check common model field names\n\tif result := gjson.GetBytes(body, \"model\"); result.Exists() && result.Type == gjson.String {\n\t\treturn result.String()\n\t}\n\n\t// For Gemini requests, model is in the URL path\n\t// Standard format: /models/{model}:generateContent -> :action parameter\n\tif action := c.Param(\"action\"); action != \"\" {\n\t\t// Split by colon to get model name (e.g., \"gemini-pro:generateContent\" -> \"gemini-pro\")\n\t\tparts := strings.Split(action, \":\")\n\t\tif len(parts) > 0 && parts[0] != \"\" {\n\t\t\treturn parts[0]\n\t\t}\n\t}\n\n\t// AMP CLI format: /publishers/google/models/{model}:method -> *path parameter\n\t// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent\n\tif path := c.Param(\"path\"); path != \"\" {\n\t\t// Look for /models/{model}:method pattern\n\t\tif idx := strings.Index(path, \"/models/\"); idx >= 0 {\n\t\t\tmodelPart := path[idx+8:] // Skip \"/models/\"\n\t\t\t// Split by colon to get model name\n\t\t\tif colonIdx := strings.Index(modelPart, \":\"); colonIdx > 0 {\n\t\t\t\treturn modelPart[:colonIdx]\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/api/modules/amp/fallback_handlers_test.go",
    "content": "package amp\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/http/httputil\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n)\n\nfunc TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client-amp-fallback\", \"codex\", []*registry.ModelInfo{\n\t\t{ID: \"test/gpt-5.2\", OwnedBy: \"openai\", Type: \"codex\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-amp-fallback\")\n\n\tmapper := NewModelMapper([]config.AmpModelMapping{\n\t\t{From: \"gpt-5.2\", To: \"test/gpt-5.2\"},\n\t})\n\n\tfallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, mapper, nil)\n\n\thandler := func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tModel string `json:\"model\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"model\":      req.Model,\n\t\t\t\"seen_model\": req.Model,\n\t\t})\n\t}\n\n\tr := gin.New()\n\tr.POST(\"/chat/completions\", fallback.WrapHandler(handler))\n\n\treqBody := []byte(`{\"model\":\"gpt-5.2(xhigh)\"}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/chat/completions\", bytes.NewReader(reqBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"Expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tModel     string `json:\"model\"`\n\t\tSeenModel string `json:\"seen_model\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Failed to parse response JSON: %v\", err)\n\t}\n\n\tif resp.Model != \"gpt-5.2(xhigh)\" {\n\t\tt.Errorf(\"Expected response model gpt-5.2(xhigh), got %s\", resp.Model)\n\t}\n\tif resp.SeenModel != \"test/gpt-5.2(xhigh)\" {\n\t\tt.Errorf(\"Expected handler to see test/gpt-5.2(xhigh), got %s\", resp.SeenModel)\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/gemini_bridge.go",
    "content": "package amp\n\nimport (\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// createGeminiBridgeHandler creates a handler that bridges AMP CLI's non-standard Gemini paths\n// to our standard Gemini handler by rewriting the request context.\n//\n// AMP CLI format: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent\n// Standard format: /models/gemini-3-pro-preview:streamGenerateContent\n//\n// This extracts the model+method from the AMP path and sets it as the :action parameter\n// so the standard Gemini handler can process it.\n//\n// The handler parameter should be a Gemini-compatible handler that expects the :action param.\nfunc createGeminiBridgeHandler(handler gin.HandlerFunc) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Get the full path from the catch-all parameter\n\t\tpath := c.Param(\"path\")\n\n\t\t// Extract model:method from AMP CLI path format\n\t\t// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent\n\t\tconst modelsPrefix = \"/models/\"\n\t\tif idx := strings.Index(path, modelsPrefix); idx >= 0 {\n\t\t\t// Extract everything after modelsPrefix\n\t\t\tactionPart := path[idx+len(modelsPrefix):]\n\n\t\t\t// Check if model was mapped by FallbackHandler\n\t\t\tif mappedModel, exists := c.Get(MappedModelContextKey); exists {\n\t\t\t\tif strModel, ok := mappedModel.(string); ok && strModel != \"\" {\n\t\t\t\t\t// Replace the model part in the action\n\t\t\t\t\t// actionPart is like \"model-name:method\"\n\t\t\t\t\tif colonIdx := strings.Index(actionPart, \":\"); colonIdx > 0 {\n\t\t\t\t\t\tmethod := actionPart[colonIdx:] // \":method\"\n\t\t\t\t\t\tactionPart = strModel + method\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Set this as the :action parameter that the Gemini handler expects\n\t\t\tc.Params = append(c.Params, gin.Param{\n\t\t\t\tKey:   \"action\",\n\t\t\t\tValue: actionPart,\n\t\t\t})\n\n\t\t\t// Call the handler\n\t\t\thandler(c)\n\t\t\treturn\n\t\t}\n\n\t\t// If we can't parse the path, return 400\n\t\tc.JSON(400, gin.H{\n\t\t\t\"error\": \"Invalid Gemini API path format\",\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/gemini_bridge_test.go",
    "content": "package amp\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestCreateGeminiBridgeHandler_ActionParameterExtraction(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ttests := []struct {\n\t\tname           string\n\t\tpath           string\n\t\tmappedModel    string // empty string means no mapping\n\t\texpectedAction string\n\t}{\n\t\t{\n\t\t\tname:           \"no_mapping_uses_url_model\",\n\t\t\tpath:           \"/publishers/google/models/gemini-pro:generateContent\",\n\t\t\tmappedModel:    \"\",\n\t\t\texpectedAction: \"gemini-pro:generateContent\",\n\t\t},\n\t\t{\n\t\t\tname:           \"mapped_model_replaces_url_model\",\n\t\t\tpath:           \"/publishers/google/models/gemini-exp:generateContent\",\n\t\t\tmappedModel:    \"gemini-2.0-flash\",\n\t\t\texpectedAction: \"gemini-2.0-flash:generateContent\",\n\t\t},\n\t\t{\n\t\t\tname:           \"mapping_preserves_method\",\n\t\t\tpath:           \"/publishers/google/models/gemini-2.5-preview:streamGenerateContent\",\n\t\t\tmappedModel:    \"gemini-flash\",\n\t\t\texpectedAction: \"gemini-flash:streamGenerateContent\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar capturedAction string\n\n\t\t\tmockGeminiHandler := func(c *gin.Context) {\n\t\t\t\tcapturedAction = c.Param(\"action\")\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\"captured\": capturedAction})\n\t\t\t}\n\n\t\t\t// Use the actual createGeminiBridgeHandler function\n\t\t\tbridgeHandler := createGeminiBridgeHandler(mockGeminiHandler)\n\n\t\t\tr := gin.New()\n\t\t\tif tt.mappedModel != \"\" {\n\t\t\t\tr.Use(func(c *gin.Context) {\n\t\t\t\t\tc.Set(MappedModelContextKey, tt.mappedModel)\n\t\t\t\t\tc.Next()\n\t\t\t\t})\n\t\t\t}\n\t\t\tr.POST(\"/api/provider/google/v1beta1/*path\", bridgeHandler)\n\n\t\t\treq := httptest.NewRequest(http.MethodPost, \"/api/provider/google/v1beta1\"+tt.path, nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code != http.StatusOK {\n\t\t\t\tt.Fatalf(\"Expected status 200, got %d\", w.Code)\n\t\t\t}\n\t\t\tif capturedAction != tt.expectedAction {\n\t\t\t\tt.Errorf(\"Expected action '%s', got '%s'\", tt.expectedAction, capturedAction)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateGeminiBridgeHandler_InvalidPath(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tmockHandler := func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t}\n\tbridgeHandler := createGeminiBridgeHandler(mockHandler)\n\n\tr := gin.New()\n\tr.POST(\"/api/provider/google/v1beta1/*path\", bridgeHandler)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/provider/google/v1beta1/invalid/path\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusBadRequest {\n\t\tt.Errorf(\"Expected status 400 for invalid path, got %d\", w.Code)\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/model_mapping.go",
    "content": "// Package amp provides model mapping functionality for routing Amp CLI requests\n// to alternative models when the requested model is not available locally.\npackage amp\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ModelMapper provides model name mapping/aliasing for Amp CLI requests.\n// When an Amp request comes in for a model that isn't available locally,\n// this mapper can redirect it to an alternative model that IS available.\ntype ModelMapper interface {\n\t// MapModel returns the target model name if a mapping exists and the target\n\t// model has available providers. Returns empty string if no mapping applies.\n\tMapModel(requestedModel string) string\n\n\t// UpdateMappings refreshes the mapping configuration (for hot-reload).\n\tUpdateMappings(mappings []config.AmpModelMapping)\n}\n\n// DefaultModelMapper implements ModelMapper with thread-safe mapping storage.\ntype DefaultModelMapper struct {\n\tmu       sync.RWMutex\n\tmappings map[string]string // exact: from -> to (normalized lowercase keys)\n\tregexps  []regexMapping    // regex rules evaluated in order\n}\n\n// NewModelMapper creates a new model mapper with the given initial mappings.\nfunc NewModelMapper(mappings []config.AmpModelMapping) *DefaultModelMapper {\n\tm := &DefaultModelMapper{\n\t\tmappings: make(map[string]string),\n\t\tregexps:  nil,\n\t}\n\tm.UpdateMappings(mappings)\n\treturn m\n}\n\n// MapModel checks if a mapping exists for the requested model and if the\n// target model has available local providers. Returns the mapped model name\n// or empty string if no valid mapping exists.\n//\n// If the requested model contains a thinking suffix (e.g., \"g25p(8192)\"),\n// the suffix is preserved in the returned model name (e.g., \"gemini-2.5-pro(8192)\").\n// However, if the mapping target already contains a suffix, the config suffix\n// takes priority over the user's suffix.\nfunc (m *DefaultModelMapper) MapModel(requestedModel string) string {\n\tif requestedModel == \"\" {\n\t\treturn \"\"\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\t// Extract thinking suffix from requested model using ParseSuffix\n\trequestResult := thinking.ParseSuffix(requestedModel)\n\tbaseModel := requestResult.ModelName\n\n\t// Normalize the base model for lookup (case-insensitive)\n\tnormalizedBase := strings.ToLower(strings.TrimSpace(baseModel))\n\n\t// Check for direct mapping using base model name\n\ttargetModel, exists := m.mappings[normalizedBase]\n\tif !exists {\n\t\t// Try regex mappings in order using base model only\n\t\t// (suffix is handled separately via ParseSuffix)\n\t\tfor _, rm := range m.regexps {\n\t\t\tif rm.re.MatchString(baseModel) {\n\t\t\t\ttargetModel = rm.to\n\t\t\t\texists = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !exists {\n\t\t\treturn \"\"\n\t\t}\n\t}\n\n\t// Check if target model already has a thinking suffix (config priority)\n\ttargetResult := thinking.ParseSuffix(targetModel)\n\n\t// Verify target model has available providers (use base model for lookup)\n\tproviders := util.GetProviderName(targetResult.ModelName)\n\tif len(providers) == 0 {\n\t\tlog.Debugf(\"amp model mapping: target model %s has no available providers, skipping mapping\", targetModel)\n\t\treturn \"\"\n\t}\n\n\t// Suffix handling: config suffix takes priority, otherwise preserve user suffix\n\tif targetResult.HasSuffix {\n\t\t// Config's \"to\" already contains a suffix - use it as-is (config priority)\n\t\treturn targetModel\n\t}\n\n\t// Preserve user's thinking suffix on the mapped model\n\t// (skip empty suffixes to avoid returning \"model()\")\n\tif requestResult.HasSuffix && requestResult.RawSuffix != \"\" {\n\t\treturn targetModel + \"(\" + requestResult.RawSuffix + \")\"\n\t}\n\n\t// Note: Detailed routing log is handled by logAmpRouting in fallback_handlers.go\n\treturn targetModel\n}\n\n// UpdateMappings refreshes the mapping configuration from config.\n// This is called during initialization and on config hot-reload.\nfunc (m *DefaultModelMapper) UpdateMappings(mappings []config.AmpModelMapping) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\t// Clear and rebuild mappings\n\tm.mappings = make(map[string]string, len(mappings))\n\tm.regexps = make([]regexMapping, 0, len(mappings))\n\n\tfor _, mapping := range mappings {\n\t\tfrom := strings.TrimSpace(mapping.From)\n\t\tto := strings.TrimSpace(mapping.To)\n\n\t\tif from == \"\" || to == \"\" {\n\t\t\tlog.Warnf(\"amp model mapping: skipping invalid mapping (from=%q, to=%q)\", from, to)\n\t\t\tcontinue\n\t\t}\n\n\t\tif mapping.Regex {\n\t\t\t// Compile case-insensitive regex; wrap with (?i) to match behavior of exact lookups\n\t\t\tpattern := \"(?i)\" + from\n\t\t\tre, err := regexp.Compile(pattern)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"amp model mapping: invalid regex %q: %v\", from, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tm.regexps = append(m.regexps, regexMapping{re: re, to: to})\n\t\t\tlog.Debugf(\"amp model regex mapping registered: /%s/ -> %s\", from, to)\n\t\t} else {\n\t\t\t// Store with normalized lowercase key for case-insensitive lookup\n\t\t\tnormalizedFrom := strings.ToLower(from)\n\t\t\tm.mappings[normalizedFrom] = to\n\t\t\tlog.Debugf(\"amp model mapping registered: %s -> %s\", from, to)\n\t\t}\n\t}\n\n\tif len(m.mappings) > 0 {\n\t\tlog.Infof(\"amp model mapping: loaded %d mapping(s)\", len(m.mappings))\n\t}\n\tif n := len(m.regexps); n > 0 {\n\t\tlog.Infof(\"amp model mapping: loaded %d regex mapping(s)\", n)\n\t}\n}\n\n// GetMappings returns a copy of current mappings (for debugging/status).\nfunc (m *DefaultModelMapper) GetMappings() map[string]string {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make(map[string]string, len(m.mappings))\n\tfor k, v := range m.mappings {\n\t\tresult[k] = v\n\t}\n\treturn result\n}\n\ntype regexMapping struct {\n\tre *regexp.Regexp\n\tto string\n}\n"
  },
  {
    "path": "internal/api/modules/amp/model_mapping_test.go",
    "content": "package amp\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n)\n\nfunc TestNewModelMapper(t *testing.T) {\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"claude-opus-4.5\", To: \"claude-sonnet-4\"},\n\t\t{From: \"gpt-5\", To: \"gemini-2.5-pro\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\tif mapper == nil {\n\t\tt.Fatal(\"Expected non-nil mapper\")\n\t}\n\n\tresult := mapper.GetMappings()\n\tif len(result) != 2 {\n\t\tt.Errorf(\"Expected 2 mappings, got %d\", len(result))\n\t}\n}\n\nfunc TestNewModelMapper_Empty(t *testing.T) {\n\tmapper := NewModelMapper(nil)\n\tif mapper == nil {\n\t\tt.Fatal(\"Expected non-nil mapper\")\n\t}\n\n\tresult := mapper.GetMappings()\n\tif len(result) != 0 {\n\t\tt.Errorf(\"Expected 0 mappings, got %d\", len(result))\n\t}\n}\n\nfunc TestModelMapper_MapModel_NoProvider(t *testing.T) {\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"claude-opus-4.5\", To: \"claude-sonnet-4\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Without a registered provider for the target, mapping should return empty\n\tresult := mapper.MapModel(\"claude-opus-4.5\")\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty result when target has no provider, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_MapModel_WithProvider(t *testing.T) {\n\t// Register a mock provider for the target model\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4\", OwnedBy: \"anthropic\", Type: \"claude\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"claude-opus-4.5\", To: \"claude-sonnet-4\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// With a registered provider, mapping should work\n\tresult := mapper.MapModel(\"claude-opus-4.5\")\n\tif result != \"claude-sonnet-4\" {\n\t\tt.Errorf(\"Expected claude-sonnet-4, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_MapModel_TargetWithThinkingSuffix(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client-thinking\", \"codex\", []*registry.ModelInfo{\n\t\t{ID: \"gpt-5.2\", OwnedBy: \"openai\", Type: \"codex\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-thinking\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"gpt-5.2-alias\", To: \"gpt-5.2(xhigh)\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\tresult := mapper.MapModel(\"gpt-5.2-alias\")\n\tif result != \"gpt-5.2(xhigh)\" {\n\t\tt.Errorf(\"Expected gpt-5.2(xhigh), got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_MapModel_CaseInsensitive(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client2\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4\", OwnedBy: \"anthropic\", Type: \"claude\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client2\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"Claude-Opus-4.5\", To: \"claude-sonnet-4\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Should match case-insensitively\n\tresult := mapper.MapModel(\"claude-opus-4.5\")\n\tif result != \"claude-sonnet-4\" {\n\t\tt.Errorf(\"Expected claude-sonnet-4, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_MapModel_NotFound(t *testing.T) {\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"claude-opus-4.5\", To: \"claude-sonnet-4\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Unknown model should return empty\n\tresult := mapper.MapModel(\"unknown-model\")\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty for unknown model, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_MapModel_EmptyInput(t *testing.T) {\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"claude-opus-4.5\", To: \"claude-sonnet-4\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\tresult := mapper.MapModel(\"\")\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty for empty input, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_UpdateMappings(t *testing.T) {\n\tmapper := NewModelMapper(nil)\n\n\t// Initially empty\n\tif len(mapper.GetMappings()) != 0 {\n\t\tt.Error(\"Expected 0 initial mappings\")\n\t}\n\n\t// Update with new mappings\n\tmapper.UpdateMappings([]config.AmpModelMapping{\n\t\t{From: \"model-a\", To: \"model-b\"},\n\t\t{From: \"model-c\", To: \"model-d\"},\n\t})\n\n\tresult := mapper.GetMappings()\n\tif len(result) != 2 {\n\t\tt.Errorf(\"Expected 2 mappings after update, got %d\", len(result))\n\t}\n\n\t// Update again should replace, not append\n\tmapper.UpdateMappings([]config.AmpModelMapping{\n\t\t{From: \"model-x\", To: \"model-y\"},\n\t})\n\n\tresult = mapper.GetMappings()\n\tif len(result) != 1 {\n\t\tt.Errorf(\"Expected 1 mapping after second update, got %d\", len(result))\n\t}\n}\n\nfunc TestModelMapper_UpdateMappings_SkipsInvalid(t *testing.T) {\n\tmapper := NewModelMapper(nil)\n\n\tmapper.UpdateMappings([]config.AmpModelMapping{\n\t\t{From: \"\", To: \"model-b\"},        // Invalid: empty from\n\t\t{From: \"model-a\", To: \"\"},        // Invalid: empty to\n\t\t{From: \"  \", To: \"model-b\"},      // Invalid: whitespace from\n\t\t{From: \"model-c\", To: \"model-d\"}, // Valid\n\t})\n\n\tresult := mapper.GetMappings()\n\tif len(result) != 1 {\n\t\tt.Errorf(\"Expected 1 valid mapping, got %d\", len(result))\n\t}\n}\n\nfunc TestModelMapper_GetMappings_ReturnsCopy(t *testing.T) {\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"model-a\", To: \"model-b\"},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Get mappings and modify the returned map\n\tresult := mapper.GetMappings()\n\tresult[\"new-key\"] = \"new-value\"\n\n\t// Original should be unchanged\n\toriginal := mapper.GetMappings()\n\tif len(original) != 1 {\n\t\tt.Errorf(\"Expected original to have 1 mapping, got %d\", len(original))\n\t}\n\tif _, exists := original[\"new-key\"]; exists {\n\t\tt.Error(\"Original map was modified\")\n\t}\n}\n\nfunc TestModelMapper_Regex_MatchBaseWithoutParens(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client-regex-1\", \"gemini\", []*registry.ModelInfo{\n\t\t{ID: \"gemini-2.5-pro\", OwnedBy: \"google\", Type: \"gemini\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-regex-1\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"^gpt-5$\", To: \"gemini-2.5-pro\", Regex: true},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Incoming model has reasoning suffix, regex matches base, suffix is preserved\n\tresult := mapper.MapModel(\"gpt-5(high)\")\n\tif result != \"gemini-2.5-pro(high)\" {\n\t\tt.Errorf(\"Expected gemini-2.5-pro(high), got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_Regex_ExactPrecedence(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client-regex-2\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4\", OwnedBy: \"anthropic\", Type: \"claude\"},\n\t})\n\treg.RegisterClient(\"test-client-regex-3\", \"gemini\", []*registry.ModelInfo{\n\t\t{ID: \"gemini-2.5-pro\", OwnedBy: \"google\", Type: \"gemini\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-regex-2\")\n\tdefer reg.UnregisterClient(\"test-client-regex-3\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"gpt-5\", To: \"claude-sonnet-4\"},                 // exact\n\t\t{From: \"^gpt-5.*$\", To: \"gemini-2.5-pro\", Regex: true}, // regex\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\t// Exact match should win over regex\n\tresult := mapper.MapModel(\"gpt-5\")\n\tif result != \"claude-sonnet-4\" {\n\t\tt.Errorf(\"Expected claude-sonnet-4, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_Regex_InvalidPattern_Skipped(t *testing.T) {\n\t// Invalid regex should be skipped and not cause panic\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"(\", To: \"target\", Regex: true},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\tresult := mapper.MapModel(\"anything\")\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty result due to invalid regex, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_Regex_CaseInsensitive(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"test-client-regex-4\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4\", OwnedBy: \"anthropic\", Type: \"claude\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-regex-4\")\n\n\tmappings := []config.AmpModelMapping{\n\t\t{From: \"^CLAUDE-OPUS-.*$\", To: \"claude-sonnet-4\", Regex: true},\n\t}\n\n\tmapper := NewModelMapper(mappings)\n\n\tresult := mapper.MapModel(\"claude-opus-4.5\")\n\tif result != \"claude-sonnet-4\" {\n\t\tt.Errorf(\"Expected claude-sonnet-4, got %s\", result)\n\t}\n}\n\nfunc TestModelMapper_SuffixPreservation(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\n\t// Register test models\n\treg.RegisterClient(\"test-client-suffix\", \"gemini\", []*registry.ModelInfo{\n\t\t{ID: \"gemini-2.5-pro\", OwnedBy: \"google\", Type: \"gemini\"},\n\t})\n\treg.RegisterClient(\"test-client-suffix-2\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4\", OwnedBy: \"anthropic\", Type: \"claude\"},\n\t})\n\tdefer reg.UnregisterClient(\"test-client-suffix\")\n\tdefer reg.UnregisterClient(\"test-client-suffix-2\")\n\n\ttests := []struct {\n\t\tname     string\n\t\tmappings []config.AmpModelMapping\n\t\tinput    string\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname:     \"numeric suffix preserved\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(8192)\",\n\t\t\twant:     \"gemini-2.5-pro(8192)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"level suffix preserved\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(high)\",\n\t\t\twant:     \"gemini-2.5-pro(high)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"no suffix unchanged\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p\",\n\t\t\twant:     \"gemini-2.5-pro\",\n\t\t},\n\t\t{\n\t\t\tname:     \"config suffix takes priority\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"alias\", To: \"gemini-2.5-pro(medium)\"}},\n\t\t\tinput:    \"alias(high)\",\n\t\t\twant:     \"gemini-2.5-pro(medium)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"regex with suffix preserved\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"^g25.*\", To: \"gemini-2.5-pro\", Regex: true}},\n\t\t\tinput:    \"g25p(8192)\",\n\t\t\twant:     \"gemini-2.5-pro(8192)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"auto suffix preserved\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(auto)\",\n\t\t\twant:     \"gemini-2.5-pro(auto)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"none suffix preserved\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(none)\",\n\t\t\twant:     \"gemini-2.5-pro(none)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"case insensitive base lookup with suffix\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"G25P\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(high)\",\n\t\t\twant:     \"gemini-2.5-pro(high)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty suffix filtered out\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p()\",\n\t\t\twant:     \"gemini-2.5-pro\",\n\t\t},\n\t\t{\n\t\t\tname:     \"incomplete suffix treated as no suffix\",\n\t\t\tmappings: []config.AmpModelMapping{{From: \"g25p(high\", To: \"gemini-2.5-pro\"}},\n\t\t\tinput:    \"g25p(high\",\n\t\t\twant:     \"gemini-2.5-pro\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmapper := NewModelMapper(tt.mappings)\n\t\t\tgot := mapper.MapModel(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"MapModel(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/proxy.go",
    "content": "package amp\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nfunc removeQueryValuesMatching(req *http.Request, key string, match string) {\n\tif req == nil || req.URL == nil || match == \"\" {\n\t\treturn\n\t}\n\n\tq := req.URL.Query()\n\tvalues, ok := q[key]\n\tif !ok || len(values) == 0 {\n\t\treturn\n\t}\n\n\tkept := make([]string, 0, len(values))\n\tfor _, v := range values {\n\t\tif v == match {\n\t\t\tcontinue\n\t\t}\n\t\tkept = append(kept, v)\n\t}\n\n\tif len(kept) == 0 {\n\t\tq.Del(key)\n\t} else {\n\t\tq[key] = kept\n\t}\n\treq.URL.RawQuery = q.Encode()\n}\n\n// readCloser wraps a reader and forwards Close to a separate closer.\n// Used to restore peeked bytes while preserving upstream body Close behavior.\ntype readCloser struct {\n\tr io.Reader\n\tc io.Closer\n}\n\nfunc (rc *readCloser) Read(p []byte) (int, error) { return rc.r.Read(p) }\nfunc (rc *readCloser) Close() error               { return rc.c.Close() }\n\n// createReverseProxy creates a reverse proxy handler for Amp upstream\n// with automatic gzip decompression via ModifyResponse\nfunc createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputil.ReverseProxy, error) {\n\tparsed, err := url.Parse(upstreamURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid amp upstream url: %w\", err)\n\t}\n\n\tproxy := httputil.NewSingleHostReverseProxy(parsed)\n\toriginalDirector := proxy.Director\n\n\t// Modify outgoing requests to inject API key and fix routing\n\tproxy.Director = func(req *http.Request) {\n\t\toriginalDirector(req)\n\t\treq.Host = parsed.Host\n\n\t\t// Remove client's Authorization header - it was only used for CLI Proxy API authentication\n\t\t// We will set our own Authorization using the configured upstream-api-key\n\t\treq.Header.Del(\"Authorization\")\n\t\treq.Header.Del(\"X-Api-Key\")\n\t\treq.Header.Del(\"X-Goog-Api-Key\")\n\n\t\t// Remove proxy, client identity, and browser fingerprint headers\n\t\tmisc.ScrubProxyAndFingerprintHeaders(req)\n\n\t\t// Remove query-based credentials if they match the authenticated client API key.\n\t\t// This prevents leaking client auth material to the Amp upstream while avoiding\n\t\t// breaking unrelated upstream query parameters.\n\t\tclientKey := getClientAPIKeyFromContext(req.Context())\n\t\tremoveQueryValuesMatching(req, \"key\", clientKey)\n\t\tremoveQueryValuesMatching(req, \"auth_token\", clientKey)\n\n\t\t// Preserve correlation headers for debugging\n\t\tif req.Header.Get(\"X-Request-ID\") == \"\" {\n\t\t\t// Could generate one here if needed\n\t\t}\n\n\t\t// Note: We do NOT filter Anthropic-Beta headers in the proxy path\n\t\t// Users going through ampcode.com proxy are paying for the service and should get all features\n\t\t// including 1M context window (context-1m-2025-08-07)\n\n\t\t// Inject API key from secret source (only uses upstream-api-key from config)\n\t\tif key, err := secretSource.Get(req.Context()); err == nil && key != \"\" {\n\t\t\treq.Header.Set(\"X-Api-Key\", key)\n\t\t\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", key))\n\t\t} else if err != nil {\n\t\t\tlog.Warnf(\"amp secret source error (continuing without auth): %v\", err)\n\t\t}\n\t}\n\n\t// Modify incoming responses to handle gzip without Content-Encoding\n\t// This addresses the same issue as inline handler gzip handling, but at the proxy level\n\tproxy.ModifyResponse = func(resp *http.Response) error {\n\t\t// Only process successful responses\n\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Skip if already marked as gzip (Content-Encoding set)\n\t\tif resp.Header.Get(\"Content-Encoding\") != \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Skip streaming responses (SSE, chunked)\n\t\tif isStreamingResponse(resp) {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Save reference to original upstream body for proper cleanup\n\t\toriginalBody := resp.Body\n\n\t\t// Peek at first 2 bytes to detect gzip magic bytes\n\t\theader := make([]byte, 2)\n\t\tn, _ := io.ReadFull(originalBody, header)\n\n\t\t// Check for gzip magic bytes (0x1f 0x8b)\n\t\t// If n < 2, we didn't get enough bytes, so it's not gzip\n\t\tif n >= 2 && header[0] == 0x1f && header[1] == 0x8b {\n\t\t\t// It's gzip - read the rest of the body\n\t\t\trest, err := io.ReadAll(originalBody)\n\t\t\tif err != nil {\n\t\t\t\t// Restore what we read and return original body (preserve Close behavior)\n\t\t\t\tresp.Body = &readCloser{\n\t\t\t\t\tr: io.MultiReader(bytes.NewReader(header[:n]), originalBody),\n\t\t\t\t\tc: originalBody,\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// Reconstruct complete gzipped data\n\t\t\tgzippedData := append(header[:n], rest...)\n\n\t\t\t// Decompress\n\t\t\tgzipReader, err := gzip.NewReader(bytes.NewReader(gzippedData))\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"amp proxy: gzip header detected but decompress failed: %v\", err)\n\t\t\t\t// Close original body and return in-memory copy\n\t\t\t\t_ = originalBody.Close()\n\t\t\t\tresp.Body = io.NopCloser(bytes.NewReader(gzippedData))\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tdecompressed, err := io.ReadAll(gzipReader)\n\t\t\t_ = gzipReader.Close()\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"amp proxy: gzip decompress error: %v\", err)\n\t\t\t\t// Close original body and return in-memory copy\n\t\t\t\t_ = originalBody.Close()\n\t\t\t\tresp.Body = io.NopCloser(bytes.NewReader(gzippedData))\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// Close original body since we're replacing with in-memory decompressed content\n\t\t\t_ = originalBody.Close()\n\n\t\t\t// Replace body with decompressed content\n\t\t\tresp.Body = io.NopCloser(bytes.NewReader(decompressed))\n\t\t\tresp.ContentLength = int64(len(decompressed))\n\n\t\t\t// Update headers to reflect decompressed state\n\t\t\tresp.Header.Del(\"Content-Encoding\")                                          // No longer compressed\n\t\t\tresp.Header.Del(\"Content-Length\")                                            // Remove stale compressed length\n\t\t\tresp.Header.Set(\"Content-Length\", strconv.FormatInt(resp.ContentLength, 10)) // Set decompressed length\n\n\t\t\tlog.Debugf(\"amp proxy: decompressed gzip response (%d -> %d bytes)\", len(gzippedData), len(decompressed))\n\t\t} else {\n\t\t\t// Not gzip - restore peeked bytes while preserving Close behavior\n\t\t\t// Handle edge cases: n might be 0, 1, or 2 depending on EOF\n\t\t\tresp.Body = &readCloser{\n\t\t\t\tr: io.MultiReader(bytes.NewReader(header[:n]), originalBody),\n\t\t\t\tc: originalBody,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// Error handler for proxy failures\n\tproxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {\n\t\t// Client-side cancellations are common during polling; suppress logging in this case\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tlog.Errorf(\"amp upstream proxy error for %s %s: %v\", req.Method, req.URL.Path, err)\n\t\trw.Header().Set(\"Content-Type\", \"application/json\")\n\t\trw.WriteHeader(http.StatusBadGateway)\n\t\t_, _ = rw.Write([]byte(`{\"error\":\"amp_upstream_proxy_error\",\"message\":\"Failed to reach Amp upstream\"}`))\n\t}\n\n\treturn proxy, nil\n}\n\n// isStreamingResponse detects if the response is streaming (SSE only)\n// Note: We only treat text/event-stream as streaming. Chunked transfer encoding\n// is a transport-level detail and doesn't mean we can't decompress the full response.\n// Many JSON APIs use chunked encoding for normal responses.\nfunc isStreamingResponse(resp *http.Response) bool {\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\n\t// Only Server-Sent Events are true streaming responses\n\tif strings.Contains(contentType, \"text/event-stream\") {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// proxyHandler converts httputil.ReverseProxy to gin.HandlerFunc\nfunc proxyHandler(proxy *httputil.ReverseProxy) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tproxy.ServeHTTP(c.Writer, c.Request)\n\t}\n}\n\n// filterBetaFeatures removes a specific beta feature from comma-separated list\nfunc filterBetaFeatures(header, featureToRemove string) string {\n\tfeatures := strings.Split(header, \",\")\n\tfiltered := make([]string, 0, len(features))\n\n\tfor _, feature := range features {\n\t\ttrimmed := strings.TrimSpace(feature)\n\t\tif trimmed != \"\" && trimmed != featureToRemove {\n\t\t\tfiltered = append(filtered, trimmed)\n\t\t}\n\t}\n\n\treturn strings.Join(filtered, \",\")\n}\n"
  },
  {
    "path": "internal/api/modules/amp/proxy_test.go",
    "content": "package amp\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// Helper: compress data with gzip\nfunc gzipBytes(b []byte) []byte {\n\tvar buf bytes.Buffer\n\tzw := gzip.NewWriter(&buf)\n\tzw.Write(b)\n\tzw.Close()\n\treturn buf.Bytes()\n}\n\n// Helper: create a mock http.Response\nfunc mkResp(status int, hdr http.Header, body []byte) *http.Response {\n\tif hdr == nil {\n\t\thdr = http.Header{}\n\t}\n\treturn &http.Response{\n\t\tStatusCode:    status,\n\t\tHeader:        hdr,\n\t\tBody:          io.NopCloser(bytes.NewReader(body)),\n\t\tContentLength: int64(len(body)),\n\t}\n}\n\nfunc TestCreateReverseProxy_ValidURL(t *testing.T) {\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"key\"))\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got: %v\", err)\n\t}\n\tif proxy == nil {\n\t\tt.Fatal(\"expected proxy to be created\")\n\t}\n}\n\nfunc TestCreateReverseProxy_InvalidURL(t *testing.T) {\n\t_, err := createReverseProxy(\"://invalid\", NewStaticSecretSource(\"key\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid URL\")\n\t}\n}\n\nfunc TestModifyResponse_GzipScenarios(t *testing.T) {\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"k\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgoodJSON := []byte(`{\"ok\":true}`)\n\tgood := gzipBytes(goodJSON)\n\ttruncated := good[:10]\n\tcorrupted := append([]byte{0x1f, 0x8b}, []byte(\"notgzip\")...)\n\n\tcases := []struct {\n\t\tname     string\n\t\theader   http.Header\n\t\tbody     []byte\n\t\tstatus   int\n\t\twantBody []byte\n\t\twantCE   string\n\t}{\n\t\t{\n\t\t\tname:     \"decompresses_valid_gzip_no_header\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     good,\n\t\t\tstatus:   200,\n\t\t\twantBody: goodJSON,\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"skips_when_ce_present\",\n\t\t\theader:   http.Header{\"Content-Encoding\": []string{\"gzip\"}},\n\t\t\tbody:     good,\n\t\t\tstatus:   200,\n\t\t\twantBody: good,\n\t\t\twantCE:   \"gzip\",\n\t\t},\n\t\t{\n\t\t\tname:     \"passes_truncated_unchanged\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     truncated,\n\t\t\tstatus:   200,\n\t\t\twantBody: truncated,\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"passes_corrupted_unchanged\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     corrupted,\n\t\t\tstatus:   200,\n\t\t\twantBody: corrupted,\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"non_gzip_unchanged\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     []byte(\"plain\"),\n\t\t\tstatus:   200,\n\t\t\twantBody: []byte(\"plain\"),\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty_body\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     []byte{},\n\t\t\tstatus:   200,\n\t\t\twantBody: []byte{},\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"single_byte_body\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     []byte{0x1f},\n\t\t\tstatus:   200,\n\t\t\twantBody: []byte{0x1f},\n\t\t\twantCE:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"skips_non_2xx_status\",\n\t\t\theader:   http.Header{},\n\t\t\tbody:     good,\n\t\t\tstatus:   404,\n\t\t\twantBody: good,\n\t\t\twantCE:   \"\",\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := mkResp(tc.status, tc.header, tc.body)\n\t\t\tif err := proxy.ModifyResponse(resp); err != nil {\n\t\t\t\tt.Fatalf(\"ModifyResponse error: %v\", err)\n\t\t\t}\n\t\t\tgot, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"ReadAll error: %v\", err)\n\t\t\t}\n\t\t\tif !bytes.Equal(got, tc.wantBody) {\n\t\t\t\tt.Fatalf(\"body mismatch:\\nwant: %q\\ngot:  %q\", tc.wantBody, got)\n\t\t\t}\n\t\t\tif ce := resp.Header.Get(\"Content-Encoding\"); ce != tc.wantCE {\n\t\t\t\tt.Fatalf(\"Content-Encoding: want %q, got %q\", tc.wantCE, ce)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestModifyResponse_UpdatesContentLengthHeader(t *testing.T) {\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"k\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgoodJSON := []byte(`{\"message\":\"test response\"}`)\n\tgzipped := gzipBytes(goodJSON)\n\n\t// Simulate upstream response with gzip body AND Content-Length header\n\t// (this is the scenario the bot flagged - stale Content-Length after decompression)\n\tresp := mkResp(200, http.Header{\n\t\t\"Content-Length\": []string{fmt.Sprintf(\"%d\", len(gzipped))}, // Compressed size\n\t}, gzipped)\n\n\tif err := proxy.ModifyResponse(resp); err != nil {\n\t\tt.Fatalf(\"ModifyResponse error: %v\", err)\n\t}\n\n\t// Verify body is decompressed\n\tgot, _ := io.ReadAll(resp.Body)\n\tif !bytes.Equal(got, goodJSON) {\n\t\tt.Fatalf(\"body should be decompressed, got: %q, want: %q\", got, goodJSON)\n\t}\n\n\t// Verify Content-Length header is updated to decompressed size\n\twantCL := fmt.Sprintf(\"%d\", len(goodJSON))\n\tgotCL := resp.Header.Get(\"Content-Length\")\n\tif gotCL != wantCL {\n\t\tt.Fatalf(\"Content-Length header mismatch: want %q (decompressed), got %q\", wantCL, gotCL)\n\t}\n\n\t// Verify struct field also matches\n\tif resp.ContentLength != int64(len(goodJSON)) {\n\t\tt.Fatalf(\"resp.ContentLength mismatch: want %d, got %d\", len(goodJSON), resp.ContentLength)\n\t}\n}\n\nfunc TestModifyResponse_SkipsStreamingResponses(t *testing.T) {\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"k\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgoodJSON := []byte(`{\"ok\":true}`)\n\tgzipped := gzipBytes(goodJSON)\n\n\tt.Run(\"sse_skips_decompression\", func(t *testing.T) {\n\t\tresp := mkResp(200, http.Header{\"Content-Type\": []string{\"text/event-stream\"}}, gzipped)\n\t\tif err := proxy.ModifyResponse(resp); err != nil {\n\t\t\tt.Fatalf(\"ModifyResponse error: %v\", err)\n\t\t}\n\t\t// SSE should NOT be decompressed\n\t\tgot, _ := io.ReadAll(resp.Body)\n\t\tif !bytes.Equal(got, gzipped) {\n\t\t\tt.Fatal(\"SSE response should not be decompressed\")\n\t\t}\n\t})\n}\n\nfunc TestModifyResponse_DecompressesChunkedJSON(t *testing.T) {\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"k\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgoodJSON := []byte(`{\"ok\":true}`)\n\tgzipped := gzipBytes(goodJSON)\n\n\tt.Run(\"chunked_json_decompresses\", func(t *testing.T) {\n\t\t// Chunked JSON responses (like thread APIs) should be decompressed\n\t\tresp := mkResp(200, http.Header{\"Transfer-Encoding\": []string{\"chunked\"}}, gzipped)\n\t\tif err := proxy.ModifyResponse(resp); err != nil {\n\t\t\tt.Fatalf(\"ModifyResponse error: %v\", err)\n\t\t}\n\t\t// Should decompress because it's not SSE\n\t\tgot, _ := io.ReadAll(resp.Body)\n\t\tif !bytes.Equal(got, goodJSON) {\n\t\t\tt.Fatalf(\"chunked JSON should be decompressed, got: %q, want: %q\", got, goodJSON)\n\t\t}\n\t})\n}\n\nfunc TestReverseProxy_InjectsHeaders(t *testing.T) {\n\tgotHeaders := make(chan http.Header, 1)\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotHeaders <- r.Header.Clone()\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`ok`))\n\t}))\n\tdefer upstream.Close()\n\n\tproxy, err := createReverseProxy(upstream.URL, NewStaticSecretSource(\"secret\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy.ServeHTTP(w, r)\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tres.Body.Close()\n\n\thdr := <-gotHeaders\n\tif hdr.Get(\"X-Api-Key\") != \"secret\" {\n\t\tt.Fatalf(\"X-Api-Key missing or wrong, got: %q\", hdr.Get(\"X-Api-Key\"))\n\t}\n\tif hdr.Get(\"Authorization\") != \"Bearer secret\" {\n\t\tt.Fatalf(\"Authorization missing or wrong, got: %q\", hdr.Get(\"Authorization\"))\n\t}\n}\n\nfunc TestReverseProxy_EmptySecret(t *testing.T) {\n\tgotHeaders := make(chan http.Header, 1)\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotHeaders <- r.Header.Clone()\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`ok`))\n\t}))\n\tdefer upstream.Close()\n\n\tproxy, err := createReverseProxy(upstream.URL, NewStaticSecretSource(\"\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy.ServeHTTP(w, r)\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tres.Body.Close()\n\n\thdr := <-gotHeaders\n\t// Should NOT inject headers when secret is empty\n\tif hdr.Get(\"X-Api-Key\") != \"\" {\n\t\tt.Fatalf(\"X-Api-Key should not be set, got: %q\", hdr.Get(\"X-Api-Key\"))\n\t}\n\tif authVal := hdr.Get(\"Authorization\"); authVal != \"\" && authVal != \"Bearer \" {\n\t\tt.Fatalf(\"Authorization should not be set, got: %q\", authVal)\n\t}\n}\n\nfunc TestReverseProxy_StripsClientCredentialsFromHeadersAndQuery(t *testing.T) {\n\ttype captured struct {\n\t\theaders http.Header\n\t\tquery   string\n\t}\n\tgot := make(chan captured, 1)\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgot <- captured{headers: r.Header.Clone(), query: r.URL.RawQuery}\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`ok`))\n\t}))\n\tdefer upstream.Close()\n\n\tproxy, err := createReverseProxy(upstream.URL, NewStaticSecretSource(\"upstream\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Simulate clientAPIKeyMiddleware injection (per-request)\n\t\tctx := context.WithValue(r.Context(), clientAPIKeyContextKey{}, \"client-key\")\n\t\tproxy.ServeHTTP(w, r.WithContext(ctx))\n\t}))\n\tdefer srv.Close()\n\n\treq, err := http.NewRequest(http.MethodGet, srv.URL+\"/test?key=client-key&key=keep&auth_token=client-key&foo=bar\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer client-key\")\n\treq.Header.Set(\"X-Api-Key\", \"client-key\")\n\treq.Header.Set(\"X-Goog-Api-Key\", \"client-key\")\n\n\tres, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tres.Body.Close()\n\n\tc := <-got\n\n\t// These are client-provided credentials and must not reach the upstream.\n\tif v := c.headers.Get(\"X-Goog-Api-Key\"); v != \"\" {\n\t\tt.Fatalf(\"X-Goog-Api-Key should be stripped, got: %q\", v)\n\t}\n\n\t// We inject upstream Authorization/X-Api-Key, so the client auth must not survive.\n\tif v := c.headers.Get(\"Authorization\"); v != \"Bearer upstream\" {\n\t\tt.Fatalf(\"Authorization should be upstream-injected, got: %q\", v)\n\t}\n\tif v := c.headers.Get(\"X-Api-Key\"); v != \"upstream\" {\n\t\tt.Fatalf(\"X-Api-Key should be upstream-injected, got: %q\", v)\n\t}\n\n\t// Query-based credentials should be stripped only when they match the authenticated client key.\n\t// Should keep unrelated values and parameters.\n\tif strings.Contains(c.query, \"auth_token=client-key\") || strings.Contains(c.query, \"key=client-key\") {\n\t\tt.Fatalf(\"query credentials should be stripped, got raw query: %q\", c.query)\n\t}\n\tif !strings.Contains(c.query, \"key=keep\") || !strings.Contains(c.query, \"foo=bar\") {\n\t\tt.Fatalf(\"expected query to keep non-credential params, got raw query: %q\", c.query)\n\t}\n}\n\nfunc TestReverseProxy_InjectsMappedSecret_FromRequestContext(t *testing.T) {\n\tgotHeaders := make(chan http.Header, 1)\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotHeaders <- r.Header.Clone()\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`ok`))\n\t}))\n\tdefer upstream.Close()\n\n\tdefaultSource := NewStaticSecretSource(\"default\")\n\tmapped := NewMappedSecretSource(defaultSource)\n\tmapped.UpdateMappings([]config.AmpUpstreamAPIKeyEntry{\n\t\t{\n\t\t\tUpstreamAPIKey: \"u1\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t})\n\n\tproxy, err := createReverseProxy(upstream.URL, mapped)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Simulate clientAPIKeyMiddleware injection (per-request)\n\t\tctx := context.WithValue(r.Context(), clientAPIKeyContextKey{}, \"k1\")\n\t\tproxy.ServeHTTP(w, r.WithContext(ctx))\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tres.Body.Close()\n\n\thdr := <-gotHeaders\n\tif hdr.Get(\"X-Api-Key\") != \"u1\" {\n\t\tt.Fatalf(\"X-Api-Key missing or wrong, got: %q\", hdr.Get(\"X-Api-Key\"))\n\t}\n\tif hdr.Get(\"Authorization\") != \"Bearer u1\" {\n\t\tt.Fatalf(\"Authorization missing or wrong, got: %q\", hdr.Get(\"Authorization\"))\n\t}\n}\n\nfunc TestReverseProxy_MappedSecret_FallsBackToDefault(t *testing.T) {\n\tgotHeaders := make(chan http.Header, 1)\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotHeaders <- r.Header.Clone()\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`ok`))\n\t}))\n\tdefer upstream.Close()\n\n\tdefaultSource := NewStaticSecretSource(\"default\")\n\tmapped := NewMappedSecretSource(defaultSource)\n\tmapped.UpdateMappings([]config.AmpUpstreamAPIKeyEntry{\n\t\t{\n\t\t\tUpstreamAPIKey: \"u1\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t})\n\n\tproxy, err := createReverseProxy(upstream.URL, mapped)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := context.WithValue(r.Context(), clientAPIKeyContextKey{}, \"k2\")\n\t\tproxy.ServeHTTP(w, r.WithContext(ctx))\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tres.Body.Close()\n\n\thdr := <-gotHeaders\n\tif hdr.Get(\"X-Api-Key\") != \"default\" {\n\t\tt.Fatalf(\"X-Api-Key fallback missing or wrong, got: %q\", hdr.Get(\"X-Api-Key\"))\n\t}\n\tif hdr.Get(\"Authorization\") != \"Bearer default\" {\n\t\tt.Fatalf(\"Authorization fallback missing or wrong, got: %q\", hdr.Get(\"Authorization\"))\n\t}\n}\n\nfunc TestReverseProxy_ErrorHandler(t *testing.T) {\n\t// Point proxy to a non-routable address to trigger error\n\tproxy, err := createReverseProxy(\"http://127.0.0.1:1\", NewStaticSecretSource(\"\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy.ServeHTTP(w, r)\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/any\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tbody, _ := io.ReadAll(res.Body)\n\tres.Body.Close()\n\n\tif res.StatusCode != http.StatusBadGateway {\n\t\tt.Fatalf(\"want 502, got %d\", res.StatusCode)\n\t}\n\tif !bytes.Contains(body, []byte(`\"amp_upstream_proxy_error\"`)) {\n\t\tt.Fatalf(\"unexpected body: %s\", body)\n\t}\n\tif ct := res.Header.Get(\"Content-Type\"); ct != \"application/json\" {\n\t\tt.Fatalf(\"content-type: want application/json, got %s\", ct)\n\t}\n}\n\nfunc TestReverseProxy_ErrorHandler_ContextCanceled(t *testing.T) {\n\t// Test that context.Canceled errors return 499 without generic error response\n\tproxy, err := createReverseProxy(\"http://example.com\", NewStaticSecretSource(\"\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Create a canceled context to trigger the cancellation path\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // Cancel immediately\n\n\treq := httptest.NewRequest(http.MethodGet, \"/test\", nil).WithContext(ctx)\n\trr := httptest.NewRecorder()\n\n\t// Directly invoke the ErrorHandler with context.Canceled\n\tproxy.ErrorHandler(rr, req, context.Canceled)\n\n\t// Body should be empty for canceled requests (no JSON error response)\n\tbody := rr.Body.Bytes()\n\tif len(body) > 0 {\n\t\tt.Fatalf(\"expected empty body for canceled context, got: %s\", body)\n\t}\n}\n\nfunc TestReverseProxy_FullRoundTrip_Gzip(t *testing.T) {\n\t// Upstream returns gzipped JSON without Content-Encoding header\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(200)\n\t\tw.Write(gzipBytes([]byte(`{\"upstream\":\"ok\"}`)))\n\t}))\n\tdefer upstream.Close()\n\n\tproxy, err := createReverseProxy(upstream.URL, NewStaticSecretSource(\"key\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy.ServeHTTP(w, r)\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tbody, _ := io.ReadAll(res.Body)\n\tres.Body.Close()\n\n\texpected := []byte(`{\"upstream\":\"ok\"}`)\n\tif !bytes.Equal(body, expected) {\n\t\tt.Fatalf(\"want decompressed JSON, got: %s\", body)\n\t}\n}\n\nfunc TestReverseProxy_FullRoundTrip_PlainJSON(t *testing.T) {\n\t// Upstream returns plain JSON\n\tupstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(`{\"plain\":\"json\"}`))\n\t}))\n\tdefer upstream.Close()\n\n\tproxy, err := createReverseProxy(upstream.URL, NewStaticSecretSource(\"key\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy.ServeHTTP(w, r)\n\t}))\n\tdefer srv.Close()\n\n\tres, err := http.Get(srv.URL + \"/test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tbody, _ := io.ReadAll(res.Body)\n\tres.Body.Close()\n\n\texpected := []byte(`{\"plain\":\"json\"}`)\n\tif !bytes.Equal(body, expected) {\n\t\tt.Fatalf(\"want plain JSON unchanged, got: %s\", body)\n\t}\n}\n\nfunc TestIsStreamingResponse(t *testing.T) {\n\tcases := []struct {\n\t\tname   string\n\t\theader http.Header\n\t\twant   bool\n\t}{\n\t\t{\n\t\t\tname:   \"sse\",\n\t\t\theader: http.Header{\"Content-Type\": []string{\"text/event-stream\"}},\n\t\t\twant:   true,\n\t\t},\n\t\t{\n\t\t\tname:   \"chunked_not_streaming\",\n\t\t\theader: http.Header{\"Transfer-Encoding\": []string{\"chunked\"}},\n\t\t\twant:   false, // Chunked is transport-level, not streaming\n\t\t},\n\t\t{\n\t\t\tname:   \"normal_json\",\n\t\t\theader: http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\twant:   false,\n\t\t},\n\t\t{\n\t\t\tname:   \"empty\",\n\t\t\theader: http.Header{},\n\t\t\twant:   false,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := &http.Response{Header: tc.header}\n\t\t\tgot := isStreamingResponse(resp)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Fatalf(\"want %v, got %v\", tc.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilterBetaFeatures(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\theader          string\n\t\tfeatureToRemove string\n\t\texpected        string\n\t}{\n\t\t{\n\t\t\tname:            \"Remove context-1m from middle\",\n\t\t\theader:          \"fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07,oauth-2025-04-20\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"fine-grained-tool-streaming-2025-05-14,oauth-2025-04-20\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Remove context-1m from start\",\n\t\t\theader:          \"context-1m-2025-08-07,fine-grained-tool-streaming-2025-05-14\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"fine-grained-tool-streaming-2025-05-14\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Remove context-1m from end\",\n\t\t\theader:          \"fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"fine-grained-tool-streaming-2025-05-14\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Feature not present\",\n\t\t\theader:          \"fine-grained-tool-streaming-2025-05-14,oauth-2025-04-20\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"fine-grained-tool-streaming-2025-05-14,oauth-2025-04-20\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Only feature to remove\",\n\t\t\theader:          \"context-1m-2025-08-07\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Empty header\",\n\t\t\theader:          \"\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Header with spaces\",\n\t\t\theader:          \"fine-grained-tool-streaming-2025-05-14, context-1m-2025-08-07 , oauth-2025-04-20\",\n\t\t\tfeatureToRemove: \"context-1m-2025-08-07\",\n\t\t\texpected:        \"fine-grained-tool-streaming-2025-05-14,oauth-2025-04-20\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := filterBetaFeatures(tt.header, tt.featureToRemove)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"filterBetaFeatures() = %q, want %q\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/response_rewriter.go",
    "content": "package amp\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ResponseRewriter wraps a gin.ResponseWriter to intercept and modify the response body\n// It's used to rewrite model names in responses when model mapping is used\ntype ResponseRewriter struct {\n\tgin.ResponseWriter\n\tbody          *bytes.Buffer\n\toriginalModel string\n\tisStreaming   bool\n}\n\n// NewResponseRewriter creates a new response rewriter for model name substitution\nfunc NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRewriter {\n\treturn &ResponseRewriter{\n\t\tResponseWriter: w,\n\t\tbody:           &bytes.Buffer{},\n\t\toriginalModel:  originalModel,\n\t}\n}\n\n// Write intercepts response writes and buffers them for model name replacement\nfunc (rw *ResponseRewriter) Write(data []byte) (int, error) {\n\t// Detect streaming on first write\n\tif rw.body.Len() == 0 && !rw.isStreaming {\n\t\tcontentType := rw.Header().Get(\"Content-Type\")\n\t\trw.isStreaming = strings.Contains(contentType, \"text/event-stream\") ||\n\t\t\tstrings.Contains(contentType, \"stream\")\n\t}\n\n\tif rw.isStreaming {\n\t\tn, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))\n\t\tif err == nil {\n\t\t\tif flusher, ok := rw.ResponseWriter.(http.Flusher); ok {\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\t\treturn n, err\n\t}\n\treturn rw.body.Write(data)\n}\n\n// Flush writes the buffered response with model names rewritten\nfunc (rw *ResponseRewriter) Flush() {\n\tif rw.isStreaming {\n\t\tif flusher, ok := rw.ResponseWriter.(http.Flusher); ok {\n\t\t\tflusher.Flush()\n\t\t}\n\t\treturn\n\t}\n\tif rw.body.Len() > 0 {\n\t\tif _, err := rw.ResponseWriter.Write(rw.rewriteModelInResponse(rw.body.Bytes())); err != nil {\n\t\t\tlog.Warnf(\"amp response rewriter: failed to write rewritten response: %v\", err)\n\t\t}\n\t}\n}\n\n// modelFieldPaths lists all JSON paths where model name may appear\nvar modelFieldPaths = []string{\"message.model\", \"model\", \"modelVersion\", \"response.model\", \"response.modelVersion\"}\n\n// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON\n// It also suppresses \"thinking\" blocks if \"tool_use\" is present to ensure Amp client compatibility\nfunc (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {\n\t// 1. Amp Compatibility: Suppress thinking blocks if tool use is detected\n\t// The Amp client struggles when both thinking and tool_use blocks are present\n\tif gjson.GetBytes(data, `content.#(type==\"tool_use\")`).Exists() {\n\t\tfiltered := gjson.GetBytes(data, `content.#(type!=\"thinking\")#`)\n\t\tif filtered.Exists() {\n\t\t\toriginalCount := gjson.GetBytes(data, \"content.#\").Int()\n\t\t\tfilteredCount := filtered.Get(\"#\").Int()\n\n\t\t\tif originalCount > filteredCount {\n\t\t\t\tvar err error\n\t\t\t\tdata, err = sjson.SetBytes(data, \"content\", filtered.Value())\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Warnf(\"Amp ResponseRewriter: failed to suppress thinking blocks: %v\", err)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Debugf(\"Amp ResponseRewriter: Suppressed %d thinking blocks due to tool usage\", originalCount-filteredCount)\n\t\t\t\t\t// Log the result for verification\n\t\t\t\t\tlog.Debugf(\"Amp ResponseRewriter: Resulting content: %s\", gjson.GetBytes(data, \"content\").String())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif rw.originalModel == \"\" {\n\t\treturn data\n\t}\n\tfor _, path := range modelFieldPaths {\n\t\tif gjson.GetBytes(data, path).Exists() {\n\t\t\tdata, _ = sjson.SetBytes(data, path, rw.originalModel)\n\t\t}\n\t}\n\treturn data\n}\n\n// rewriteStreamChunk rewrites model names in SSE stream chunks\nfunc (rw *ResponseRewriter) rewriteStreamChunk(chunk []byte) []byte {\n\tif rw.originalModel == \"\" {\n\t\treturn chunk\n\t}\n\n\t// SSE format: \"data: {json}\\n\\n\"\n\tlines := bytes.Split(chunk, []byte(\"\\n\"))\n\tfor i, line := range lines {\n\t\tif bytes.HasPrefix(line, []byte(\"data: \")) {\n\t\t\tjsonData := bytes.TrimPrefix(line, []byte(\"data: \"))\n\t\t\tif len(jsonData) > 0 && jsonData[0] == '{' {\n\t\t\t\t// Rewrite JSON in the data line\n\t\t\t\trewritten := rw.rewriteModelInResponse(jsonData)\n\t\t\t\tlines[i] = append([]byte(\"data: \"), rewritten...)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn bytes.Join(lines, []byte(\"\\n\"))\n}\n"
  },
  {
    "path": "internal/api/modules/amp/response_rewriter_test.go",
    "content": "package amp\n\nimport (\n\t\"testing\"\n)\n\nfunc TestRewriteModelInResponse_TopLevel(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tinput := []byte(`{\"id\":\"resp_1\",\"model\":\"gpt-5.3-codex\",\"output\":[]}`)\n\tresult := rw.rewriteModelInResponse(input)\n\n\texpected := `{\"id\":\"resp_1\",\"model\":\"gpt-5.2-codex\",\"output\":[]}`\n\tif string(result) != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, string(result))\n\t}\n}\n\nfunc TestRewriteModelInResponse_ResponseModel(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tinput := []byte(`{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.3-codex\",\"status\":\"completed\"}}`)\n\tresult := rw.rewriteModelInResponse(input)\n\n\texpected := `{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.2-codex\",\"status\":\"completed\"}}`\n\tif string(result) != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, string(result))\n\t}\n}\n\nfunc TestRewriteModelInResponse_ResponseCreated(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tinput := []byte(`{\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.3-codex\",\"status\":\"in_progress\"}}`)\n\tresult := rw.rewriteModelInResponse(input)\n\n\texpected := `{\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.2-codex\",\"status\":\"in_progress\"}}`\n\tif string(result) != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, string(result))\n\t}\n}\n\nfunc TestRewriteModelInResponse_NoModelField(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tinput := []byte(`{\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_1\",\"type\":\"message\"}}`)\n\tresult := rw.rewriteModelInResponse(input)\n\n\tif string(result) != string(input) {\n\t\tt.Errorf(\"expected no modification, got %s\", string(result))\n\t}\n}\n\nfunc TestRewriteModelInResponse_EmptyOriginalModel(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"\"}\n\n\tinput := []byte(`{\"model\":\"gpt-5.3-codex\"}`)\n\tresult := rw.rewriteModelInResponse(input)\n\n\tif string(result) != string(input) {\n\t\tt.Errorf(\"expected no modification when originalModel is empty, got %s\", string(result))\n\t}\n}\n\nfunc TestRewriteStreamChunk_SSEWithResponseModel(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tchunk := []byte(\"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_1\\\",\\\"model\\\":\\\"gpt-5.3-codex\\\",\\\"status\\\":\\\"completed\\\"}}\\n\\n\")\n\tresult := rw.rewriteStreamChunk(chunk)\n\n\texpected := \"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_1\\\",\\\"model\\\":\\\"gpt-5.2-codex\\\",\\\"status\\\":\\\"completed\\\"}}\\n\\n\"\n\tif string(result) != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, string(result))\n\t}\n}\n\nfunc TestRewriteStreamChunk_MultipleEvents(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"gpt-5.2-codex\"}\n\n\tchunk := []byte(\"data: {\\\"type\\\":\\\"response.created\\\",\\\"response\\\":{\\\"model\\\":\\\"gpt-5.3-codex\\\"}}\\n\\ndata: {\\\"type\\\":\\\"response.output_item.added\\\",\\\"item\\\":{\\\"id\\\":\\\"item_1\\\"}}\\n\\n\")\n\tresult := rw.rewriteStreamChunk(chunk)\n\n\tif string(result) == string(chunk) {\n\t\tt.Error(\"expected response.model to be rewritten in SSE stream\")\n\t}\n\tif !contains(result, []byte(`\"model\":\"gpt-5.2-codex\"`)) {\n\t\tt.Errorf(\"expected rewritten model in output, got %s\", string(result))\n\t}\n}\n\nfunc TestRewriteStreamChunk_MessageModel(t *testing.T) {\n\trw := &ResponseRewriter{originalModel: \"claude-opus-4.5\"}\n\n\tchunk := []byte(\"data: {\\\"message\\\":{\\\"model\\\":\\\"claude-sonnet-4\\\",\\\"role\\\":\\\"assistant\\\"}}\\n\\n\")\n\tresult := rw.rewriteStreamChunk(chunk)\n\n\texpected := \"data: {\\\"message\\\":{\\\"model\\\":\\\"claude-opus-4.5\\\",\\\"role\\\":\\\"assistant\\\"}}\\n\\n\"\n\tif string(result) != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, string(result))\n\t}\n}\n\nfunc contains(data, substr []byte) bool {\n\tfor i := 0; i <= len(data)-len(substr); i++ {\n\t\tif string(data[i:i+len(substr)]) == string(substr) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/api/modules/amp/routes.go",
    "content": "package amp\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// clientAPIKeyContextKey is the context key used to pass the client API key\n// from gin.Context to the request context for SecretSource lookup.\ntype clientAPIKeyContextKey struct{}\n\n// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context[\"apiKey\"]\n// into the request context so that SecretSource can look it up for per-client upstream routing.\nfunc clientAPIKeyMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Extract the client API key from gin context (set by AuthMiddleware)\n\t\tif apiKey, exists := c.Get(\"apiKey\"); exists {\n\t\t\tif keyStr, ok := apiKey.(string); ok && keyStr != \"\" {\n\t\t\t\t// Inject into request context for SecretSource.Get(ctx) to read\n\t\t\t\tctx := context.WithValue(c.Request.Context(), clientAPIKeyContextKey{}, keyStr)\n\t\t\t\tc.Request = c.Request.WithContext(ctx)\n\t\t\t}\n\t\t}\n\t\tc.Next()\n\t}\n}\n\n// getClientAPIKeyFromContext retrieves the client API key from request context.\n// Returns empty string if not present.\nfunc getClientAPIKeyFromContext(ctx context.Context) string {\n\tif val := ctx.Value(clientAPIKeyContextKey{}); val != nil {\n\t\tif keyStr, ok := val.(string); ok {\n\t\t\treturn keyStr\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// localhostOnlyMiddleware returns a middleware that dynamically checks the module's\n// localhost restriction setting. This allows hot-reload of the restriction without restarting.\nfunc (m *AmpModule) localhostOnlyMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Check current setting (hot-reloadable)\n\t\tif !m.IsRestrictedToLocalhost() {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Use actual TCP connection address (RemoteAddr) to prevent header spoofing\n\t\t// This cannot be forged by X-Forwarded-For or other client-controlled headers\n\t\tremoteAddr := c.Request.RemoteAddr\n\n\t\t// RemoteAddr format is \"IP:port\" or \"[IPv6]:port\", extract just the IP\n\t\thost, _, err := net.SplitHostPort(remoteAddr)\n\t\tif err != nil {\n\t\t\t// Try parsing as raw IP (shouldn't happen with standard HTTP, but be defensive)\n\t\t\thost = remoteAddr\n\t\t}\n\n\t\t// Parse the IP to handle both IPv4 and IPv6\n\t\tip := net.ParseIP(host)\n\t\tif ip == nil {\n\t\t\tlog.Warnf(\"amp management: invalid RemoteAddr %s, denying access\", remoteAddr)\n\t\t\tc.AbortWithStatusJSON(403, gin.H{\n\t\t\t\t\"error\": \"Access denied: management routes restricted to localhost\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Check if IP is loopback (127.0.0.1 or ::1)\n\t\tif !ip.IsLoopback() {\n\t\t\tlog.Warnf(\"amp management: non-localhost connection from %s attempted access, denying\", remoteAddr)\n\t\t\tc.AbortWithStatusJSON(403, gin.H{\n\t\t\t\t\"error\": \"Access denied: management routes restricted to localhost\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// noCORSMiddleware disables CORS for management routes to prevent browser-based attacks.\n// This overwrites any global CORS headers set by the server.\nfunc noCORSMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Remove CORS headers to prevent cross-origin access from browsers\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"\")\n\t\tc.Header(\"Access-Control-Allow-Methods\", \"\")\n\t\tc.Header(\"Access-Control-Allow-Headers\", \"\")\n\t\tc.Header(\"Access-Control-Allow-Credentials\", \"\")\n\n\t\t// For OPTIONS preflight, deny with 403\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(403)\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// managementAvailabilityMiddleware short-circuits management routes when the upstream\n// proxy is disabled, preventing noisy localhost warnings and accidental exposure.\nfunc (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif m.getProxy() == nil {\n\t\t\tlogging.SkipGinRequestLogging(c)\n\t\t\tc.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{\n\t\t\t\t\"error\": \"amp upstream proxy not available\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}\n\n// wrapManagementAuth skips auth for selected management paths while keeping authentication elsewhere.\nfunc wrapManagementAuth(auth gin.HandlerFunc, prefixes ...string) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpath := c.Request.URL.Path\n\t\tfor _, prefix := range prefixes {\n\t\t\tif strings.HasPrefix(path, prefix) && (len(path) == len(prefix) || path[len(prefix)] == '/') {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tauth(c)\n\t}\n}\n\n// registerManagementRoutes registers Amp management proxy routes\n// These routes proxy through to the Amp control plane for OAuth, user management, etc.\n// Uses dynamic middleware and proxy getter for hot-reload support.\n// The auth middleware validates Authorization header against configured API keys.\nfunc (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, auth gin.HandlerFunc) {\n\tampAPI := engine.Group(\"/api\")\n\n\t// Always disable CORS for management routes to prevent browser-based attacks\n\tampAPI.Use(m.managementAvailabilityMiddleware(), noCORSMiddleware())\n\n\t// Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost())\n\tampAPI.Use(m.localhostOnlyMiddleware())\n\n\t// Apply authentication middleware - requires valid API key in Authorization header\n\tvar authWithBypass gin.HandlerFunc\n\tif auth != nil {\n\t\tampAPI.Use(auth)\n\t\tauthWithBypass = wrapManagementAuth(auth, \"/threads\", \"/auth\", \"/docs\", \"/settings\")\n\t}\n\n\t// Inject client API key into request context for per-client upstream routing\n\tampAPI.Use(clientAPIKeyMiddleware())\n\n\t// Dynamic proxy handler that uses m.getProxy() for hot-reload support\n\tproxyHandler := func(c *gin.Context) {\n\t\t// Swallow ErrAbortHandler panics from ReverseProxy copyResponse to avoid noisy stack traces\n\t\tdefer func() {\n\t\t\tif rec := recover(); rec != nil {\n\t\t\t\tif err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) {\n\t\t\t\t\t// Upstream already wrote the status (often 404) before the client/stream ended.\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpanic(rec)\n\t\t\t}\n\t\t}()\n\n\t\tproxy := m.getProxy()\n\t\tif proxy == nil {\n\t\t\tc.JSON(503, gin.H{\"error\": \"amp upstream proxy not available\"})\n\t\t\treturn\n\t\t}\n\t\tproxy.ServeHTTP(c.Writer, c.Request)\n\t}\n\n\t// Management routes - these are proxied directly to Amp upstream\n\tampAPI.Any(\"/internal\", proxyHandler)\n\tampAPI.Any(\"/internal/*path\", proxyHandler)\n\tampAPI.Any(\"/user\", proxyHandler)\n\tampAPI.Any(\"/user/*path\", proxyHandler)\n\tampAPI.Any(\"/auth\", proxyHandler)\n\tampAPI.Any(\"/auth/*path\", proxyHandler)\n\tampAPI.Any(\"/meta\", proxyHandler)\n\tampAPI.Any(\"/meta/*path\", proxyHandler)\n\tampAPI.Any(\"/ads\", proxyHandler)\n\tampAPI.Any(\"/telemetry\", proxyHandler)\n\tampAPI.Any(\"/telemetry/*path\", proxyHandler)\n\tampAPI.Any(\"/threads\", proxyHandler)\n\tampAPI.Any(\"/threads/*path\", proxyHandler)\n\tampAPI.Any(\"/otel\", proxyHandler)\n\tampAPI.Any(\"/otel/*path\", proxyHandler)\n\tampAPI.Any(\"/tab\", proxyHandler)\n\tampAPI.Any(\"/tab/*path\", proxyHandler)\n\n\t// Root-level routes that AMP CLI expects without /api prefix\n\t// These need the same security middleware as the /api/* routes (dynamic for hot-reload)\n\trootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()}\n\tif authWithBypass != nil {\n\t\trootMiddleware = append(rootMiddleware, authWithBypass)\n\t}\n\t// Add clientAPIKeyMiddleware after auth for per-client upstream routing\n\trootMiddleware = append(rootMiddleware, clientAPIKeyMiddleware())\n\tengine.GET(\"/threads\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/threads/*path\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/docs\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/docs/*path\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/settings\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/settings/*path\", append(rootMiddleware, proxyHandler)...)\n\n\tengine.GET(\"/threads.rss\", append(rootMiddleware, proxyHandler)...)\n\tengine.GET(\"/news.rss\", append(rootMiddleware, proxyHandler)...)\n\n\t// Root-level auth routes for CLI login flow\n\t// Amp uses multiple auth routes: /auth/cli-login, /auth/callback, /auth/sign-in, /auth/logout\n\t// We proxy all /auth/* to support the complete OAuth flow\n\tengine.Any(\"/auth\", append(rootMiddleware, proxyHandler)...)\n\tengine.Any(\"/auth/*path\", append(rootMiddleware, proxyHandler)...)\n\n\t// Google v1beta1 passthrough with OAuth fallback\n\t// AMP CLI uses non-standard paths like /publishers/google/models/...\n\t// We bridge these to our standard Gemini handler to enable local OAuth.\n\t// If no local OAuth is available, falls back to ampcode.com proxy.\n\tgeminiHandlers := gemini.NewGeminiAPIHandler(baseHandler)\n\tgeminiBridge := createGeminiBridgeHandler(geminiHandlers.GeminiHandler)\n\tgeminiV1Beta1Fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy {\n\t\treturn m.getProxy()\n\t}, m.modelMapper, m.forceModelMappings)\n\tgeminiV1Beta1Handler := geminiV1Beta1Fallback.WrapHandler(geminiBridge)\n\n\t// Route POST model calls through Gemini bridge with FallbackHandler.\n\t// FallbackHandler checks provider -> mapping -> proxy fallback automatically.\n\t// All other methods (e.g., GET model listing) always proxy to upstream to preserve Amp CLI behavior.\n\tampAPI.Any(\"/provider/google/v1beta1/*path\", func(c *gin.Context) {\n\t\tif c.Request.Method == \"POST\" {\n\t\t\tif path := c.Param(\"path\"); strings.Contains(path, \"/models/\") {\n\t\t\t\t// POST with /models/ path -> use Gemini bridge with fallback handler\n\t\t\t\t// FallbackHandler will check provider/mapping and proxy if needed\n\t\t\t\tgeminiV1Beta1Handler(c)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t// Non-POST or no local provider available -> proxy upstream\n\t\tproxyHandler(c)\n\t})\n}\n\n// registerProviderAliases registers /api/provider/{provider}/... routes\n// These allow Amp CLI to route requests like:\n//\n//\t/api/provider/openai/v1/chat/completions\n//\t/api/provider/anthropic/v1/messages\n//\t/api/provider/google/v1beta/models\nfunc (m *AmpModule) registerProviderAliases(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, auth gin.HandlerFunc) {\n\t// Create handler instances for different providers\n\topenaiHandlers := openai.NewOpenAIAPIHandler(baseHandler)\n\tgeminiHandlers := gemini.NewGeminiAPIHandler(baseHandler)\n\tclaudeCodeHandlers := claude.NewClaudeCodeAPIHandler(baseHandler)\n\topenaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(baseHandler)\n\n\t// Create fallback handler wrapper that forwards to ampcode.com when provider not found\n\t// Uses m.getProxy() for hot-reload support (proxy can be updated at runtime)\n\t// Also includes model mapping support for routing unavailable models to alternatives\n\tfallbackHandler := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy {\n\t\treturn m.getProxy()\n\t}, m.modelMapper, m.forceModelMappings)\n\n\t// Provider-specific routes under /api/provider/:provider\n\tampProviders := engine.Group(\"/api/provider\")\n\tif auth != nil {\n\t\tampProviders.Use(auth)\n\t}\n\t// Inject client API key into request context for per-client upstream routing\n\tampProviders.Use(clientAPIKeyMiddleware())\n\n\tprovider := ampProviders.Group(\"/:provider\")\n\n\t// Dynamic models handler - routes to appropriate provider based on path parameter\n\tampModelsHandler := func(c *gin.Context) {\n\t\tproviderName := strings.ToLower(c.Param(\"provider\"))\n\n\t\tswitch providerName {\n\t\tcase \"anthropic\":\n\t\t\tclaudeCodeHandlers.ClaudeModels(c)\n\t\tcase \"google\":\n\t\t\tgeminiHandlers.GeminiModels(c)\n\t\tdefault:\n\t\t\t// Default to OpenAI-compatible (works for openai, groq, cerebras, etc.)\n\t\t\topenaiHandlers.OpenAIModels(c)\n\t\t}\n\t}\n\n\t// Root-level routes (for providers that omit /v1, like groq/cerebras)\n\t// Wrap handlers with fallback logic to forward to ampcode.com when provider not found\n\tprovider.GET(\"/models\", ampModelsHandler) // Models endpoint doesn't need fallback (no body to check)\n\tprovider.POST(\"/chat/completions\", fallbackHandler.WrapHandler(openaiHandlers.ChatCompletions))\n\tprovider.POST(\"/completions\", fallbackHandler.WrapHandler(openaiHandlers.Completions))\n\tprovider.POST(\"/responses\", fallbackHandler.WrapHandler(openaiResponsesHandlers.Responses))\n\n\t// /v1 routes (OpenAI/Claude-compatible endpoints)\n\tv1Amp := provider.Group(\"/v1\")\n\t{\n\t\tv1Amp.GET(\"/models\", ampModelsHandler) // Models endpoint doesn't need fallback\n\n\t\t// OpenAI-compatible endpoints with fallback\n\t\tv1Amp.POST(\"/chat/completions\", fallbackHandler.WrapHandler(openaiHandlers.ChatCompletions))\n\t\tv1Amp.POST(\"/completions\", fallbackHandler.WrapHandler(openaiHandlers.Completions))\n\t\tv1Amp.POST(\"/responses\", fallbackHandler.WrapHandler(openaiResponsesHandlers.Responses))\n\n\t\t// Claude/Anthropic-compatible endpoints with fallback\n\t\tv1Amp.POST(\"/messages\", fallbackHandler.WrapHandler(claudeCodeHandlers.ClaudeMessages))\n\t\tv1Amp.POST(\"/messages/count_tokens\", fallbackHandler.WrapHandler(claudeCodeHandlers.ClaudeCountTokens))\n\t}\n\n\t// /v1beta routes (Gemini native API)\n\t// Note: Gemini handler extracts model from URL path, so fallback logic needs special handling\n\tv1betaAmp := provider.Group(\"/v1beta\")\n\t{\n\t\tv1betaAmp.GET(\"/models\", geminiHandlers.GeminiModels)\n\t\tv1betaAmp.POST(\"/models/*action\", fallbackHandler.WrapHandler(geminiHandlers.GeminiHandler))\n\t\tv1betaAmp.GET(\"/models/*action\", geminiHandlers.GeminiGetHandler)\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/routes_test.go",
    "content": "package amp\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n)\n\nfunc TestRegisterManagementRoutes(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Create module with proxy for testing\n\tm := &AmpModule{\n\t\trestrictToLocalhost: false, // disable localhost restriction for tests\n\t}\n\n\t// Create a mock proxy that tracks calls\n\tproxyCalled := false\n\tmockProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tproxyCalled = true\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(\"proxied\"))\n\t}))\n\tdefer mockProxy.Close()\n\n\t// Create real proxy to mock server\n\tproxy, _ := createReverseProxy(mockProxy.URL, NewStaticSecretSource(\"\"))\n\tm.setProxy(proxy)\n\n\tbase := &handlers.BaseAPIHandler{}\n\tm.registerManagementRoutes(r, base, nil)\n\tsrv := httptest.NewServer(r)\n\tdefer srv.Close()\n\n\tmanagementPaths := []struct {\n\t\tpath   string\n\t\tmethod string\n\t}{\n\t\t{\"/api/internal\", http.MethodGet},\n\t\t{\"/api/internal/some/path\", http.MethodGet},\n\t\t{\"/api/user\", http.MethodGet},\n\t\t{\"/api/user/profile\", http.MethodGet},\n\t\t{\"/api/auth\", http.MethodGet},\n\t\t{\"/api/auth/login\", http.MethodGet},\n\t\t{\"/api/meta\", http.MethodGet},\n\t\t{\"/api/telemetry\", http.MethodGet},\n\t\t{\"/api/threads\", http.MethodGet},\n\t\t{\"/threads/\", http.MethodGet},\n\t\t{\"/threads.rss\", http.MethodGet}, // Root-level route (no /api prefix)\n\t\t{\"/api/otel\", http.MethodGet},\n\t\t{\"/api/tab\", http.MethodGet},\n\t\t{\"/api/tab/some/path\", http.MethodGet},\n\t\t{\"/auth\", http.MethodGet},           // Root-level auth route\n\t\t{\"/auth/cli-login\", http.MethodGet}, // CLI login flow\n\t\t{\"/auth/callback\", http.MethodGet},  // OAuth callback\n\t\t// Google v1beta1 bridge should still proxy non-model requests (GET) and allow POST\n\t\t{\"/api/provider/google/v1beta1/models\", http.MethodGet},\n\t\t{\"/api/provider/google/v1beta1/models\", http.MethodPost},\n\t}\n\n\tfor _, path := range managementPaths {\n\t\tt.Run(path.path, func(t *testing.T) {\n\t\t\tproxyCalled = false\n\t\t\treq, err := http.NewRequest(path.method, srv.URL+path.path, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to build request: %v\", err)\n\t\t\t}\n\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"request failed: %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tif resp.StatusCode == http.StatusNotFound {\n\t\t\t\tt.Fatalf(\"route %s not registered\", path.path)\n\t\t\t}\n\t\t\tif !proxyCalled {\n\t\t\t\tt.Fatalf(\"proxy handler not called for %s\", path.path)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegisterProviderAliases_AllProvidersRegistered(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Minimal base handler setup (no need to initialize, just check routing)\n\tbase := &handlers.BaseAPIHandler{}\n\n\t// Track if auth middleware was called\n\tauthCalled := false\n\tauthMiddleware := func(c *gin.Context) {\n\t\tauthCalled = true\n\t\tc.Header(\"X-Auth\", \"ok\")\n\t\t// Abort with success to avoid calling the actual handler (which needs full setup)\n\t\tc.AbortWithStatus(http.StatusOK)\n\t}\n\n\tm := &AmpModule{authMiddleware_: authMiddleware}\n\tm.registerProviderAliases(r, base, authMiddleware)\n\n\tpaths := []struct {\n\t\tpath   string\n\t\tmethod string\n\t}{\n\t\t{\"/api/provider/openai/models\", http.MethodGet},\n\t\t{\"/api/provider/anthropic/models\", http.MethodGet},\n\t\t{\"/api/provider/google/models\", http.MethodGet},\n\t\t{\"/api/provider/groq/models\", http.MethodGet},\n\t\t{\"/api/provider/openai/chat/completions\", http.MethodPost},\n\t\t{\"/api/provider/anthropic/v1/messages\", http.MethodPost},\n\t\t{\"/api/provider/google/v1beta/models\", http.MethodGet},\n\t}\n\n\tfor _, tc := range paths {\n\t\tt.Run(tc.path, func(t *testing.T) {\n\t\t\tauthCalled = false\n\t\t\treq := httptest.NewRequest(tc.method, tc.path, nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code == http.StatusNotFound {\n\t\t\t\tt.Fatalf(\"route %s %s not registered\", tc.method, tc.path)\n\t\t\t}\n\t\t\tif !authCalled {\n\t\t\t\tt.Fatalf(\"auth middleware not executed for %s\", tc.path)\n\t\t\t}\n\t\t\tif w.Header().Get(\"X-Auth\") != \"ok\" {\n\t\t\t\tt.Fatalf(\"auth middleware header not set for %s\", tc.path)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegisterProviderAliases_DynamicModelsHandler(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := &AmpModule{authMiddleware_: func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) }}\n\tm.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })\n\n\tproviders := []string{\"openai\", \"anthropic\", \"google\", \"groq\", \"cerebras\"}\n\n\tfor _, provider := range providers {\n\t\tt.Run(provider, func(t *testing.T) {\n\t\t\tpath := \"/api/provider/\" + provider + \"/models\"\n\t\t\treq := httptest.NewRequest(http.MethodGet, path, nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\t// Should not 404\n\t\t\tif w.Code == http.StatusNotFound {\n\t\t\t\tt.Fatalf(\"models route not found for provider: %s\", provider)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegisterProviderAliases_V1Routes(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := &AmpModule{authMiddleware_: func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) }}\n\tm.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })\n\n\tv1Paths := []struct {\n\t\tpath   string\n\t\tmethod string\n\t}{\n\t\t{\"/api/provider/openai/v1/models\", http.MethodGet},\n\t\t{\"/api/provider/openai/v1/chat/completions\", http.MethodPost},\n\t\t{\"/api/provider/openai/v1/completions\", http.MethodPost},\n\t\t{\"/api/provider/anthropic/v1/messages\", http.MethodPost},\n\t\t{\"/api/provider/anthropic/v1/messages/count_tokens\", http.MethodPost},\n\t}\n\n\tfor _, tc := range v1Paths {\n\t\tt.Run(tc.path, func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(tc.method, tc.path, nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code == http.StatusNotFound {\n\t\t\t\tt.Fatalf(\"v1 route %s %s not registered\", tc.method, tc.path)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegisterProviderAliases_V1BetaRoutes(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := &AmpModule{authMiddleware_: func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) }}\n\tm.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })\n\n\tv1betaPaths := []struct {\n\t\tpath   string\n\t\tmethod string\n\t}{\n\t\t{\"/api/provider/google/v1beta/models\", http.MethodGet},\n\t\t{\"/api/provider/google/v1beta/models/generateContent\", http.MethodPost},\n\t}\n\n\tfor _, tc := range v1betaPaths {\n\t\tt.Run(tc.path, func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(tc.method, tc.path, nil)\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code == http.StatusNotFound {\n\t\t\t\tt.Fatalf(\"v1beta route %s %s not registered\", tc.method, tc.path)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegisterProviderAliases_NoAuthMiddleware(t *testing.T) {\n\t// Test that routes still register even if auth middleware is nil (fallback behavior)\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\tbase := &handlers.BaseAPIHandler{}\n\n\tm := &AmpModule{authMiddleware_: nil} // No auth middleware\n\tm.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/provider/openai/models\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\t// Should still work (with fallback no-op auth)\n\tif w.Code == http.StatusNotFound {\n\t\tt.Fatal(\"routes should register even without auth middleware\")\n\t}\n}\n\nfunc TestLocalhostOnlyMiddleware_PreventsSpoofing(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Create module with localhost restriction enabled\n\tm := &AmpModule{\n\t\trestrictToLocalhost: true,\n\t}\n\n\t// Apply dynamic localhost-only middleware\n\tr.Use(m.localhostOnlyMiddleware())\n\tr.GET(\"/test\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"ok\")\n\t})\n\n\ttests := []struct {\n\t\tname           string\n\t\tremoteAddr     string\n\t\tforwardedFor   string\n\t\texpectedStatus int\n\t\tdescription    string\n\t}{\n\t\t{\n\t\t\tname:           \"spoofed_header_remote_connection\",\n\t\t\tremoteAddr:     \"192.168.1.100:12345\",\n\t\t\tforwardedFor:   \"127.0.0.1\",\n\t\t\texpectedStatus: http.StatusForbidden,\n\t\t\tdescription:    \"Spoofed X-Forwarded-For header should be ignored\",\n\t\t},\n\t\t{\n\t\t\tname:           \"real_localhost_ipv4\",\n\t\t\tremoteAddr:     \"127.0.0.1:54321\",\n\t\t\tforwardedFor:   \"\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t\tdescription:    \"Real localhost IPv4 connection should work\",\n\t\t},\n\t\t{\n\t\t\tname:           \"real_localhost_ipv6\",\n\t\t\tremoteAddr:     \"[::1]:54321\",\n\t\t\tforwardedFor:   \"\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t\tdescription:    \"Real localhost IPv6 connection should work\",\n\t\t},\n\t\t{\n\t\t\tname:           \"remote_ipv4\",\n\t\t\tremoteAddr:     \"203.0.113.42:8080\",\n\t\t\tforwardedFor:   \"\",\n\t\t\texpectedStatus: http.StatusForbidden,\n\t\t\tdescription:    \"Remote IPv4 connection should be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:           \"remote_ipv6\",\n\t\t\tremoteAddr:     \"[2001:db8::1]:9090\",\n\t\t\tforwardedFor:   \"\",\n\t\t\texpectedStatus: http.StatusForbidden,\n\t\t\tdescription:    \"Remote IPv6 connection should be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:           \"spoofed_localhost_ipv6\",\n\t\t\tremoteAddr:     \"203.0.113.42:8080\",\n\t\t\tforwardedFor:   \"::1\",\n\t\t\texpectedStatus: http.StatusForbidden,\n\t\t\tdescription:    \"Spoofed X-Forwarded-For with IPv6 localhost should be ignored\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(http.MethodGet, \"/test\", nil)\n\t\t\treq.RemoteAddr = tt.remoteAddr\n\t\t\tif tt.forwardedFor != \"\" {\n\t\t\t\treq.Header.Set(\"X-Forwarded-For\", tt.forwardedFor)\n\t\t\t}\n\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr.ServeHTTP(w, req)\n\n\t\t\tif w.Code != tt.expectedStatus {\n\t\t\t\tt.Errorf(\"%s: expected status %d, got %d\", tt.description, tt.expectedStatus, w.Code)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\n\t// Create module with localhost restriction initially enabled\n\tm := &AmpModule{\n\t\trestrictToLocalhost: true,\n\t}\n\n\t// Apply dynamic localhost-only middleware\n\tr.Use(m.localhostOnlyMiddleware())\n\tr.GET(\"/test\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"ok\")\n\t})\n\n\t// Test 1: Remote IP should be blocked when restriction is enabled\n\treq := httptest.NewRequest(http.MethodGet, \"/test\", nil)\n\treq.RemoteAddr = \"192.168.1.100:12345\"\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusForbidden {\n\t\tt.Errorf(\"Expected 403 when restriction enabled, got %d\", w.Code)\n\t}\n\n\t// Test 2: Hot-reload - disable restriction\n\tm.setRestrictToLocalhost(false)\n\n\treq = httptest.NewRequest(http.MethodGet, \"/test\", nil)\n\treq.RemoteAddr = \"192.168.1.100:12345\"\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"Expected 200 after disabling restriction, got %d\", w.Code)\n\t}\n\n\t// Test 3: Hot-reload - re-enable restriction\n\tm.setRestrictToLocalhost(true)\n\n\treq = httptest.NewRequest(http.MethodGet, \"/test\", nil)\n\treq.RemoteAddr = \"192.168.1.100:12345\"\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusForbidden {\n\t\tt.Errorf(\"Expected 403 after re-enabling restriction, got %d\", w.Code)\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/secret.go",
    "content": "package amp\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// SecretSource provides Amp API keys with configurable precedence and caching\ntype SecretSource interface {\n\tGet(ctx context.Context) (string, error)\n}\n\n// cachedSecret holds a secret value with expiration\ntype cachedSecret struct {\n\tvalue     string\n\texpiresAt time.Time\n}\n\n// MultiSourceSecret implements precedence-based secret lookup:\n// 1. Explicit config value (highest priority)\n// 2. Environment variable AMP_API_KEY\n// 3. File-based secret (lowest priority)\ntype MultiSourceSecret struct {\n\texplicitKey string\n\tenvKey      string\n\tfilePath    string\n\tcacheTTL    time.Duration\n\n\tmu    sync.RWMutex\n\tcache *cachedSecret\n}\n\n// NewMultiSourceSecret creates a secret source with precedence and caching\nfunc NewMultiSourceSecret(explicitKey string, cacheTTL time.Duration) *MultiSourceSecret {\n\tif cacheTTL == 0 {\n\t\tcacheTTL = 5 * time.Minute // Default 5 minute cache\n\t}\n\n\thome, _ := os.UserHomeDir()\n\tfilePath := filepath.Join(home, \".local\", \"share\", \"amp\", \"secrets.json\")\n\n\treturn &MultiSourceSecret{\n\t\texplicitKey: strings.TrimSpace(explicitKey),\n\t\tenvKey:      \"AMP_API_KEY\",\n\t\tfilePath:    filePath,\n\t\tcacheTTL:    cacheTTL,\n\t}\n}\n\n// NewMultiSourceSecretWithPath creates a secret source with a custom file path (for testing)\nfunc NewMultiSourceSecretWithPath(explicitKey string, filePath string, cacheTTL time.Duration) *MultiSourceSecret {\n\tif cacheTTL == 0 {\n\t\tcacheTTL = 5 * time.Minute\n\t}\n\n\treturn &MultiSourceSecret{\n\t\texplicitKey: strings.TrimSpace(explicitKey),\n\t\tenvKey:      \"AMP_API_KEY\",\n\t\tfilePath:    filePath,\n\t\tcacheTTL:    cacheTTL,\n\t}\n}\n\n// Get retrieves the Amp API key using precedence: config > env > file\n// Results are cached for cacheTTL duration to avoid excessive file reads\nfunc (s *MultiSourceSecret) Get(ctx context.Context) (string, error) {\n\t// Precedence 1: Explicit config key (highest priority, no caching needed)\n\tif s.explicitKey != \"\" {\n\t\treturn s.explicitKey, nil\n\t}\n\n\t// Precedence 2: Environment variable\n\tif envValue := strings.TrimSpace(os.Getenv(s.envKey)); envValue != \"\" {\n\t\treturn envValue, nil\n\t}\n\n\t// Precedence 3: File-based secret (lowest priority, cached)\n\t// Check cache first\n\ts.mu.RLock()\n\tif s.cache != nil && time.Now().Before(s.cache.expiresAt) {\n\t\tvalue := s.cache.value\n\t\ts.mu.RUnlock()\n\t\treturn value, nil\n\t}\n\ts.mu.RUnlock()\n\n\t// Cache miss or expired - read from file\n\tkey, err := s.readFromFile()\n\tif err != nil {\n\t\t// Cache empty result to avoid repeated file reads on missing files\n\t\ts.updateCache(\"\")\n\t\treturn \"\", err\n\t}\n\n\t// Cache the result\n\ts.updateCache(key)\n\treturn key, nil\n}\n\n// readFromFile reads the Amp API key from the secrets file\nfunc (s *MultiSourceSecret) readFromFile() (string, error) {\n\tcontent, err := os.ReadFile(s.filePath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn \"\", nil // Missing file is not an error, just no key available\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"failed to read amp secrets from %s: %w\", s.filePath, err)\n\t}\n\n\tvar secrets map[string]string\n\tif err := json.Unmarshal(content, &secrets); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse amp secrets from %s: %w\", s.filePath, err)\n\t}\n\n\tkey := strings.TrimSpace(secrets[\"apiKey@https://ampcode.com/\"])\n\treturn key, nil\n}\n\n// updateCache updates the cached secret value\nfunc (s *MultiSourceSecret) updateCache(value string) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.cache = &cachedSecret{\n\t\tvalue:     value,\n\t\texpiresAt: time.Now().Add(s.cacheTTL),\n\t}\n}\n\n// InvalidateCache clears the cached secret, forcing a fresh read on next Get\nfunc (s *MultiSourceSecret) InvalidateCache() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.cache = nil\n}\n\n// UpdateExplicitKey refreshes the config-provided key and clears cache.\nfunc (s *MultiSourceSecret) UpdateExplicitKey(key string) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\ts.explicitKey = strings.TrimSpace(key)\n\ts.cache = nil\n\ts.mu.Unlock()\n}\n\n// StaticSecretSource returns a fixed API key (for testing)\ntype StaticSecretSource struct {\n\tkey string\n}\n\n// NewStaticSecretSource creates a secret source with a fixed key\nfunc NewStaticSecretSource(key string) *StaticSecretSource {\n\treturn &StaticSecretSource{key: strings.TrimSpace(key)}\n}\n\n// Get returns the static API key\nfunc (s *StaticSecretSource) Get(ctx context.Context) (string, error) {\n\treturn s.key, nil\n}\n\n// MappedSecretSource wraps a default SecretSource and adds per-client API key mapping.\n// When a request context contains a client API key that matches a configured mapping,\n// the corresponding upstream key is returned. Otherwise, falls back to the default source.\ntype MappedSecretSource struct {\n\tdefaultSource SecretSource\n\tmu            sync.RWMutex\n\tlookup        map[string]string // clientKey -> upstreamKey\n}\n\n// NewMappedSecretSource creates a MappedSecretSource wrapping the given default source.\nfunc NewMappedSecretSource(defaultSource SecretSource) *MappedSecretSource {\n\treturn &MappedSecretSource{\n\t\tdefaultSource: defaultSource,\n\t\tlookup:        make(map[string]string),\n\t}\n}\n\n// Get retrieves the Amp API key, checking per-client mappings first.\n// If the request context contains a client API key that matches a configured mapping,\n// returns the corresponding upstream key. Otherwise, falls back to the default source.\nfunc (s *MappedSecretSource) Get(ctx context.Context) (string, error) {\n\t// Try to get client API key from request context\n\tclientKey := getClientAPIKeyFromContext(ctx)\n\tif clientKey != \"\" {\n\t\ts.mu.RLock()\n\t\tif upstreamKey, ok := s.lookup[clientKey]; ok && upstreamKey != \"\" {\n\t\t\ts.mu.RUnlock()\n\t\t\treturn upstreamKey, nil\n\t\t}\n\t\ts.mu.RUnlock()\n\t}\n\n\t// Fall back to default source\n\treturn s.defaultSource.Get(ctx)\n}\n\n// UpdateMappings rebuilds the client-to-upstream key mapping from configuration entries.\n// If the same client key appears in multiple entries, logs a warning and uses the first one.\nfunc (s *MappedSecretSource) UpdateMappings(entries []config.AmpUpstreamAPIKeyEntry) {\n\tnewLookup := make(map[string]string)\n\n\tfor _, entry := range entries {\n\t\tupstreamKey := strings.TrimSpace(entry.UpstreamAPIKey)\n\t\tif upstreamKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, clientKey := range entry.APIKeys {\n\t\t\ttrimmedKey := strings.TrimSpace(clientKey)\n\t\t\tif trimmedKey == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, exists := newLookup[trimmedKey]; exists {\n\t\t\t\t// Log warning for duplicate client key, first one wins\n\t\t\t\tlog.Warnf(\"amp upstream-api-keys: client API key appears in multiple entries; using first mapping.\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnewLookup[trimmedKey] = upstreamKey\n\t\t}\n\t}\n\n\ts.mu.Lock()\n\ts.lookup = newLookup\n\ts.mu.Unlock()\n}\n\n// UpdateDefaultExplicitKey updates the explicit key on the underlying MultiSourceSecret (if applicable).\nfunc (s *MappedSecretSource) UpdateDefaultExplicitKey(key string) {\n\tif ms, ok := s.defaultSource.(*MultiSourceSecret); ok {\n\t\tms.UpdateExplicitKey(key)\n\t}\n}\n\n// InvalidateCache invalidates cache on the underlying MultiSourceSecret (if applicable).\nfunc (s *MappedSecretSource) InvalidateCache() {\n\tif ms, ok := s.defaultSource.(*MultiSourceSecret); ok {\n\t\tms.InvalidateCache()\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/amp/secret_test.go",
    "content": "package amp\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/sirupsen/logrus/hooks/test\"\n)\n\nfunc TestMultiSourceSecret_PrecedenceOrder(t *testing.T) {\n\tctx := context.Background()\n\n\tcases := []struct {\n\t\tname      string\n\t\tconfigKey string\n\t\tenvKey    string\n\t\tfileJSON  string\n\t\twant      string\n\t}{\n\t\t{\"config_wins\", \"cfg\", \"env\", `{\"apiKey@https://ampcode.com/\":\"file\"}`, \"cfg\"},\n\t\t{\"env_wins_when_no_cfg\", \"\", \"env\", `{\"apiKey@https://ampcode.com/\":\"file\"}`, \"env\"},\n\t\t{\"file_when_no_cfg_env\", \"\", \"\", `{\"apiKey@https://ampcode.com/\":\"file\"}`, \"file\"},\n\t\t{\"empty_cfg_trims_then_env\", \"   \", \"env\", `{\"apiKey@https://ampcode.com/\":\"file\"}`, \"env\"},\n\t\t{\"empty_env_then_file\", \"\", \"   \", `{\"apiKey@https://ampcode.com/\":\"file\"}`, \"file\"},\n\t\t{\"missing_file_returns_empty\", \"\", \"\", \"\", \"\"},\n\t\t{\"all_empty_returns_empty\", \"  \", \"  \", `{\"apiKey@https://ampcode.com/\":\"  \"}`, \"\"},\n\t}\n\n\tfor _, tc := range cases {\n\t\ttc := tc // capture range variable\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tsecretsPath := filepath.Join(tmpDir, \"secrets.json\")\n\n\t\t\tif tc.fileJSON != \"\" {\n\t\t\t\tif err := os.WriteFile(secretsPath, []byte(tc.fileJSON), 0600); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tt.Setenv(\"AMP_API_KEY\", tc.envKey)\n\n\t\t\ts := NewMultiSourceSecretWithPath(tc.configKey, secretsPath, 100*time.Millisecond)\n\t\t\tgot, err := s.Get(ctx)\n\t\t\tif err != nil && tc.fileJSON != \"\" && json.Valid([]byte(tc.fileJSON)) {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif got != tc.want {\n\t\t\t\tt.Fatalf(\"want %q, got %q\", tc.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMultiSourceSecret_CacheBehavior(t *testing.T) {\n\tctx := context.Background()\n\ttmpDir := t.TempDir()\n\tp := filepath.Join(tmpDir, \"secrets.json\")\n\n\t// Initial value\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"v1\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ts := NewMultiSourceSecretWithPath(\"\", p, 50*time.Millisecond)\n\n\t// First read - should return v1\n\tgot1, err := s.Get(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\tif got1 != \"v1\" {\n\t\tt.Fatalf(\"expected v1, got %s\", got1)\n\t}\n\n\t// Change file; within TTL we should still see v1 (cached)\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"v2\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot2, _ := s.Get(ctx)\n\tif got2 != \"v1\" {\n\t\tt.Fatalf(\"cache hit expected v1, got %s\", got2)\n\t}\n\n\t// After TTL expires, should see v2\n\ttime.Sleep(60 * time.Millisecond)\n\tgot3, _ := s.Get(ctx)\n\tif got3 != \"v2\" {\n\t\tt.Fatalf(\"cache miss expected v2, got %s\", got3)\n\t}\n\n\t// Invalidate forces re-read immediately\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"v3\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ts.InvalidateCache()\n\tgot4, _ := s.Get(ctx)\n\tif got4 != \"v3\" {\n\t\tt.Fatalf(\"invalidate expected v3, got %s\", got4)\n\t}\n}\n\nfunc TestMultiSourceSecret_FileHandling(t *testing.T) {\n\tctx := context.Background()\n\n\tt.Run(\"missing_file_no_error\", func(t *testing.T) {\n\t\ts := NewMultiSourceSecretWithPath(\"\", \"/nonexistent/path/secrets.json\", 100*time.Millisecond)\n\t\tgot, err := s.Get(ctx)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected no error for missing file, got: %v\", err)\n\t\t}\n\t\tif got != \"\" {\n\t\t\tt.Fatalf(\"expected empty string, got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid_json\", func(t *testing.T) {\n\t\ttmpDir := t.TempDir()\n\t\tp := filepath.Join(tmpDir, \"secrets.json\")\n\t\tif err := os.WriteFile(p, []byte(`{invalid json`), 0600); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\ts := NewMultiSourceSecretWithPath(\"\", p, 100*time.Millisecond)\n\t\t_, err := s.Get(ctx)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for invalid JSON\")\n\t\t}\n\t})\n\n\tt.Run(\"missing_key_in_json\", func(t *testing.T) {\n\t\ttmpDir := t.TempDir()\n\t\tp := filepath.Join(tmpDir, \"secrets.json\")\n\t\tif err := os.WriteFile(p, []byte(`{\"other\":\"value\"}`), 0600); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\ts := NewMultiSourceSecretWithPath(\"\", p, 100*time.Millisecond)\n\t\tgot, err := s.Get(ctx)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif got != \"\" {\n\t\t\tt.Fatalf(\"expected empty string for missing key, got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"empty_key_value\", func(t *testing.T) {\n\t\ttmpDir := t.TempDir()\n\t\tp := filepath.Join(tmpDir, \"secrets.json\")\n\t\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"   \"}`), 0600); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\ts := NewMultiSourceSecretWithPath(\"\", p, 100*time.Millisecond)\n\t\tgot, _ := s.Get(ctx)\n\t\tif got != \"\" {\n\t\t\tt.Fatalf(\"expected empty after trim, got %q\", got)\n\t\t}\n\t})\n}\n\nfunc TestMultiSourceSecret_Concurrency(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tp := filepath.Join(tmpDir, \"secrets.json\")\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"concurrent\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ts := NewMultiSourceSecretWithPath(\"\", p, 5*time.Second)\n\tctx := context.Background()\n\n\t// Spawn many goroutines calling Get concurrently\n\tconst goroutines = 50\n\tconst iterations = 100\n\n\tvar wg sync.WaitGroup\n\terrors := make(chan error, goroutines)\n\n\tfor i := 0; i < goroutines; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := 0; j < iterations; j++ {\n\t\t\t\tval, err := s.Get(ctx)\n\t\t\t\tif err != nil {\n\t\t\t\t\terrors <- err\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif val != \"concurrent\" {\n\t\t\t\t\terrors <- err\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n\tclose(errors)\n\n\tfor err := range errors {\n\t\tt.Errorf(\"concurrency error: %v\", err)\n\t}\n}\n\nfunc TestStaticSecretSource(t *testing.T) {\n\tctx := context.Background()\n\n\tt.Run(\"returns_provided_key\", func(t *testing.T) {\n\t\ts := NewStaticSecretSource(\"test-key-123\")\n\t\tgot, err := s.Get(ctx)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif got != \"test-key-123\" {\n\t\t\tt.Fatalf(\"want test-key-123, got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"trims_whitespace\", func(t *testing.T) {\n\t\ts := NewStaticSecretSource(\"  test-key  \")\n\t\tgot, err := s.Get(ctx)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif got != \"test-key\" {\n\t\t\tt.Fatalf(\"want test-key, got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"empty_string\", func(t *testing.T) {\n\t\ts := NewStaticSecretSource(\"\")\n\t\tgot, err := s.Get(ctx)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif got != \"\" {\n\t\t\tt.Fatalf(\"want empty string, got %q\", got)\n\t\t}\n\t})\n}\n\nfunc TestMultiSourceSecret_CacheEmptyResult(t *testing.T) {\n\t// Test that missing file results are cached to avoid repeated file reads\n\ttmpDir := t.TempDir()\n\tp := filepath.Join(tmpDir, \"nonexistent.json\")\n\n\ts := NewMultiSourceSecretWithPath(\"\", p, 100*time.Millisecond)\n\tctx := context.Background()\n\n\t// First call - file doesn't exist, should cache empty result\n\tgot1, err := s.Get(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error for missing file, got: %v\", err)\n\t}\n\tif got1 != \"\" {\n\t\tt.Fatalf(\"expected empty string, got %q\", got1)\n\t}\n\n\t// Create the file now\n\tif err := os.WriteFile(p, []byte(`{\"apiKey@https://ampcode.com/\":\"new-value\"}`), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Second call - should still return empty (cached), not read the new file\n\tgot2, _ := s.Get(ctx)\n\tif got2 != \"\" {\n\t\tt.Fatalf(\"cache should return empty, got %q\", got2)\n\t}\n\n\t// After TTL expires, should see the new value\n\ttime.Sleep(110 * time.Millisecond)\n\tgot3, _ := s.Get(ctx)\n\tif got3 != \"new-value\" {\n\t\tt.Fatalf(\"after cache expiry, expected new-value, got %q\", got3)\n\t}\n}\n\nfunc TestMappedSecretSource_UsesMappingFromContext(t *testing.T) {\n\tdefaultSource := NewStaticSecretSource(\"default\")\n\ts := NewMappedSecretSource(defaultSource)\n\ts.UpdateMappings([]config.AmpUpstreamAPIKeyEntry{\n\t\t{\n\t\t\tUpstreamAPIKey: \"u1\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t})\n\n\tctx := context.WithValue(context.Background(), clientAPIKeyContextKey{}, \"k1\")\n\tgot, err := s.Get(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"u1\" {\n\t\tt.Fatalf(\"want u1, got %q\", got)\n\t}\n\n\tctx = context.WithValue(context.Background(), clientAPIKeyContextKey{}, \"k2\")\n\tgot, err = s.Get(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"default\" {\n\t\tt.Fatalf(\"want default fallback, got %q\", got)\n\t}\n}\n\nfunc TestMappedSecretSource_DuplicateClientKey_FirstWins(t *testing.T) {\n\tdefaultSource := NewStaticSecretSource(\"default\")\n\ts := NewMappedSecretSource(defaultSource)\n\ts.UpdateMappings([]config.AmpUpstreamAPIKeyEntry{\n\t\t{\n\t\t\tUpstreamAPIKey: \"u1\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t\t{\n\t\t\tUpstreamAPIKey: \"u2\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t})\n\n\tctx := context.WithValue(context.Background(), clientAPIKeyContextKey{}, \"k1\")\n\tgot, err := s.Get(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"u1\" {\n\t\tt.Fatalf(\"want u1 (first wins), got %q\", got)\n\t}\n}\n\nfunc TestMappedSecretSource_DuplicateClientKey_LogsWarning(t *testing.T) {\n\thook := test.NewLocal(log.StandardLogger())\n\tdefer hook.Reset()\n\n\tdefaultSource := NewStaticSecretSource(\"default\")\n\ts := NewMappedSecretSource(defaultSource)\n\ts.UpdateMappings([]config.AmpUpstreamAPIKeyEntry{\n\t\t{\n\t\t\tUpstreamAPIKey: \"u1\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t\t{\n\t\t\tUpstreamAPIKey: \"u2\",\n\t\t\tAPIKeys:        []string{\"k1\"},\n\t\t},\n\t})\n\n\tfoundWarning := false\n\tfor _, entry := range hook.AllEntries() {\n\t\tif entry.Level == log.WarnLevel && entry.Message == \"amp upstream-api-keys: client API key appears in multiple entries; using first mapping.\" {\n\t\t\tfoundWarning = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundWarning {\n\t\tt.Fatal(\"expected warning log for duplicate client key, but none was found\")\n\t}\n}\n"
  },
  {
    "path": "internal/api/modules/modules.go",
    "content": "// Package modules provides a pluggable routing module system for extending\n// the API server with optional features without modifying core routing logic.\npackage modules\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n)\n\n// Context encapsulates the dependencies exposed to routing modules during\n// registration. Modules can use the Gin engine to attach routes, the shared\n// BaseAPIHandler for constructing SDK-specific handlers, and the resolved\n// authentication middleware for protecting routes that require API keys.\ntype Context struct {\n\tEngine         *gin.Engine\n\tBaseHandler    *handlers.BaseAPIHandler\n\tConfig         *config.Config\n\tAuthMiddleware gin.HandlerFunc\n}\n\n// RouteModule represents a pluggable routing module that can register routes\n// and handle configuration updates independently of the core server.\n//\n// DEPRECATED: Use RouteModuleV2 for new modules. This interface is kept for\n// backwards compatibility and will be removed in a future version.\ntype RouteModule interface {\n\t// Name returns a human-readable identifier for the module\n\tName() string\n\n\t// Register sets up routes and handlers for this module.\n\t// It receives the Gin engine, base handlers, and current configuration.\n\t// Returns an error if registration fails (errors are logged but don't stop the server).\n\tRegister(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, cfg *config.Config) error\n\n\t// OnConfigUpdated is called when the configuration is reloaded.\n\t// Modules can respond to configuration changes here.\n\t// Returns an error if the update cannot be applied.\n\tOnConfigUpdated(cfg *config.Config) error\n}\n\n// RouteModuleV2 represents a pluggable bundle of routes that can integrate with\n// the API server without modifying its core routing logic. Implementations can\n// attach routes during Register and react to configuration updates via\n// OnConfigUpdated.\n//\n// This is the preferred interface for new modules. It uses Context for cleaner\n// dependency injection and supports idempotent registration.\ntype RouteModuleV2 interface {\n\t// Name returns a unique identifier for logging and diagnostics.\n\tName() string\n\n\t// Register wires the module's routes into the provided Gin engine. Modules\n\t// should treat multiple calls as idempotent and avoid duplicate route\n\t// registration when invoked more than once.\n\tRegister(ctx Context) error\n\n\t// OnConfigUpdated notifies the module when the server configuration changes\n\t// via hot reload. Implementations can refresh cached state or emit warnings.\n\tOnConfigUpdated(cfg *config.Config) error\n}\n\n// RegisterModule is a helper that registers a module using either the V1 or V2\n// interface. This allows gradual migration from V1 to V2 without breaking\n// existing modules.\n//\n// Example usage:\n//\n//\tctx := modules.Context{\n//\t    Engine:         engine,\n//\t    BaseHandler:    baseHandler,\n//\t    Config:         cfg,\n//\t    AuthMiddleware: authMiddleware,\n//\t}\n//\tif err := modules.RegisterModule(ctx, ampModule); err != nil {\n//\t    log.Errorf(\"Failed to register module: %v\", err)\n//\t}\nfunc RegisterModule(ctx Context, mod interface{}) error {\n\t// Try V2 interface first (preferred)\n\tif v2, ok := mod.(RouteModuleV2); ok {\n\t\treturn v2.Register(ctx)\n\t}\n\n\t// Fall back to V1 interface for backwards compatibility\n\tif v1, ok := mod.(RouteModule); ok {\n\t\treturn v1.Register(ctx.Engine, ctx.BaseHandler, ctx.Config)\n\t}\n\n\treturn fmt.Errorf(\"unsupported module type %T (must implement RouteModule or RouteModuleV2)\", mod)\n}\n"
  },
  {
    "path": "internal/api/server.go",
    "content": "// Package api provides the HTTP API server implementation for the CLI Proxy API.\n// It includes the main server struct, routing setup, middleware for CORS and authentication,\n// and integration with various AI API handlers (OpenAI, Claude, Gemini).\n// The server supports hot-reloading of clients and configuration.\npackage api\n\nimport (\n\t\"context\"\n\t\"crypto/subtle\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/access\"\n\tmanagementHandlers \"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules\"\n\tampmodule \"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/usage\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst oauthCallbackSuccessHTML = `<html><head><meta charset=\"utf-8\"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`\n\ntype serverOptionConfig struct {\n\textraMiddleware      []gin.HandlerFunc\n\tengineConfigurator   func(*gin.Engine)\n\trouterConfigurator   func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)\n\trequestLoggerFactory func(*config.Config, string) logging.RequestLogger\n\tlocalPassword        string\n\tkeepAliveEnabled     bool\n\tkeepAliveTimeout     time.Duration\n\tkeepAliveOnTimeout   func()\n\tpostAuthHook         auth.PostAuthHook\n}\n\n// ServerOption customises HTTP server construction.\ntype ServerOption func(*serverOptionConfig)\n\nfunc defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {\n\tconfigDir := filepath.Dir(configPath)\n\tlogsDir := logging.ResolveLogDirectory(cfg)\n\treturn logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles)\n}\n\n// WithMiddleware appends additional Gin middleware during server construction.\nfunc WithMiddleware(mw ...gin.HandlerFunc) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.extraMiddleware = append(cfg.extraMiddleware, mw...)\n\t}\n}\n\n// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.\nfunc WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.engineConfigurator = fn\n\t}\n}\n\n// WithRouterConfigurator appends a callback after default routes are registered.\nfunc WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.routerConfigurator = fn\n\t}\n}\n\n// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.\nfunc WithLocalManagementPassword(password string) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.localPassword = password\n\t}\n}\n\n// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.\nfunc WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tif timeout <= 0 || onTimeout == nil {\n\t\t\treturn\n\t\t}\n\t\tcfg.keepAliveEnabled = true\n\t\tcfg.keepAliveTimeout = timeout\n\t\tcfg.keepAliveOnTimeout = onTimeout\n\t}\n}\n\n// WithRequestLoggerFactory customises request logger creation.\nfunc WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.requestLoggerFactory = factory\n\t}\n}\n\n// WithPostAuthHook registers a hook to be called after auth record creation.\nfunc WithPostAuthHook(hook auth.PostAuthHook) ServerOption {\n\treturn func(cfg *serverOptionConfig) {\n\t\tcfg.postAuthHook = hook\n\t}\n}\n\n// Server represents the main API server.\n// It encapsulates the Gin engine, HTTP server, handlers, and configuration.\ntype Server struct {\n\t// engine is the Gin web framework engine instance.\n\tengine *gin.Engine\n\n\t// server is the underlying HTTP server.\n\tserver *http.Server\n\n\t// handlers contains the API handlers for processing requests.\n\thandlers *handlers.BaseAPIHandler\n\n\t// cfg holds the current server configuration.\n\tcfg *config.Config\n\n\t// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.\n\t// This prevents issues when the config object is modified in place by Management API.\n\toldConfigYaml []byte\n\n\t// accessManager handles request authentication providers.\n\taccessManager *sdkaccess.Manager\n\n\t// requestLogger is the request logger instance for dynamic configuration updates.\n\trequestLogger logging.RequestLogger\n\tloggerToggle  func(bool)\n\n\t// configFilePath is the absolute path to the YAML config file for persistence.\n\tconfigFilePath string\n\n\t// currentPath is the absolute path to the current working directory.\n\tcurrentPath string\n\n\t// wsRoutes tracks registered websocket upgrade paths.\n\twsRouteMu     sync.Mutex\n\twsRoutes      map[string]struct{}\n\twsAuthChanged func(bool, bool)\n\twsAuthEnabled atomic.Bool\n\n\t// management handler\n\tmgmt *managementHandlers.Handler\n\n\t// ampModule is the Amp routing module for model mapping hot-reload\n\tampModule *ampmodule.AmpModule\n\n\t// managementRoutesRegistered tracks whether the management routes have been attached to the engine.\n\tmanagementRoutesRegistered atomic.Bool\n\t// managementRoutesEnabled controls whether management endpoints serve real handlers.\n\tmanagementRoutesEnabled atomic.Bool\n\n\t// envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.\n\tenvManagementSecret bool\n\n\tlocalPassword string\n\n\tkeepAliveEnabled   bool\n\tkeepAliveTimeout   time.Duration\n\tkeepAliveOnTimeout func()\n\tkeepAliveHeartbeat chan struct{}\n\tkeepAliveStop      chan struct{}\n}\n\n// NewServer creates and initializes a new API server instance.\n// It sets up the Gin engine, middleware, routes, and handlers.\n//\n// Parameters:\n//   - cfg: The server configuration\n//   - authManager: core runtime auth manager\n//   - accessManager: request authentication manager\n//\n// Returns:\n//   - *Server: A new server instance\nfunc NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {\n\toptionState := &serverOptionConfig{\n\t\trequestLoggerFactory: defaultRequestLoggerFactory,\n\t}\n\tfor i := range opts {\n\t\topts[i](optionState)\n\t}\n\t// Set gin mode\n\tif !cfg.Debug {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\t// Create gin engine\n\tengine := gin.New()\n\tif optionState.engineConfigurator != nil {\n\t\toptionState.engineConfigurator(engine)\n\t}\n\n\t// Add middleware\n\tengine.Use(logging.GinLogrusLogger())\n\tengine.Use(logging.GinLogrusRecovery())\n\tfor _, mw := range optionState.extraMiddleware {\n\t\tengine.Use(mw)\n\t}\n\n\t// Add request logging middleware (positioned after recovery, before auth)\n\t// Resolve logs directory relative to the configuration file directory.\n\tvar requestLogger logging.RequestLogger\n\tvar toggle func(bool)\n\tif !cfg.CommercialMode {\n\t\tif optionState.requestLoggerFactory != nil {\n\t\t\trequestLogger = optionState.requestLoggerFactory(cfg, configFilePath)\n\t\t}\n\t\tif requestLogger != nil {\n\t\t\tengine.Use(middleware.RequestLoggingMiddleware(requestLogger))\n\t\t\tif setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {\n\t\t\t\ttoggle = setter.SetEnabled\n\t\t\t}\n\t\t}\n\t}\n\n\tengine.Use(corsMiddleware())\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\twd = configFilePath\n\t}\n\n\tenvAdminPassword, envAdminPasswordSet := os.LookupEnv(\"MANAGEMENT_PASSWORD\")\n\tenvAdminPassword = strings.TrimSpace(envAdminPassword)\n\tenvManagementSecret := envAdminPasswordSet && envAdminPassword != \"\"\n\n\t// Create server instance\n\ts := &Server{\n\t\tengine:              engine,\n\t\thandlers:            handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),\n\t\tcfg:                 cfg,\n\t\taccessManager:       accessManager,\n\t\trequestLogger:       requestLogger,\n\t\tloggerToggle:        toggle,\n\t\tconfigFilePath:      configFilePath,\n\t\tcurrentPath:         wd,\n\t\tenvManagementSecret: envManagementSecret,\n\t\twsRoutes:            make(map[string]struct{}),\n\t}\n\ts.wsAuthEnabled.Store(cfg.WebsocketAuth)\n\t// Save initial YAML snapshot\n\ts.oldConfigYaml, _ = yaml.Marshal(cfg)\n\ts.applyAccessConfig(nil, cfg)\n\tif authManager != nil {\n\t\tauthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)\n\t}\n\tmanagementasset.SetCurrentConfig(cfg)\n\tauth.SetQuotaCooldownDisabled(cfg.DisableCooling)\n\t// Initialize management handler\n\ts.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)\n\tif optionState.localPassword != \"\" {\n\t\ts.mgmt.SetLocalPassword(optionState.localPassword)\n\t}\n\tlogDir := logging.ResolveLogDirectory(cfg)\n\ts.mgmt.SetLogDirectory(logDir)\n\tif optionState.postAuthHook != nil {\n\t\ts.mgmt.SetPostAuthHook(optionState.postAuthHook)\n\t}\n\ts.localPassword = optionState.localPassword\n\n\t// Setup routes\n\ts.setupRoutes()\n\n\t// Register Amp module using V2 interface with Context\n\ts.ampModule = ampmodule.NewLegacy(accessManager, AuthMiddleware(accessManager))\n\tctx := modules.Context{\n\t\tEngine:         engine,\n\t\tBaseHandler:    s.handlers,\n\t\tConfig:         cfg,\n\t\tAuthMiddleware: AuthMiddleware(accessManager),\n\t}\n\tif err := modules.RegisterModule(ctx, s.ampModule); err != nil {\n\t\tlog.Errorf(\"Failed to register Amp module: %v\", err)\n\t}\n\n\t// Apply additional router configurators from options\n\tif optionState.routerConfigurator != nil {\n\t\toptionState.routerConfigurator(engine, s.handlers, cfg)\n\t}\n\n\t// Register management routes when configuration or environment secrets are available,\n\t// or when a local management password is provided (e.g. TUI mode).\n\thasManagementSecret := cfg.RemoteManagement.SecretKey != \"\" || envManagementSecret || s.localPassword != \"\"\n\ts.managementRoutesEnabled.Store(hasManagementSecret)\n\tif hasManagementSecret {\n\t\ts.registerManagementRoutes()\n\t}\n\n\tif optionState.keepAliveEnabled {\n\t\ts.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)\n\t}\n\n\t// Create HTTP server\n\ts.server = &http.Server{\n\t\tAddr:    fmt.Sprintf(\"%s:%d\", cfg.Host, cfg.Port),\n\t\tHandler: engine,\n\t}\n\n\treturn s\n}\n\n// setupRoutes configures the API routes for the server.\n// It defines the endpoints and associates them with their respective handlers.\nfunc (s *Server) setupRoutes() {\n\ts.engine.GET(\"/management.html\", s.serveManagementControlPanel)\n\topenaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)\n\tgeminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)\n\tgeminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)\n\tclaudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)\n\topenaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)\n\n\t// OpenAI compatible API routes\n\tv1 := s.engine.Group(\"/v1\")\n\tv1.Use(AuthMiddleware(s.accessManager))\n\t{\n\t\tv1.GET(\"/models\", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))\n\t\tv1.POST(\"/chat/completions\", openaiHandlers.ChatCompletions)\n\t\tv1.POST(\"/completions\", openaiHandlers.Completions)\n\t\tv1.POST(\"/messages\", claudeCodeHandlers.ClaudeMessages)\n\t\tv1.POST(\"/messages/count_tokens\", claudeCodeHandlers.ClaudeCountTokens)\n\t\tv1.GET(\"/responses\", openaiResponsesHandlers.ResponsesWebsocket)\n\t\tv1.POST(\"/responses\", openaiResponsesHandlers.Responses)\n\t\tv1.POST(\"/responses/compact\", openaiResponsesHandlers.Compact)\n\t}\n\n\t// Gemini compatible API routes\n\tv1beta := s.engine.Group(\"/v1beta\")\n\tv1beta.Use(AuthMiddleware(s.accessManager))\n\t{\n\t\tv1beta.GET(\"/models\", geminiHandlers.GeminiModels)\n\t\tv1beta.POST(\"/models/*action\", geminiHandlers.GeminiHandler)\n\t\tv1beta.GET(\"/models/*action\", geminiHandlers.GeminiGetHandler)\n\t}\n\n\t// Root endpoint\n\ts.engine.GET(\"/\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"CLI Proxy API Server\",\n\t\t\t\"endpoints\": []string{\n\t\t\t\t\"POST /v1/chat/completions\",\n\t\t\t\t\"POST /v1/completions\",\n\t\t\t\t\"GET /v1/models\",\n\t\t\t},\n\t\t})\n\t})\n\ts.engine.POST(\"/v1internal:method\", geminiCLIHandlers.CLIHandler)\n\n\t// OAuth callback endpoints (reuse main server port)\n\t// These endpoints receive provider redirects and persist\n\t// the short-lived code/state for the waiting goroutine.\n\ts.engine.GET(\"/anthropic/callback\", func(c *gin.Context) {\n\t\tcode := c.Query(\"code\")\n\t\tstate := c.Query(\"state\")\n\t\terrStr := c.Query(\"error\")\n\t\tif errStr == \"\" {\n\t\t\terrStr = c.Query(\"error_description\")\n\t\t}\n\t\tif state != \"\" {\n\t\t\t_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, \"anthropic\", state, code, errStr)\n\t\t}\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, oauthCallbackSuccessHTML)\n\t})\n\n\ts.engine.GET(\"/codex/callback\", func(c *gin.Context) {\n\t\tcode := c.Query(\"code\")\n\t\tstate := c.Query(\"state\")\n\t\terrStr := c.Query(\"error\")\n\t\tif errStr == \"\" {\n\t\t\terrStr = c.Query(\"error_description\")\n\t\t}\n\t\tif state != \"\" {\n\t\t\t_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, \"codex\", state, code, errStr)\n\t\t}\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, oauthCallbackSuccessHTML)\n\t})\n\n\ts.engine.GET(\"/google/callback\", func(c *gin.Context) {\n\t\tcode := c.Query(\"code\")\n\t\tstate := c.Query(\"state\")\n\t\terrStr := c.Query(\"error\")\n\t\tif errStr == \"\" {\n\t\t\terrStr = c.Query(\"error_description\")\n\t\t}\n\t\tif state != \"\" {\n\t\t\t_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, \"gemini\", state, code, errStr)\n\t\t}\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, oauthCallbackSuccessHTML)\n\t})\n\n\ts.engine.GET(\"/iflow/callback\", func(c *gin.Context) {\n\t\tcode := c.Query(\"code\")\n\t\tstate := c.Query(\"state\")\n\t\terrStr := c.Query(\"error\")\n\t\tif errStr == \"\" {\n\t\t\terrStr = c.Query(\"error_description\")\n\t\t}\n\t\tif state != \"\" {\n\t\t\t_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, \"iflow\", state, code, errStr)\n\t\t}\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, oauthCallbackSuccessHTML)\n\t})\n\n\ts.engine.GET(\"/antigravity/callback\", func(c *gin.Context) {\n\t\tcode := c.Query(\"code\")\n\t\tstate := c.Query(\"state\")\n\t\terrStr := c.Query(\"error\")\n\t\tif errStr == \"\" {\n\t\t\terrStr = c.Query(\"error_description\")\n\t\t}\n\t\tif state != \"\" {\n\t\t\t_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, \"antigravity\", state, code, errStr)\n\t\t}\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, oauthCallbackSuccessHTML)\n\t})\n\n\t// Management routes are registered lazily by registerManagementRoutes when a secret is configured.\n}\n\n// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine.\n// The handler is served as-is without additional middleware beyond the standard stack already configured.\nfunc (s *Server) AttachWebsocketRoute(path string, handler http.Handler) {\n\tif s == nil || s.engine == nil || handler == nil {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(path)\n\tif trimmed == \"\" {\n\t\ttrimmed = \"/v1/ws\"\n\t}\n\tif !strings.HasPrefix(trimmed, \"/\") {\n\t\ttrimmed = \"/\" + trimmed\n\t}\n\ts.wsRouteMu.Lock()\n\tif _, exists := s.wsRoutes[trimmed]; exists {\n\t\ts.wsRouteMu.Unlock()\n\t\treturn\n\t}\n\ts.wsRoutes[trimmed] = struct{}{}\n\ts.wsRouteMu.Unlock()\n\n\tauthMiddleware := AuthMiddleware(s.accessManager)\n\tconditionalAuth := func(c *gin.Context) {\n\t\tif !s.wsAuthEnabled.Load() {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t\tauthMiddleware(c)\n\t}\n\tfinalHandler := func(c *gin.Context) {\n\t\thandler.ServeHTTP(c.Writer, c.Request)\n\t\tc.Abort()\n\t}\n\n\ts.engine.GET(trimmed, conditionalAuth, finalHandler)\n}\n\nfunc (s *Server) registerManagementRoutes() {\n\tif s == nil || s.engine == nil || s.mgmt == nil {\n\t\treturn\n\t}\n\tif !s.managementRoutesRegistered.CompareAndSwap(false, true) {\n\t\treturn\n\t}\n\n\tlog.Info(\"management routes registered after secret key configuration\")\n\n\tmgmt := s.engine.Group(\"/v0/management\")\n\tmgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())\n\t{\n\t\tmgmt.GET(\"/usage\", s.mgmt.GetUsageStatistics)\n\t\tmgmt.GET(\"/usage/export\", s.mgmt.ExportUsageStatistics)\n\t\tmgmt.POST(\"/usage/import\", s.mgmt.ImportUsageStatistics)\n\t\tmgmt.GET(\"/config\", s.mgmt.GetConfig)\n\t\tmgmt.GET(\"/config.yaml\", s.mgmt.GetConfigYAML)\n\t\tmgmt.PUT(\"/config.yaml\", s.mgmt.PutConfigYAML)\n\t\tmgmt.GET(\"/latest-version\", s.mgmt.GetLatestVersion)\n\n\t\tmgmt.GET(\"/debug\", s.mgmt.GetDebug)\n\t\tmgmt.PUT(\"/debug\", s.mgmt.PutDebug)\n\t\tmgmt.PATCH(\"/debug\", s.mgmt.PutDebug)\n\n\t\tmgmt.GET(\"/logging-to-file\", s.mgmt.GetLoggingToFile)\n\t\tmgmt.PUT(\"/logging-to-file\", s.mgmt.PutLoggingToFile)\n\t\tmgmt.PATCH(\"/logging-to-file\", s.mgmt.PutLoggingToFile)\n\n\t\tmgmt.GET(\"/logs-max-total-size-mb\", s.mgmt.GetLogsMaxTotalSizeMB)\n\t\tmgmt.PUT(\"/logs-max-total-size-mb\", s.mgmt.PutLogsMaxTotalSizeMB)\n\t\tmgmt.PATCH(\"/logs-max-total-size-mb\", s.mgmt.PutLogsMaxTotalSizeMB)\n\n\t\tmgmt.GET(\"/error-logs-max-files\", s.mgmt.GetErrorLogsMaxFiles)\n\t\tmgmt.PUT(\"/error-logs-max-files\", s.mgmt.PutErrorLogsMaxFiles)\n\t\tmgmt.PATCH(\"/error-logs-max-files\", s.mgmt.PutErrorLogsMaxFiles)\n\n\t\tmgmt.GET(\"/usage-statistics-enabled\", s.mgmt.GetUsageStatisticsEnabled)\n\t\tmgmt.PUT(\"/usage-statistics-enabled\", s.mgmt.PutUsageStatisticsEnabled)\n\t\tmgmt.PATCH(\"/usage-statistics-enabled\", s.mgmt.PutUsageStatisticsEnabled)\n\n\t\tmgmt.GET(\"/proxy-url\", s.mgmt.GetProxyURL)\n\t\tmgmt.PUT(\"/proxy-url\", s.mgmt.PutProxyURL)\n\t\tmgmt.PATCH(\"/proxy-url\", s.mgmt.PutProxyURL)\n\t\tmgmt.DELETE(\"/proxy-url\", s.mgmt.DeleteProxyURL)\n\n\t\tmgmt.POST(\"/api-call\", s.mgmt.APICall)\n\n\t\tmgmt.GET(\"/quota-exceeded/switch-project\", s.mgmt.GetSwitchProject)\n\t\tmgmt.PUT(\"/quota-exceeded/switch-project\", s.mgmt.PutSwitchProject)\n\t\tmgmt.PATCH(\"/quota-exceeded/switch-project\", s.mgmt.PutSwitchProject)\n\n\t\tmgmt.GET(\"/quota-exceeded/switch-preview-model\", s.mgmt.GetSwitchPreviewModel)\n\t\tmgmt.PUT(\"/quota-exceeded/switch-preview-model\", s.mgmt.PutSwitchPreviewModel)\n\t\tmgmt.PATCH(\"/quota-exceeded/switch-preview-model\", s.mgmt.PutSwitchPreviewModel)\n\n\t\tmgmt.GET(\"/api-keys\", s.mgmt.GetAPIKeys)\n\t\tmgmt.PUT(\"/api-keys\", s.mgmt.PutAPIKeys)\n\t\tmgmt.PATCH(\"/api-keys\", s.mgmt.PatchAPIKeys)\n\t\tmgmt.DELETE(\"/api-keys\", s.mgmt.DeleteAPIKeys)\n\n\t\tmgmt.GET(\"/gemini-api-key\", s.mgmt.GetGeminiKeys)\n\t\tmgmt.PUT(\"/gemini-api-key\", s.mgmt.PutGeminiKeys)\n\t\tmgmt.PATCH(\"/gemini-api-key\", s.mgmt.PatchGeminiKey)\n\t\tmgmt.DELETE(\"/gemini-api-key\", s.mgmt.DeleteGeminiKey)\n\n\t\tmgmt.GET(\"/logs\", s.mgmt.GetLogs)\n\t\tmgmt.DELETE(\"/logs\", s.mgmt.DeleteLogs)\n\t\tmgmt.GET(\"/request-error-logs\", s.mgmt.GetRequestErrorLogs)\n\t\tmgmt.GET(\"/request-error-logs/:name\", s.mgmt.DownloadRequestErrorLog)\n\t\tmgmt.GET(\"/request-log-by-id/:id\", s.mgmt.GetRequestLogByID)\n\t\tmgmt.GET(\"/request-log\", s.mgmt.GetRequestLog)\n\t\tmgmt.PUT(\"/request-log\", s.mgmt.PutRequestLog)\n\t\tmgmt.PATCH(\"/request-log\", s.mgmt.PutRequestLog)\n\t\tmgmt.GET(\"/ws-auth\", s.mgmt.GetWebsocketAuth)\n\t\tmgmt.PUT(\"/ws-auth\", s.mgmt.PutWebsocketAuth)\n\t\tmgmt.PATCH(\"/ws-auth\", s.mgmt.PutWebsocketAuth)\n\n\t\tmgmt.GET(\"/ampcode\", s.mgmt.GetAmpCode)\n\t\tmgmt.GET(\"/ampcode/upstream-url\", s.mgmt.GetAmpUpstreamURL)\n\t\tmgmt.PUT(\"/ampcode/upstream-url\", s.mgmt.PutAmpUpstreamURL)\n\t\tmgmt.PATCH(\"/ampcode/upstream-url\", s.mgmt.PutAmpUpstreamURL)\n\t\tmgmt.DELETE(\"/ampcode/upstream-url\", s.mgmt.DeleteAmpUpstreamURL)\n\t\tmgmt.GET(\"/ampcode/upstream-api-key\", s.mgmt.GetAmpUpstreamAPIKey)\n\t\tmgmt.PUT(\"/ampcode/upstream-api-key\", s.mgmt.PutAmpUpstreamAPIKey)\n\t\tmgmt.PATCH(\"/ampcode/upstream-api-key\", s.mgmt.PutAmpUpstreamAPIKey)\n\t\tmgmt.DELETE(\"/ampcode/upstream-api-key\", s.mgmt.DeleteAmpUpstreamAPIKey)\n\t\tmgmt.GET(\"/ampcode/restrict-management-to-localhost\", s.mgmt.GetAmpRestrictManagementToLocalhost)\n\t\tmgmt.PUT(\"/ampcode/restrict-management-to-localhost\", s.mgmt.PutAmpRestrictManagementToLocalhost)\n\t\tmgmt.PATCH(\"/ampcode/restrict-management-to-localhost\", s.mgmt.PutAmpRestrictManagementToLocalhost)\n\t\tmgmt.GET(\"/ampcode/model-mappings\", s.mgmt.GetAmpModelMappings)\n\t\tmgmt.PUT(\"/ampcode/model-mappings\", s.mgmt.PutAmpModelMappings)\n\t\tmgmt.PATCH(\"/ampcode/model-mappings\", s.mgmt.PatchAmpModelMappings)\n\t\tmgmt.DELETE(\"/ampcode/model-mappings\", s.mgmt.DeleteAmpModelMappings)\n\t\tmgmt.GET(\"/ampcode/force-model-mappings\", s.mgmt.GetAmpForceModelMappings)\n\t\tmgmt.PUT(\"/ampcode/force-model-mappings\", s.mgmt.PutAmpForceModelMappings)\n\t\tmgmt.PATCH(\"/ampcode/force-model-mappings\", s.mgmt.PutAmpForceModelMappings)\n\t\tmgmt.GET(\"/ampcode/upstream-api-keys\", s.mgmt.GetAmpUpstreamAPIKeys)\n\t\tmgmt.PUT(\"/ampcode/upstream-api-keys\", s.mgmt.PutAmpUpstreamAPIKeys)\n\t\tmgmt.PATCH(\"/ampcode/upstream-api-keys\", s.mgmt.PatchAmpUpstreamAPIKeys)\n\t\tmgmt.DELETE(\"/ampcode/upstream-api-keys\", s.mgmt.DeleteAmpUpstreamAPIKeys)\n\n\t\tmgmt.GET(\"/request-retry\", s.mgmt.GetRequestRetry)\n\t\tmgmt.PUT(\"/request-retry\", s.mgmt.PutRequestRetry)\n\t\tmgmt.PATCH(\"/request-retry\", s.mgmt.PutRequestRetry)\n\t\tmgmt.GET(\"/max-retry-interval\", s.mgmt.GetMaxRetryInterval)\n\t\tmgmt.PUT(\"/max-retry-interval\", s.mgmt.PutMaxRetryInterval)\n\t\tmgmt.PATCH(\"/max-retry-interval\", s.mgmt.PutMaxRetryInterval)\n\n\t\tmgmt.GET(\"/force-model-prefix\", s.mgmt.GetForceModelPrefix)\n\t\tmgmt.PUT(\"/force-model-prefix\", s.mgmt.PutForceModelPrefix)\n\t\tmgmt.PATCH(\"/force-model-prefix\", s.mgmt.PutForceModelPrefix)\n\n\t\tmgmt.GET(\"/routing/strategy\", s.mgmt.GetRoutingStrategy)\n\t\tmgmt.PUT(\"/routing/strategy\", s.mgmt.PutRoutingStrategy)\n\t\tmgmt.PATCH(\"/routing/strategy\", s.mgmt.PutRoutingStrategy)\n\n\t\tmgmt.GET(\"/claude-api-key\", s.mgmt.GetClaudeKeys)\n\t\tmgmt.PUT(\"/claude-api-key\", s.mgmt.PutClaudeKeys)\n\t\tmgmt.PATCH(\"/claude-api-key\", s.mgmt.PatchClaudeKey)\n\t\tmgmt.DELETE(\"/claude-api-key\", s.mgmt.DeleteClaudeKey)\n\n\t\tmgmt.GET(\"/codex-api-key\", s.mgmt.GetCodexKeys)\n\t\tmgmt.PUT(\"/codex-api-key\", s.mgmt.PutCodexKeys)\n\t\tmgmt.PATCH(\"/codex-api-key\", s.mgmt.PatchCodexKey)\n\t\tmgmt.DELETE(\"/codex-api-key\", s.mgmt.DeleteCodexKey)\n\n\t\tmgmt.GET(\"/openai-compatibility\", s.mgmt.GetOpenAICompat)\n\t\tmgmt.PUT(\"/openai-compatibility\", s.mgmt.PutOpenAICompat)\n\t\tmgmt.PATCH(\"/openai-compatibility\", s.mgmt.PatchOpenAICompat)\n\t\tmgmt.DELETE(\"/openai-compatibility\", s.mgmt.DeleteOpenAICompat)\n\n\t\tmgmt.GET(\"/vertex-api-key\", s.mgmt.GetVertexCompatKeys)\n\t\tmgmt.PUT(\"/vertex-api-key\", s.mgmt.PutVertexCompatKeys)\n\t\tmgmt.PATCH(\"/vertex-api-key\", s.mgmt.PatchVertexCompatKey)\n\t\tmgmt.DELETE(\"/vertex-api-key\", s.mgmt.DeleteVertexCompatKey)\n\n\t\tmgmt.GET(\"/oauth-excluded-models\", s.mgmt.GetOAuthExcludedModels)\n\t\tmgmt.PUT(\"/oauth-excluded-models\", s.mgmt.PutOAuthExcludedModels)\n\t\tmgmt.PATCH(\"/oauth-excluded-models\", s.mgmt.PatchOAuthExcludedModels)\n\t\tmgmt.DELETE(\"/oauth-excluded-models\", s.mgmt.DeleteOAuthExcludedModels)\n\n\t\tmgmt.GET(\"/oauth-model-alias\", s.mgmt.GetOAuthModelAlias)\n\t\tmgmt.PUT(\"/oauth-model-alias\", s.mgmt.PutOAuthModelAlias)\n\t\tmgmt.PATCH(\"/oauth-model-alias\", s.mgmt.PatchOAuthModelAlias)\n\t\tmgmt.DELETE(\"/oauth-model-alias\", s.mgmt.DeleteOAuthModelAlias)\n\n\t\tmgmt.GET(\"/auth-files\", s.mgmt.ListAuthFiles)\n\t\tmgmt.GET(\"/auth-files/models\", s.mgmt.GetAuthFileModels)\n\t\tmgmt.GET(\"/model-definitions/:channel\", s.mgmt.GetStaticModelDefinitions)\n\t\tmgmt.GET(\"/auth-files/download\", s.mgmt.DownloadAuthFile)\n\t\tmgmt.POST(\"/auth-files\", s.mgmt.UploadAuthFile)\n\t\tmgmt.DELETE(\"/auth-files\", s.mgmt.DeleteAuthFile)\n\t\tmgmt.PATCH(\"/auth-files/status\", s.mgmt.PatchAuthFileStatus)\n\t\tmgmt.PATCH(\"/auth-files/fields\", s.mgmt.PatchAuthFileFields)\n\t\tmgmt.POST(\"/vertex/import\", s.mgmt.ImportVertexCredential)\n\n\t\tmgmt.GET(\"/anthropic-auth-url\", s.mgmt.RequestAnthropicToken)\n\t\tmgmt.GET(\"/codex-auth-url\", s.mgmt.RequestCodexToken)\n\t\tmgmt.GET(\"/gemini-cli-auth-url\", s.mgmt.RequestGeminiCLIToken)\n\t\tmgmt.GET(\"/antigravity-auth-url\", s.mgmt.RequestAntigravityToken)\n\t\tmgmt.GET(\"/qwen-auth-url\", s.mgmt.RequestQwenToken)\n\t\tmgmt.GET(\"/kimi-auth-url\", s.mgmt.RequestKimiToken)\n\t\tmgmt.GET(\"/iflow-auth-url\", s.mgmt.RequestIFlowToken)\n\t\tmgmt.POST(\"/iflow-auth-url\", s.mgmt.RequestIFlowCookieToken)\n\t\tmgmt.POST(\"/oauth-callback\", s.mgmt.PostOAuthCallback)\n\t\tmgmt.GET(\"/get-auth-status\", s.mgmt.GetAuthStatus)\n\t}\n}\n\nfunc (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif !s.managementRoutesEnabled.Load() {\n\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}\n\nfunc (s *Server) serveManagementControlPanel(c *gin.Context) {\n\tcfg := s.cfg\n\tif cfg == nil || cfg.RemoteManagement.DisableControlPanel {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\treturn\n\t}\n\tfilePath := managementasset.FilePath(s.configFilePath)\n\tif strings.TrimSpace(filePath) == \"\" {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\treturn\n\t}\n\n\tif _, err := os.Stat(filePath); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\t// Synchronously ensure management.html is available with a detached context.\n\t\t\t// Control panel bootstrap should not be canceled by client disconnects.\n\t\t\tif !managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) {\n\t\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tlog.WithError(err).Error(\"failed to stat management control panel asset\")\n\t\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.File(filePath)\n}\n\nfunc (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {\n\tif timeout <= 0 || onTimeout == nil {\n\t\treturn\n\t}\n\n\ts.keepAliveEnabled = true\n\ts.keepAliveTimeout = timeout\n\ts.keepAliveOnTimeout = onTimeout\n\ts.keepAliveHeartbeat = make(chan struct{}, 1)\n\ts.keepAliveStop = make(chan struct{}, 1)\n\n\ts.engine.GET(\"/keep-alive\", s.handleKeepAlive)\n\n\tgo s.watchKeepAlive()\n}\n\nfunc (s *Server) handleKeepAlive(c *gin.Context) {\n\tif s.localPassword != \"\" {\n\t\tprovided := strings.TrimSpace(c.GetHeader(\"Authorization\"))\n\t\tif provided != \"\" {\n\t\t\tparts := strings.SplitN(provided, \" \", 2)\n\t\t\tif len(parts) == 2 && strings.EqualFold(parts[0], \"bearer\") {\n\t\t\t\tprovided = parts[1]\n\t\t\t}\n\t\t}\n\t\tif provided == \"\" {\n\t\t\tprovided = strings.TrimSpace(c.GetHeader(\"X-Local-Password\"))\n\t\t}\n\t\tif subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"invalid password\"})\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.signalKeepAlive()\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n}\n\nfunc (s *Server) signalKeepAlive() {\n\tif !s.keepAliveEnabled {\n\t\treturn\n\t}\n\tselect {\n\tcase s.keepAliveHeartbeat <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc (s *Server) watchKeepAlive() {\n\tif !s.keepAliveEnabled {\n\t\treturn\n\t}\n\n\ttimer := time.NewTimer(s.keepAliveTimeout)\n\tdefer timer.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-timer.C:\n\t\t\tlog.Warnf(\"keep-alive endpoint idle for %s, shutting down\", s.keepAliveTimeout)\n\t\t\tif s.keepAliveOnTimeout != nil {\n\t\t\t\ts.keepAliveOnTimeout()\n\t\t\t}\n\t\t\treturn\n\t\tcase <-s.keepAliveHeartbeat:\n\t\t\tif !timer.Stop() {\n\t\t\t\tselect {\n\t\t\t\tcase <-timer.C:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t\ttimer.Reset(s.keepAliveTimeout)\n\t\tcase <-s.keepAliveStop:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// unifiedModelsHandler creates a unified handler for the /v1/models endpoint\n// that routes to different handlers based on the User-Agent header.\n// If User-Agent starts with \"claude-cli\", it routes to Claude handler,\n// otherwise it routes to OpenAI handler.\nfunc (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tuserAgent := c.GetHeader(\"User-Agent\")\n\n\t\t// Route to Claude handler if User-Agent starts with \"claude-cli\"\n\t\tif strings.HasPrefix(userAgent, \"claude-cli\") {\n\t\t\t// log.Debugf(\"Routing /v1/models to Claude handler for User-Agent: %s\", userAgent)\n\t\t\tclaudeHandler.ClaudeModels(c)\n\t\t} else {\n\t\t\t// log.Debugf(\"Routing /v1/models to OpenAI handler for User-Agent: %s\", userAgent)\n\t\t\topenaiHandler.OpenAIModels(c)\n\t\t}\n\t}\n}\n\n// Start begins listening for and serving HTTP or HTTPS requests.\n// It's a blocking call and will only return on an unrecoverable error.\n//\n// Returns:\n//   - error: An error if the server fails to start\nfunc (s *Server) Start() error {\n\tif s == nil || s.server == nil {\n\t\treturn fmt.Errorf(\"failed to start HTTP server: server not initialized\")\n\t}\n\n\tuseTLS := s.cfg != nil && s.cfg.TLS.Enable\n\tif useTLS {\n\t\tcert := strings.TrimSpace(s.cfg.TLS.Cert)\n\t\tkey := strings.TrimSpace(s.cfg.TLS.Key)\n\t\tif cert == \"\" || key == \"\" {\n\t\t\treturn fmt.Errorf(\"failed to start HTTPS server: tls.cert or tls.key is empty\")\n\t\t}\n\t\tlog.Debugf(\"Starting API server on %s with TLS\", s.server.Addr)\n\t\tif errServeTLS := s.server.ListenAndServeTLS(cert, key); errServeTLS != nil && !errors.Is(errServeTLS, http.ErrServerClosed) {\n\t\t\treturn fmt.Errorf(\"failed to start HTTPS server: %v\", errServeTLS)\n\t\t}\n\t\treturn nil\n\t}\n\n\tlog.Debugf(\"Starting API server on %s\", s.server.Addr)\n\tif errServe := s.server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {\n\t\treturn fmt.Errorf(\"failed to start HTTP server: %v\", errServe)\n\t}\n\n\treturn nil\n}\n\n// Stop gracefully shuts down the API server without interrupting any\n// active connections.\n//\n// Parameters:\n//   - ctx: The context for graceful shutdown\n//\n// Returns:\n//   - error: An error if the server fails to stop\nfunc (s *Server) Stop(ctx context.Context) error {\n\tlog.Debug(\"Stopping API server...\")\n\n\tif s.keepAliveEnabled {\n\t\tselect {\n\t\tcase s.keepAliveStop <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t}\n\n\t// Shutdown the HTTP server.\n\tif err := s.server.Shutdown(ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to shutdown HTTP server: %v\", err)\n\t}\n\n\tlog.Debug(\"API server stopped\")\n\treturn nil\n}\n\n// corsMiddleware returns a Gin middleware handler that adds CORS headers\n// to every response, allowing cross-origin requests.\n//\n// Returns:\n//   - gin.HandlerFunc: The CORS middleware handler\nfunc corsMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t\tc.Header(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, PATCH, DELETE, OPTIONS\")\n\t\tc.Header(\"Access-Control-Allow-Headers\", \"*\")\n\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(http.StatusNoContent)\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\nfunc (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {\n\tif s == nil || s.accessManager == nil || newCfg == nil {\n\t\treturn\n\t}\n\tif _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {\n\t\treturn\n\t}\n}\n\n// UpdateClients updates the server's client list and configuration.\n// This method is called when the configuration or authentication tokens change.\n//\n// Parameters:\n//   - clients: The new slice of AI service clients\n//   - cfg: The new application configuration\nfunc (s *Server) UpdateClients(cfg *config.Config) {\n\t// Reconstruct old config from YAML snapshot to avoid reference sharing issues\n\tvar oldCfg *config.Config\n\tif len(s.oldConfigYaml) > 0 {\n\t\t_ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)\n\t}\n\n\t// Update request logger enabled state if it has changed\n\tpreviousRequestLog := false\n\tif oldCfg != nil {\n\t\tpreviousRequestLog = oldCfg.RequestLog\n\t}\n\tif s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {\n\t\tif s.loggerToggle != nil {\n\t\t\ts.loggerToggle(cfg.RequestLog)\n\t\t} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {\n\t\t\ttoggler.SetEnabled(cfg.RequestLog)\n\t\t}\n\t}\n\n\tif oldCfg == nil || oldCfg.LoggingToFile != cfg.LoggingToFile || oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB {\n\t\tif err := logging.ConfigureLogOutput(cfg); err != nil {\n\t\t\tlog.Errorf(\"failed to reconfigure log output: %v\", err)\n\t\t}\n\t}\n\n\tif oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {\n\t\tusage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)\n\t}\n\n\tif s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) {\n\t\tif setter, ok := s.requestLogger.(interface{ SetErrorLogsMaxFiles(int) }); ok {\n\t\t\tsetter.SetErrorLogsMaxFiles(cfg.ErrorLogsMaxFiles)\n\t\t}\n\t}\n\n\tif oldCfg == nil || oldCfg.DisableCooling != cfg.DisableCooling {\n\t\tauth.SetQuotaCooldownDisabled(cfg.DisableCooling)\n\t}\n\n\tif s.handlers != nil && s.handlers.AuthManager != nil {\n\t\ts.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)\n\t}\n\n\t// Update log level dynamically when debug flag changes\n\tif oldCfg == nil || oldCfg.Debug != cfg.Debug {\n\t\tutil.SetLogLevel(cfg)\n\t}\n\n\tprevSecretEmpty := true\n\tif oldCfg != nil {\n\t\tprevSecretEmpty = oldCfg.RemoteManagement.SecretKey == \"\"\n\t}\n\tnewSecretEmpty := cfg.RemoteManagement.SecretKey == \"\"\n\tif s.envManagementSecret {\n\t\ts.registerManagementRoutes()\n\t\tif s.managementRoutesEnabled.CompareAndSwap(false, true) {\n\t\t\tlog.Info(\"management routes enabled via MANAGEMENT_PASSWORD\")\n\t\t} else {\n\t\t\ts.managementRoutesEnabled.Store(true)\n\t\t}\n\t} else {\n\t\tswitch {\n\t\tcase prevSecretEmpty && !newSecretEmpty:\n\t\t\ts.registerManagementRoutes()\n\t\t\tif s.managementRoutesEnabled.CompareAndSwap(false, true) {\n\t\t\t\tlog.Info(\"management routes enabled after secret key update\")\n\t\t\t} else {\n\t\t\t\ts.managementRoutesEnabled.Store(true)\n\t\t\t}\n\t\tcase !prevSecretEmpty && newSecretEmpty:\n\t\t\tif s.managementRoutesEnabled.CompareAndSwap(true, false) {\n\t\t\t\tlog.Info(\"management routes disabled after secret key removal\")\n\t\t\t} else {\n\t\t\t\ts.managementRoutesEnabled.Store(false)\n\t\t\t}\n\t\tdefault:\n\t\t\ts.managementRoutesEnabled.Store(!newSecretEmpty)\n\t\t}\n\t}\n\n\ts.applyAccessConfig(oldCfg, cfg)\n\ts.cfg = cfg\n\ts.wsAuthEnabled.Store(cfg.WebsocketAuth)\n\tif oldCfg != nil && s.wsAuthChanged != nil && oldCfg.WebsocketAuth != cfg.WebsocketAuth {\n\t\ts.wsAuthChanged(oldCfg.WebsocketAuth, cfg.WebsocketAuth)\n\t}\n\tmanagementasset.SetCurrentConfig(cfg)\n\t// Save YAML snapshot for next comparison\n\ts.oldConfigYaml, _ = yaml.Marshal(cfg)\n\n\ts.handlers.UpdateClients(&cfg.SDKConfig)\n\n\tif s.mgmt != nil {\n\t\ts.mgmt.SetConfig(cfg)\n\t\ts.mgmt.SetAuthManager(s.handlers.AuthManager)\n\t}\n\n\t// Notify Amp module only when Amp config has changed.\n\tampConfigChanged := oldCfg == nil || !reflect.DeepEqual(oldCfg.AmpCode, cfg.AmpCode)\n\tif ampConfigChanged {\n\t\tif s.ampModule != nil {\n\t\t\tlog.Debugf(\"triggering amp module config update\")\n\t\t\tif err := s.ampModule.OnConfigUpdated(cfg); err != nil {\n\t\t\t\tlog.Errorf(\"failed to update Amp module config: %v\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Warnf(\"amp module is nil, skipping config update\")\n\t\t}\n\t}\n\n\t// Count client sources from configuration and auth store.\n\ttokenStore := sdkAuth.GetTokenStore()\n\tif dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {\n\t\tdirSetter.SetBaseDir(cfg.AuthDir)\n\t}\n\tauthEntries := util.CountAuthFiles(context.Background(), tokenStore)\n\tgeminiAPIKeyCount := len(cfg.GeminiKey)\n\tclaudeAPIKeyCount := len(cfg.ClaudeKey)\n\tcodexAPIKeyCount := len(cfg.CodexKey)\n\tvertexAICompatCount := len(cfg.VertexCompatAPIKey)\n\topenAICompatCount := 0\n\tfor i := range cfg.OpenAICompatibility {\n\t\tentry := cfg.OpenAICompatibility[i]\n\t\topenAICompatCount += len(entry.APIKeyEntries)\n\t}\n\n\ttotal := authEntries + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount\n\tfmt.Printf(\"server clients and configuration updated: %d clients (%d auth entries + %d Gemini API keys + %d Claude API keys + %d Codex keys + %d Vertex-compat + %d OpenAI-compat)\\n\",\n\t\ttotal,\n\t\tauthEntries,\n\t\tgeminiAPIKeyCount,\n\t\tclaudeAPIKeyCount,\n\t\tcodexAPIKeyCount,\n\t\tvertexAICompatCount,\n\t\topenAICompatCount,\n\t)\n}\n\nfunc (s *Server) SetWebsocketAuthChangeHandler(fn func(bool, bool)) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.wsAuthChanged = fn\n}\n\n// (management handlers moved to internal/api/handlers/management)\n\n// AuthMiddleware returns a Gin middleware handler that authenticates requests\n// using the configured authentication providers. When no providers are available,\n// it allows all requests (legacy behaviour).\nfunc AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif manager == nil {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tresult, err := manager.Authenticate(c.Request.Context(), c.Request)\n\t\tif err == nil {\n\t\t\tif result != nil {\n\t\t\t\tc.Set(\"apiKey\", result.Principal)\n\t\t\t\tc.Set(\"accessProvider\", result.Provider)\n\t\t\t\tif len(result.Metadata) > 0 {\n\t\t\t\t\tc.Set(\"accessMetadata\", result.Metadata)\n\t\t\t\t}\n\t\t\t}\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tstatusCode := err.HTTPStatusCode()\n\t\tif statusCode >= http.StatusInternalServerError {\n\t\t\tlog.Errorf(\"authentication middleware error: %v\", err)\n\t\t}\n\t\tc.AbortWithStatusJSON(statusCode, gin.H{\"error\": err.Message})\n\t}\n}\n"
  },
  {
    "path": "internal/api/server_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\tgin \"github.com/gin-gonic/gin\"\n\tproxyconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tinternallogging \"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc newTestServer(t *testing.T) *Server {\n\tt.Helper()\n\n\tgin.SetMode(gin.TestMode)\n\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o700); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\n\tcfg := &proxyconfig.Config{\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tAPIKeys: []string{\"test-key\"},\n\t\t},\n\t\tPort:                   0,\n\t\tAuthDir:                authDir,\n\t\tDebug:                  true,\n\t\tLoggingToFile:          false,\n\t\tUsageStatisticsEnabled: false,\n\t}\n\n\tauthManager := auth.NewManager(nil, nil, nil)\n\taccessManager := sdkaccess.NewManager()\n\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\treturn NewServer(cfg, authManager, accessManager, configPath)\n}\n\nfunc TestAmpProviderModelRoutes(t *testing.T) {\n\ttestCases := []struct {\n\t\tname         string\n\t\tpath         string\n\t\twantStatus   int\n\t\twantContains string\n\t}{\n\t\t{\n\t\t\tname:         \"openai root models\",\n\t\t\tpath:         \"/api/provider/openai/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"object\":\"list\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"groq root models\",\n\t\t\tpath:         \"/api/provider/groq/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"object\":\"list\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"openai models\",\n\t\t\tpath:         \"/api/provider/openai/v1/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"object\":\"list\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"anthropic models\",\n\t\t\tpath:         \"/api/provider/anthropic/v1/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"data\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"google models v1\",\n\t\t\tpath:         \"/api/provider/google/v1/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"models\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"google models v1beta\",\n\t\t\tpath:         \"/api/provider/google/v1beta/models\",\n\t\t\twantStatus:   http.StatusOK,\n\t\t\twantContains: `\"models\"`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tserver := newTestServer(t)\n\n\t\t\treq := httptest.NewRequest(http.MethodGet, tc.path, nil)\n\t\t\treq.Header.Set(\"Authorization\", \"Bearer test-key\")\n\n\t\t\trr := httptest.NewRecorder()\n\t\t\tserver.engine.ServeHTTP(rr, req)\n\n\t\t\tif rr.Code != tc.wantStatus {\n\t\t\t\tt.Fatalf(\"unexpected status code for %s: got %d want %d; body=%s\", tc.path, rr.Code, tc.wantStatus, rr.Body.String())\n\t\t\t}\n\t\t\tif body := rr.Body.String(); !strings.Contains(body, tc.wantContains) {\n\t\t\t\tt.Fatalf(\"response body for %s missing %q: %s\", tc.path, tc.wantContains, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDefaultRequestLoggerFactory_UsesResolvedLogDirectory(t *testing.T) {\n\tt.Setenv(\"WRITABLE_PATH\", \"\")\n\tt.Setenv(\"writable_path\", \"\")\n\n\toriginalWD, errGetwd := os.Getwd()\n\tif errGetwd != nil {\n\t\tt.Fatalf(\"failed to get current working directory: %v\", errGetwd)\n\t}\n\n\ttmpDir := t.TempDir()\n\tif errChdir := os.Chdir(tmpDir); errChdir != nil {\n\t\tt.Fatalf(\"failed to switch working directory: %v\", errChdir)\n\t}\n\tdefer func() {\n\t\tif errChdirBack := os.Chdir(originalWD); errChdirBack != nil {\n\t\t\tt.Fatalf(\"failed to restore working directory: %v\", errChdirBack)\n\t\t}\n\t}()\n\n\t// Force ResolveLogDirectory to fallback to auth-dir/logs by making ./logs not a writable directory.\n\tif errWriteFile := os.WriteFile(filepath.Join(tmpDir, \"logs\"), []byte(\"not-a-directory\"), 0o644); errWriteFile != nil {\n\t\tt.Fatalf(\"failed to create blocking logs file: %v\", errWriteFile)\n\t}\n\n\tconfigDir := filepath.Join(tmpDir, \"config\")\n\tif errMkdirConfig := os.MkdirAll(configDir, 0o755); errMkdirConfig != nil {\n\t\tt.Fatalf(\"failed to create config dir: %v\", errMkdirConfig)\n\t}\n\tconfigPath := filepath.Join(configDir, \"config.yaml\")\n\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", errMkdirAuth)\n\t}\n\n\tcfg := &proxyconfig.Config{\n\t\tSDKConfig: proxyconfig.SDKConfig{\n\t\t\tRequestLog: false,\n\t\t},\n\t\tAuthDir:           authDir,\n\t\tErrorLogsMaxFiles: 10,\n\t}\n\n\tlogger := defaultRequestLoggerFactory(cfg, configPath)\n\tfileLogger, ok := logger.(*internallogging.FileRequestLogger)\n\tif !ok {\n\t\tt.Fatalf(\"expected *FileRequestLogger, got %T\", logger)\n\t}\n\n\terrLog := fileLogger.LogRequestWithOptions(\n\t\t\"/v1/chat/completions\",\n\t\thttp.MethodPost,\n\t\tmap[string][]string{\"Content-Type\": []string{\"application/json\"}},\n\t\t[]byte(`{\"input\":\"hello\"}`),\n\t\thttp.StatusBadGateway,\n\t\tmap[string][]string{\"Content-Type\": []string{\"application/json\"}},\n\t\t[]byte(`{\"error\":\"upstream failure\"}`),\n\t\tnil,\n\t\tnil,\n\t\tnil,\n\t\ttrue,\n\t\t\"issue-1711\",\n\t\ttime.Now(),\n\t\ttime.Now(),\n\t)\n\tif errLog != nil {\n\t\tt.Fatalf(\"failed to write forced error request log: %v\", errLog)\n\t}\n\n\tauthLogsDir := filepath.Join(authDir, \"logs\")\n\tauthEntries, errReadAuthDir := os.ReadDir(authLogsDir)\n\tif errReadAuthDir != nil {\n\t\tt.Fatalf(\"failed to read auth logs dir %s: %v\", authLogsDir, errReadAuthDir)\n\t}\n\tfoundErrorLogInAuthDir := false\n\tfor _, entry := range authEntries {\n\t\tif strings.HasPrefix(entry.Name(), \"error-\") && strings.HasSuffix(entry.Name(), \".log\") {\n\t\t\tfoundErrorLogInAuthDir = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundErrorLogInAuthDir {\n\t\tt.Fatalf(\"expected forced error log in auth fallback dir %s, got entries: %+v\", authLogsDir, authEntries)\n\t}\n\n\tconfigLogsDir := filepath.Join(configDir, \"logs\")\n\tconfigEntries, errReadConfigDir := os.ReadDir(configLogsDir)\n\tif errReadConfigDir != nil && !os.IsNotExist(errReadConfigDir) {\n\t\tt.Fatalf(\"failed to inspect config logs dir %s: %v\", configLogsDir, errReadConfigDir)\n\t}\n\tfor _, entry := range configEntries {\n\t\tif strings.HasPrefix(entry.Name(), \"error-\") && strings.HasSuffix(entry.Name(), \".log\") {\n\t\t\tt.Fatalf(\"unexpected forced error log in config dir %s\", configLogsDir)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/auth/antigravity/auth.go",
    "content": "// Package antigravity provides OAuth2 authentication functionality for the Antigravity provider.\npackage antigravity\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// TokenResponse represents OAuth token response from Google\ntype TokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tExpiresIn    int64  `json:\"expires_in\"`\n\tTokenType    string `json:\"token_type\"`\n}\n\n// userInfo represents Google user profile\ntype userInfo struct {\n\tEmail string `json:\"email\"`\n}\n\n// AntigravityAuth handles Antigravity OAuth authentication\ntype AntigravityAuth struct {\n\thttpClient *http.Client\n}\n\n// NewAntigravityAuth creates a new Antigravity auth service.\nfunc NewAntigravityAuth(cfg *config.Config, httpClient *http.Client) *AntigravityAuth {\n\tif httpClient != nil {\n\t\treturn &AntigravityAuth{httpClient: httpClient}\n\t}\n\tif cfg == nil {\n\t\tcfg = &config.Config{}\n\t}\n\treturn &AntigravityAuth{\n\t\thttpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),\n\t}\n}\n\n// BuildAuthURL generates the OAuth authorization URL.\nfunc (o *AntigravityAuth) BuildAuthURL(state, redirectURI string) string {\n\tif strings.TrimSpace(redirectURI) == \"\" {\n\t\tredirectURI = fmt.Sprintf(\"http://localhost:%d/oauth-callback\", CallbackPort)\n\t}\n\tparams := url.Values{}\n\tparams.Set(\"access_type\", \"offline\")\n\tparams.Set(\"client_id\", ClientID)\n\tparams.Set(\"prompt\", \"consent\")\n\tparams.Set(\"redirect_uri\", redirectURI)\n\tparams.Set(\"response_type\", \"code\")\n\tparams.Set(\"scope\", strings.Join(Scopes, \" \"))\n\tparams.Set(\"state\", state)\n\treturn AuthEndpoint + \"?\" + params.Encode()\n}\n\n// ExchangeCodeForTokens exchanges authorization code for access and refresh tokens\nfunc (o *AntigravityAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*TokenResponse, error) {\n\tdata := url.Values{}\n\tdata.Set(\"code\", code)\n\tdata.Set(\"client_id\", ClientID)\n\tdata.Set(\"client_secret\", ClientSecret)\n\tdata.Set(\"redirect_uri\", redirectURI)\n\tdata.Set(\"grant_type\", \"authorization_code\")\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenEndpoint, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity token exchange: create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\tresp, errDo := o.httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity token exchange: execute request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"antigravity token exchange: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 8<<10))\n\t\tif errRead != nil {\n\t\t\treturn nil, fmt.Errorf(\"antigravity token exchange: read response: %w\", errRead)\n\t\t}\n\t\tbody := strings.TrimSpace(string(bodyBytes))\n\t\tif body == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"antigravity token exchange: request failed: status %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"antigravity token exchange: request failed: status %d: %s\", resp.StatusCode, body)\n\t}\n\n\tvar token TokenResponse\n\tif errDecode := json.NewDecoder(resp.Body).Decode(&token); errDecode != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity token exchange: decode response: %w\", errDecode)\n\t}\n\treturn &token, nil\n}\n\n// FetchUserInfo retrieves user email from Google\nfunc (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string) (string, error) {\n\taccessToken = strings.TrimSpace(accessToken)\n\tif accessToken == \"\" {\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: missing access token\")\n\t}\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: create request: %w\", err)\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\n\tresp, errDo := o.httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: execute request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"antigravity userinfo: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 8<<10))\n\t\tif errRead != nil {\n\t\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: read response: %w\", errRead)\n\t\t}\n\t\tbody := strings.TrimSpace(string(bodyBytes))\n\t\tif body == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: request failed: status %d\", resp.StatusCode)\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: request failed: status %d: %s\", resp.StatusCode, body)\n\t}\n\tvar info userInfo\n\tif errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: decode response: %w\", errDecode)\n\t}\n\temail := strings.TrimSpace(info.Email)\n\tif email == \"\" {\n\t\treturn \"\", fmt.Errorf(\"antigravity userinfo: response missing email\")\n\t}\n\treturn email, nil\n}\n\n// FetchProjectID retrieves the project ID for the authenticated user via loadCodeAssist\nfunc (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string) (string, error) {\n\tloadReqBody := map[string]any{\n\t\t\"metadata\": map[string]string{\n\t\t\t\"ideType\":    \"ANTIGRAVITY\",\n\t\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\t\"pluginType\": \"GEMINI\",\n\t\t},\n\t}\n\n\trawBody, errMarshal := json.Marshal(loadReqBody)\n\tif errMarshal != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal request body: %w\", errMarshal)\n\t}\n\n\tendpointURL := fmt.Sprintf(\"%s/%s:loadCodeAssist\", APIEndpoint, APIVersion)\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create request: %w\", err)\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", APIUserAgent)\n\treq.Header.Set(\"X-Goog-Api-Client\", APIClient)\n\treq.Header.Set(\"Client-Metadata\", ClientMetadata)\n\n\tresp, errDo := o.httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn \"\", fmt.Errorf(\"execute request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"antigravity loadCodeAssist: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, errRead := io.ReadAll(resp.Body)\n\tif errRead != nil {\n\t\treturn \"\", fmt.Errorf(\"read response: %w\", errRead)\n\t}\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\treturn \"\", fmt.Errorf(\"request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tvar loadResp map[string]any\n\tif errDecode := json.Unmarshal(bodyBytes, &loadResp); errDecode != nil {\n\t\treturn \"\", fmt.Errorf(\"decode response: %w\", errDecode)\n\t}\n\n\t// Extract projectID from response\n\tprojectID := \"\"\n\tif id, ok := loadResp[\"cloudaicompanionProject\"].(string); ok {\n\t\tprojectID = strings.TrimSpace(id)\n\t}\n\tif projectID == \"\" {\n\t\tif projectMap, ok := loadResp[\"cloudaicompanionProject\"].(map[string]any); ok {\n\t\t\tif id, okID := projectMap[\"id\"].(string); okID {\n\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t}\n\t\t}\n\t}\n\n\tif projectID == \"\" {\n\t\ttierID := \"legacy-tier\"\n\t\tif tiers, okTiers := loadResp[\"allowedTiers\"].([]any); okTiers {\n\t\t\tfor _, rawTier := range tiers {\n\t\t\t\ttier, okTier := rawTier.(map[string]any)\n\t\t\t\tif !okTier {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif isDefault, okDefault := tier[\"isDefault\"].(bool); okDefault && isDefault {\n\t\t\t\t\tif id, okID := tier[\"id\"].(string); okID && strings.TrimSpace(id) != \"\" {\n\t\t\t\t\t\ttierID = strings.TrimSpace(id)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tprojectID, err = o.OnboardUser(ctx, accessToken, tierID)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn projectID, nil\n\t}\n\n\treturn projectID, nil\n}\n\n// OnboardUser attempts to fetch the project ID via onboardUser by polling for completion\nfunc (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) {\n\tlog.Infof(\"Antigravity: onboarding user with tier: %s\", tierID)\n\trequestBody := map[string]any{\n\t\t\"tierId\": tierID,\n\t\t\"metadata\": map[string]string{\n\t\t\t\"ideType\":    \"ANTIGRAVITY\",\n\t\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\t\"pluginType\": \"GEMINI\",\n\t\t},\n\t}\n\n\trawBody, errMarshal := json.Marshal(requestBody)\n\tif errMarshal != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal request body: %w\", errMarshal)\n\t}\n\n\tmaxAttempts := 5\n\tfor attempt := 1; attempt <= maxAttempts; attempt++ {\n\t\tlog.Debugf(\"Polling attempt %d/%d\", attempt, maxAttempts)\n\n\t\treqCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif reqCtx == nil {\n\t\t\treqCtx = context.Background()\n\t\t}\n\t\treqCtx, cancel = context.WithTimeout(reqCtx, 30*time.Second)\n\n\t\tendpointURL := fmt.Sprintf(\"%s/%s:onboardUser\", APIEndpoint, APIVersion)\n\t\treq, errRequest := http.NewRequestWithContext(reqCtx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))\n\t\tif errRequest != nil {\n\t\t\tcancel()\n\t\t\treturn \"\", fmt.Errorf(\"create request: %w\", errRequest)\n\t\t}\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", APIUserAgent)\n\t\treq.Header.Set(\"X-Goog-Api-Client\", APIClient)\n\t\treq.Header.Set(\"Client-Metadata\", ClientMetadata)\n\n\t\tresp, errDo := o.httpClient.Do(req)\n\t\tif errDo != nil {\n\t\t\tcancel()\n\t\t\treturn \"\", fmt.Errorf(\"execute request: %w\", errDo)\n\t\t}\n\n\t\tbodyBytes, errRead := io.ReadAll(resp.Body)\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"close body error: %v\", errClose)\n\t\t}\n\t\tcancel()\n\n\t\tif errRead != nil {\n\t\t\treturn \"\", fmt.Errorf(\"read response: %w\", errRead)\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\tvar data map[string]any\n\t\t\tif errDecode := json.Unmarshal(bodyBytes, &data); errDecode != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"decode response: %w\", errDecode)\n\t\t\t}\n\n\t\t\tif done, okDone := data[\"done\"].(bool); okDone && done {\n\t\t\t\tprojectID := \"\"\n\t\t\t\tif responseData, okResp := data[\"response\"].(map[string]any); okResp {\n\t\t\t\t\tswitch projectValue := responseData[\"cloudaicompanionProject\"].(type) {\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tif id, okID := projectValue[\"id\"].(string); okID {\n\t\t\t\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t\t\t\t}\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tprojectID = strings.TrimSpace(projectValue)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif projectID != \"\" {\n\t\t\t\t\tlog.Infof(\"Successfully fetched project_id: %s\", projectID)\n\t\t\t\t\treturn projectID, nil\n\t\t\t\t}\n\n\t\t\t\treturn \"\", fmt.Errorf(\"no project_id in response\")\n\t\t\t}\n\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tcontinue\n\t\t}\n\n\t\tresponsePreview := strings.TrimSpace(string(bodyBytes))\n\t\tif len(responsePreview) > 500 {\n\t\t\tresponsePreview = responsePreview[:500]\n\t\t}\n\n\t\tresponseErr := responsePreview\n\t\tif len(responseErr) > 200 {\n\t\t\tresponseErr = responseErr[:200]\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"http %d: %s\", resp.StatusCode, responseErr)\n\t}\n\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "internal/auth/antigravity/constants.go",
    "content": "// Package antigravity provides OAuth2 authentication functionality for the Antigravity provider.\npackage antigravity\n\n// OAuth client credentials and configuration\nconst (\n\tClientID     = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\"\n\tClientSecret = \"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\"\n\tCallbackPort = 51121\n)\n\n// Scopes defines the OAuth scopes required for Antigravity authentication\nvar Scopes = []string{\n\t\"https://www.googleapis.com/auth/cloud-platform\",\n\t\"https://www.googleapis.com/auth/userinfo.email\",\n\t\"https://www.googleapis.com/auth/userinfo.profile\",\n\t\"https://www.googleapis.com/auth/cclog\",\n\t\"https://www.googleapis.com/auth/experimentsandconfigs\",\n}\n\n// OAuth2 endpoints for Google authentication\nconst (\n\tTokenEndpoint    = \"https://oauth2.googleapis.com/token\"\n\tAuthEndpoint     = \"https://accounts.google.com/o/oauth2/v2/auth\"\n\tUserInfoEndpoint = \"https://www.googleapis.com/oauth2/v1/userinfo?alt=json\"\n)\n\n// Antigravity API configuration\nconst (\n\tAPIEndpoint    = \"https://cloudcode-pa.googleapis.com\"\n\tAPIVersion     = \"v1internal\"\n\tAPIUserAgent   = \"google-api-nodejs-client/9.15.1\"\n\tAPIClient      = \"google-cloud-sdk vscode_cloudshelleditor/0.1\"\n\tClientMetadata = `{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}`\n)\n"
  },
  {
    "path": "internal/auth/antigravity/filename.go",
    "content": "package antigravity\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// CredentialFileName returns the filename used to persist Antigravity credentials.\n// It uses the email as a suffix to disambiguate accounts.\nfunc CredentialFileName(email string) string {\n\temail = strings.TrimSpace(email)\n\tif email == \"\" {\n\t\treturn \"antigravity.json\"\n\t}\n\treturn fmt.Sprintf(\"antigravity-%s.json\", email)\n}\n"
  },
  {
    "path": "internal/auth/claude/anthropic.go",
    "content": "package claude\n\n// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow\ntype PKCECodes struct {\n\t// CodeVerifier is the cryptographically random string used to correlate\n\t// the authorization request to the token request\n\tCodeVerifier string `json:\"code_verifier\"`\n\t// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded\n\tCodeChallenge string `json:\"code_challenge\"`\n}\n\n// ClaudeTokenData holds OAuth token information from Anthropic\ntype ClaudeTokenData struct {\n\t// AccessToken is the OAuth2 access token for API access\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain new access tokens\n\tRefreshToken string `json:\"refresh_token\"`\n\t// Email is the Anthropic account email\n\tEmail string `json:\"email\"`\n\t// Expire is the timestamp of the token expire\n\tExpire string `json:\"expired\"`\n}\n\n// ClaudeAuthBundle aggregates authentication data after OAuth flow completion\ntype ClaudeAuthBundle struct {\n\t// APIKey is the Anthropic API key obtained from token exchange\n\tAPIKey string `json:\"api_key\"`\n\t// TokenData contains the OAuth tokens from the authentication flow\n\tTokenData ClaudeTokenData `json:\"token_data\"`\n\t// LastRefresh is the timestamp of the last token refresh\n\tLastRefresh string `json:\"last_refresh\"`\n}\n"
  },
  {
    "path": "internal/auth/claude/anthropic_auth.go",
    "content": "// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.\n// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)\n// for secure authentication with Claude API, including token exchange, refresh, and storage.\npackage claude\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// OAuth configuration constants for Claude/Anthropic\nconst (\n\tAuthURL     = \"https://claude.ai/oauth/authorize\"\n\tTokenURL    = \"https://api.anthropic.com/v1/oauth/token\"\n\tClientID    = \"9d1c250a-e61b-44d9-88ed-5944d1962f5e\"\n\tRedirectURI = \"http://localhost:54545/callback\"\n)\n\n// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.\n// It contains access token, refresh token, and associated user/organization information.\ntype tokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tTokenType    string `json:\"token_type\"`\n\tExpiresIn    int    `json:\"expires_in\"`\n\tOrganization struct {\n\t\tUUID string `json:\"uuid\"`\n\t\tName string `json:\"name\"`\n\t} `json:\"organization\"`\n\tAccount struct {\n\t\tUUID         string `json:\"uuid\"`\n\t\tEmailAddress string `json:\"email_address\"`\n\t} `json:\"account\"`\n}\n\n// ClaudeAuth handles Anthropic OAuth2 authentication flow.\n// It provides methods for generating authorization URLs, exchanging codes for tokens,\n// and refreshing expired tokens using PKCE for enhanced security.\ntype ClaudeAuth struct {\n\thttpClient *http.Client\n}\n\n// NewClaudeAuth creates a new Anthropic authentication service.\n// It initializes the HTTP client with a custom TLS transport that uses Firefox\n// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains.\n//\n// Parameters:\n//   - cfg: The application configuration containing proxy settings\n//\n// Returns:\n//   - *ClaudeAuth: A new Claude authentication service instance\nfunc NewClaudeAuth(cfg *config.Config) *ClaudeAuth {\n\t// Use custom HTTP client with Firefox TLS fingerprint to bypass\n\t// Cloudflare's bot detection on Anthropic domains\n\treturn &ClaudeAuth{\n\t\thttpClient: NewAnthropicHttpClient(&cfg.SDKConfig),\n\t}\n}\n\n// GenerateAuthURL creates the OAuth authorization URL with PKCE.\n// This method generates a secure authorization URL including PKCE challenge codes\n// for the OAuth2 flow with Anthropic's API.\n//\n// Parameters:\n//   - state: A random state parameter for CSRF protection\n//   - pkceCodes: The PKCE codes for secure code exchange\n//\n// Returns:\n//   - string: The complete authorization URL\n//   - string: The state parameter for verification\n//   - error: An error if PKCE codes are missing or URL generation fails\nfunc (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {\n\tif pkceCodes == nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"PKCE codes are required\")\n\t}\n\n\tparams := url.Values{\n\t\t\"code\":                  {\"true\"},\n\t\t\"client_id\":             {ClientID},\n\t\t\"response_type\":         {\"code\"},\n\t\t\"redirect_uri\":          {RedirectURI},\n\t\t\"scope\":                 {\"org:create_api_key user:profile user:inference\"},\n\t\t\"code_challenge\":        {pkceCodes.CodeChallenge},\n\t\t\"code_challenge_method\": {\"S256\"},\n\t\t\"state\":                 {state},\n\t}\n\n\tauthURL := fmt.Sprintf(\"%s?%s\", AuthURL, params.Encode())\n\treturn authURL, state, nil\n}\n\n// parseCodeAndState extracts the authorization code and state from the callback response.\n// It handles the parsing of the code parameter which may contain additional fragments.\n//\n// Parameters:\n//   - code: The raw code parameter from the OAuth callback\n//\n// Returns:\n//   - parsedCode: The extracted authorization code\n//   - parsedState: The extracted state parameter if present\nfunc (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {\n\tsplits := strings.Split(code, \"#\")\n\tparsedCode = splits[0]\n\tif len(splits) > 1 {\n\t\tparsedState = splits[1]\n\t}\n\treturn\n}\n\n// ExchangeCodeForTokens exchanges authorization code for access tokens.\n// This method implements the OAuth2 token exchange flow using PKCE for security.\n// It sends the authorization code along with PKCE verifier to get access and refresh tokens.\n//\n// Parameters:\n//   - ctx: The context for the request\n//   - code: The authorization code received from OAuth callback\n//   - state: The state parameter for verification\n//   - pkceCodes: The PKCE codes for secure verification\n//\n// Returns:\n//   - *ClaudeAuthBundle: The complete authentication bundle with tokens\n//   - error: An error if token exchange fails\nfunc (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {\n\tif pkceCodes == nil {\n\t\treturn nil, fmt.Errorf(\"PKCE codes are required for token exchange\")\n\t}\n\tnewCode, newState := o.parseCodeAndState(code)\n\n\t// Prepare token exchange request\n\treqBody := map[string]interface{}{\n\t\t\"code\":          newCode,\n\t\t\"state\":         state,\n\t\t\"grant_type\":    \"authorization_code\",\n\t\t\"client_id\":     ClientID,\n\t\t\"redirect_uri\":  RedirectURI,\n\t\t\"code_verifier\": pkceCodes.CodeVerifier,\n\t}\n\n\t// Include state if present\n\tif newState != \"\" {\n\t\treqBody[\"state\"] = newState\n\t}\n\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request body: %w\", err)\n\t}\n\n\t// log.Debugf(\"Token exchange request: %s\", string(jsonBody))\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", TokenURL, strings.NewReader(string(jsonBody)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create token request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := o.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"token exchange request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"failed to close response body: %v\", errClose)\n\t\t}\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read token response: %w\", err)\n\t}\n\t// log.Debugf(\"Token response: %s\", string(body))\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token exchange failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\t// log.Debugf(\"Token response: %s\", string(body))\n\n\tvar tokenResp tokenResponse\n\tif err = json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse token response: %w\", err)\n\t}\n\n\t// Create token data\n\ttokenData := ClaudeTokenData{\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tEmail:        tokenResp.Account.EmailAddress,\n\t\tExpire:       time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}\n\n\t// Create auth bundle\n\tbundle := &ClaudeAuthBundle{\n\t\tTokenData:   tokenData,\n\t\tLastRefresh: time.Now().Format(time.RFC3339),\n\t}\n\n\treturn bundle, nil\n}\n\n// RefreshTokens refreshes the access token using the refresh token.\n// This method exchanges a valid refresh token for a new access token,\n// extending the user's authenticated session.\n//\n// Parameters:\n//   - ctx: The context for the request\n//   - refreshToken: The refresh token to use for getting new access token\n//\n// Returns:\n//   - *ClaudeTokenData: The new token data with updated access token\n//   - error: An error if token refresh fails\nfunc (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {\n\tif refreshToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"refresh token is required\")\n\t}\n\n\treqBody := map[string]interface{}{\n\t\t\"client_id\":     ClientID,\n\t\t\"grant_type\":    \"refresh_token\",\n\t\t\"refresh_token\": refreshToken,\n\t}\n\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request body: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", TokenURL, strings.NewReader(string(jsonBody)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create refresh request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := o.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"token refresh request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read refresh response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token refresh failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\t// log.Debugf(\"Token response: %s\", string(body))\n\n\tvar tokenResp tokenResponse\n\tif err = json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse token response: %w\", err)\n\t}\n\n\t// Create token data\n\treturn &ClaudeTokenData{\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tEmail:        tokenResp.Account.EmailAddress,\n\t\tExpire:       time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}, nil\n}\n\n// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.\n// This method converts the authentication bundle into a token storage structure\n// suitable for persistence and later use.\n//\n// Parameters:\n//   - bundle: The authentication bundle containing token data\n//\n// Returns:\n//   - *ClaudeTokenStorage: A new token storage instance\nfunc (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {\n\tstorage := &ClaudeTokenStorage{\n\t\tAccessToken:  bundle.TokenData.AccessToken,\n\t\tRefreshToken: bundle.TokenData.RefreshToken,\n\t\tLastRefresh:  bundle.LastRefresh,\n\t\tEmail:        bundle.TokenData.Email,\n\t\tExpire:       bundle.TokenData.Expire,\n\t}\n\n\treturn storage\n}\n\n// RefreshTokensWithRetry refreshes tokens with automatic retry logic.\n// This method implements exponential backoff retry logic for token refresh operations,\n// providing resilience against temporary network or service issues.\n//\n// Parameters:\n//   - ctx: The context for the request\n//   - refreshToken: The refresh token to use\n//   - maxRetries: The maximum number of retry attempts\n//\n// Returns:\n//   - *ClaudeTokenData: The refreshed token data\n//   - error: An error if all retry attempts fail\nfunc (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// Wait before retry\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(time.Duration(attempt) * time.Second):\n\t\t\t}\n\t\t}\n\n\t\ttokenData, err := o.RefreshTokens(ctx, refreshToken)\n\t\tif err == nil {\n\t\t\treturn tokenData, nil\n\t\t}\n\n\t\tlastErr = err\n\t\tlog.Warnf(\"Token refresh attempt %d failed: %v\", attempt+1, err)\n\t}\n\n\treturn nil, fmt.Errorf(\"token refresh failed after %d attempts: %w\", maxRetries, lastErr)\n}\n\n// UpdateTokenStorage updates an existing token storage with new token data.\n// This method refreshes the token storage with newly obtained access and refresh tokens,\n// updating timestamps and expiration information.\n//\n// Parameters:\n//   - storage: The existing token storage to update\n//   - tokenData: The new token data to apply\nfunc (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {\n\tstorage.AccessToken = tokenData.AccessToken\n\tstorage.RefreshToken = tokenData.RefreshToken\n\tstorage.LastRefresh = time.Now().Format(time.RFC3339)\n\tstorage.Email = tokenData.Email\n\tstorage.Expire = tokenData.Expire\n}\n"
  },
  {
    "path": "internal/auth/claude/errors.go",
    "content": "// Package claude provides authentication and token management functionality\n// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Claude API.\npackage claude\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n)\n\n// OAuthError represents an OAuth-specific error.\ntype OAuthError struct {\n\t// Code is the OAuth error code.\n\tCode string `json:\"error\"`\n\t// Description is a human-readable description of the error.\n\tDescription string `json:\"error_description,omitempty\"`\n\t// URI is a URI identifying a human-readable web page with information about the error.\n\tURI string `json:\"error_uri,omitempty\"`\n\t// StatusCode is the HTTP status code associated with the error.\n\tStatusCode int `json:\"-\"`\n}\n\n// Error returns a string representation of the OAuth error.\nfunc (e *OAuthError) Error() string {\n\tif e.Description != \"\" {\n\t\treturn fmt.Sprintf(\"OAuth error %s: %s\", e.Code, e.Description)\n\t}\n\treturn fmt.Sprintf(\"OAuth error: %s\", e.Code)\n}\n\n// NewOAuthError creates a new OAuth error with the specified code, description, and status code.\nfunc NewOAuthError(code, description string, statusCode int) *OAuthError {\n\treturn &OAuthError{\n\t\tCode:        code,\n\t\tDescription: description,\n\t\tStatusCode:  statusCode,\n\t}\n}\n\n// AuthenticationError represents authentication-related errors.\ntype AuthenticationError struct {\n\t// Type is the type of authentication error.\n\tType string `json:\"type\"`\n\t// Message is a human-readable message describing the error.\n\tMessage string `json:\"message\"`\n\t// Code is the HTTP status code associated with the error.\n\tCode int `json:\"code\"`\n\t// Cause is the underlying error that caused this authentication error.\n\tCause error `json:\"-\"`\n}\n\n// Error returns a string representation of the authentication error.\nfunc (e *AuthenticationError) Error() string {\n\tif e.Cause != nil {\n\t\treturn fmt.Sprintf(\"%s: %s (caused by: %v)\", e.Type, e.Message, e.Cause)\n\t}\n\treturn fmt.Sprintf(\"%s: %s\", e.Type, e.Message)\n}\n\n// Common authentication error types.\nvar (\n\t// ErrTokenExpired = &AuthenticationError{\n\t// \tType:    \"token_expired\",\n\t// \tMessage: \"Access token has expired\",\n\t// \tCode:    http.StatusUnauthorized,\n\t// }\n\n\t// ErrInvalidState represents an error for invalid OAuth state parameter.\n\tErrInvalidState = &AuthenticationError{\n\t\tType:    \"invalid_state\",\n\t\tMessage: \"OAuth state parameter is invalid\",\n\t\tCode:    http.StatusBadRequest,\n\t}\n\n\t// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.\n\tErrCodeExchangeFailed = &AuthenticationError{\n\t\tType:    \"code_exchange_failed\",\n\t\tMessage: \"Failed to exchange authorization code for tokens\",\n\t\tCode:    http.StatusBadRequest,\n\t}\n\n\t// ErrServerStartFailed represents an error when starting the OAuth callback server fails.\n\tErrServerStartFailed = &AuthenticationError{\n\t\tType:    \"server_start_failed\",\n\t\tMessage: \"Failed to start OAuth callback server\",\n\t\tCode:    http.StatusInternalServerError,\n\t}\n\n\t// ErrPortInUse represents an error when the OAuth callback port is already in use.\n\tErrPortInUse = &AuthenticationError{\n\t\tType:    \"port_in_use\",\n\t\tMessage: \"OAuth callback port is already in use\",\n\t\tCode:    13, // Special exit code for port-in-use\n\t}\n\n\t// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.\n\tErrCallbackTimeout = &AuthenticationError{\n\t\tType:    \"callback_timeout\",\n\t\tMessage: \"Timeout waiting for OAuth callback\",\n\t\tCode:    http.StatusRequestTimeout,\n\t}\n)\n\n// NewAuthenticationError creates a new authentication error with a cause based on a base error.\nfunc NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {\n\treturn &AuthenticationError{\n\t\tType:    baseErr.Type,\n\t\tMessage: baseErr.Message,\n\t\tCode:    baseErr.Code,\n\t\tCause:   cause,\n\t}\n}\n\n// IsAuthenticationError checks if an error is an authentication error.\nfunc IsAuthenticationError(err error) bool {\n\tvar authenticationError *AuthenticationError\n\tok := errors.As(err, &authenticationError)\n\treturn ok\n}\n\n// IsOAuthError checks if an error is an OAuth error.\nfunc IsOAuthError(err error) bool {\n\tvar oAuthError *OAuthError\n\tok := errors.As(err, &oAuthError)\n\treturn ok\n}\n\n// GetUserFriendlyMessage returns a user-friendly error message based on the error type.\nfunc GetUserFriendlyMessage(err error) string {\n\tswitch {\n\tcase IsAuthenticationError(err):\n\t\tvar authErr *AuthenticationError\n\t\terrors.As(err, &authErr)\n\t\tswitch authErr.Type {\n\t\tcase \"token_expired\":\n\t\t\treturn \"Your authentication has expired. Please log in again.\"\n\t\tcase \"token_invalid\":\n\t\t\treturn \"Your authentication is invalid. Please log in again.\"\n\t\tcase \"authentication_required\":\n\t\t\treturn \"Please log in to continue.\"\n\t\tcase \"port_in_use\":\n\t\t\treturn \"The required port is already in use. Please close any applications using port 3000 and try again.\"\n\t\tcase \"callback_timeout\":\n\t\t\treturn \"Authentication timed out. Please try again.\"\n\t\tcase \"browser_open_failed\":\n\t\t\treturn \"Could not open your browser automatically. Please copy and paste the URL manually.\"\n\t\tdefault:\n\t\t\treturn \"Authentication failed. Please try again.\"\n\t\t}\n\tcase IsOAuthError(err):\n\t\tvar oauthErr *OAuthError\n\t\terrors.As(err, &oauthErr)\n\t\tswitch oauthErr.Code {\n\t\tcase \"access_denied\":\n\t\t\treturn \"Authentication was cancelled or denied.\"\n\t\tcase \"invalid_request\":\n\t\t\treturn \"Invalid authentication request. Please try again.\"\n\t\tcase \"server_error\":\n\t\t\treturn \"Authentication server error. Please try again later.\"\n\t\tdefault:\n\t\t\treturn fmt.Sprintf(\"Authentication failed: %s\", oauthErr.Description)\n\t\t}\n\tdefault:\n\t\treturn \"An unexpected error occurred. Please try again.\"\n\t}\n}\n"
  },
  {
    "path": "internal/auth/claude/html_templates.go",
    "content": "// Package claude provides authentication and token management functionality\n// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Claude API.\npackage claude\n\n// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.\n// This template provides a user-friendly success page with options to close the window\n// or navigate to the Claude platform. It includes automatic window closing functionality\n// and keyboard accessibility features.\nconst LoginSuccessHtml = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Authentication Successful - Claude</title>\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E\">\n    <style>\n        * {\n            box-sizing: border-box;\n        }\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            min-height: 100vh;\n            margin: 0;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            padding: 1rem;\n        }\n        .container {\n            text-align: center;\n            background: white;\n            padding: 2.5rem;\n            border-radius: 12px;\n            box-shadow: 0 10px 25px rgba(0,0,0,0.1);\n            max-width: 480px;\n            width: 100%;\n            animation: slideIn 0.3s ease-out;\n        }\n        @keyframes slideIn {\n            from {\n                opacity: 0;\n                transform: translateY(-20px);\n            }\n            to {\n                opacity: 1;\n                transform: translateY(0);\n            }\n        }\n        .success-icon {\n            width: 64px;\n            height: 64px;\n            margin: 0 auto 1.5rem;\n            background: #10b981;\n            border-radius: 50%;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            color: white;\n            font-size: 2rem;\n            font-weight: bold;\n        }\n        h1 {\n            color: #1f2937;\n            margin-bottom: 1rem;\n            font-size: 1.75rem;\n            font-weight: 600;\n        }\n        .subtitle {\n            color: #6b7280;\n            margin-bottom: 1.5rem;\n            font-size: 1rem;\n            line-height: 1.5;\n        }\n        .setup-notice {\n            background: #fef3c7;\n            border: 1px solid #f59e0b;\n            border-radius: 6px;\n            padding: 1rem;\n            margin: 1rem 0;\n        }\n        .setup-notice h3 {\n            color: #92400e;\n            margin: 0 0 0.5rem 0;\n            font-size: 1rem;\n        }\n        .setup-notice p {\n            color: #92400e;\n            margin: 0;\n            font-size: 0.875rem;\n        }\n        .setup-notice a {\n            color: #1d4ed8;\n            text-decoration: none;\n        }\n        .setup-notice a:hover {\n            text-decoration: underline;\n        }\n        .actions {\n            display: flex;\n            gap: 1rem;\n            justify-content: center;\n            flex-wrap: wrap;\n            margin-top: 2rem;\n        }\n        .button {\n            padding: 0.75rem 1.5rem;\n            border-radius: 8px;\n            font-size: 0.875rem;\n            font-weight: 500;\n            text-decoration: none;\n            transition: all 0.2s;\n            cursor: pointer;\n            border: none;\n            display: inline-flex;\n            align-items: center;\n            gap: 0.5rem;\n        }\n        .button-primary {\n            background: #3b82f6;\n            color: white;\n        }\n        .button-primary:hover {\n            background: #2563eb;\n            transform: translateY(-1px);\n        }\n        .button-secondary {\n            background: #f3f4f6;\n            color: #374151;\n            border: 1px solid #d1d5db;\n        }\n        .button-secondary:hover {\n            background: #e5e7eb;\n        }\n        .countdown {\n            color: #9ca3af;\n            font-size: 0.75rem;\n            margin-top: 1rem;\n        }\n        .footer {\n            margin-top: 2rem;\n            padding-top: 1.5rem;\n            border-top: 1px solid #e5e7eb;\n            color: #9ca3af;\n            font-size: 0.75rem;\n        }\n        .footer a {\n            color: #3b82f6;\n            text-decoration: none;\n        }\n        .footer a:hover {\n            text-decoration: underline;\n        }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"success-icon\">✓</div>\n        <h1>Authentication Successful!</h1>\n        <p class=\"subtitle\">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>\n        \n        {{SETUP_NOTICE}}\n        \n        <div class=\"actions\">\n            <button class=\"button button-primary\" onclick=\"window.close()\">\n                <span>Close Window</span>\n            </button>\n            <a href=\"{{PLATFORM_URL}}\" target=\"_blank\" class=\"button button-secondary\">\n                <span>Open Platform</span>\n                <span>↗</span>\n            </a>\n        </div>\n        \n        <div class=\"countdown\">\n            This window will close automatically in <span id=\"countdown\">10</span> seconds\n        </div>\n        \n        <div class=\"footer\">\n            <p>Powered by <a href=\"https://chatgpt.com\" target=\"_blank\">ChatGPT</a></p>\n        </div>\n    </div>\n    \n    <script>\n        let countdown = 10;\n        const countdownElement = document.getElementById('countdown');\n        \n        const timer = setInterval(() => {\n            countdown--;\n            countdownElement.textContent = countdown;\n            \n            if (countdown <= 0) {\n                clearInterval(timer);\n                window.close();\n            }\n        }, 1000);\n        \n        // Close window when user presses Escape\n        document.addEventListener('keydown', (e) => {\n            if (e.key === 'Escape') {\n                window.close();\n            }\n        });\n        \n        // Focus the close button for keyboard accessibility\n        document.querySelector('.button-primary').focus();\n    </script>\n</body>\n</html>`\n\n// SetupNoticeHtml is the HTML template for the setup notice section.\n// This template is embedded within the success page to inform users about\n// additional setup steps required to complete their Claude account configuration.\nconst SetupNoticeHtml = `\n        <div class=\"setup-notice\">\n            <h3>Additional Setup Required</h3>\n            <p>To complete your setup, please visit the <a href=\"{{PLATFORM_URL}}\" target=\"_blank\">Claude</a> to configure your account.</p>\n        </div>`\n"
  },
  {
    "path": "internal/auth/claude/oauth_server.go",
    "content": "// Package claude provides authentication and token management functionality\n// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Claude API.\npackage claude\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// OAuthServer handles the local HTTP server for OAuth callbacks.\n// It listens for the authorization code response from the OAuth provider\n// and captures the necessary parameters to complete the authentication flow.\ntype OAuthServer struct {\n\t// server is the underlying HTTP server instance\n\tserver *http.Server\n\t// port is the port number on which the server listens\n\tport int\n\t// resultChan is a channel for sending OAuth results\n\tresultChan chan *OAuthResult\n\t// errorChan is a channel for sending OAuth errors\n\terrorChan chan error\n\t// mu is a mutex for protecting server state\n\tmu sync.Mutex\n\t// running indicates whether the server is currently running\n\trunning bool\n}\n\n// OAuthResult contains the result of the OAuth callback.\n// It holds either the authorization code and state for successful authentication\n// or an error message if the authentication failed.\ntype OAuthResult struct {\n\t// Code is the authorization code received from the OAuth provider\n\tCode string\n\t// State is the state parameter used to prevent CSRF attacks\n\tState string\n\t// Error contains any error message if the OAuth flow failed\n\tError string\n}\n\n// NewOAuthServer creates a new OAuth callback server.\n// It initializes the server with the specified port and creates channels\n// for handling OAuth results and errors.\n//\n// Parameters:\n//   - port: The port number on which the server should listen\n//\n// Returns:\n//   - *OAuthServer: A new OAuthServer instance\nfunc NewOAuthServer(port int) *OAuthServer {\n\treturn &OAuthServer{\n\t\tport:       port,\n\t\tresultChan: make(chan *OAuthResult, 1),\n\t\terrorChan:  make(chan error, 1),\n\t}\n}\n\n// Start starts the OAuth callback server.\n// It sets up the HTTP handlers for the callback and success endpoints,\n// and begins listening on the specified port.\n//\n// Returns:\n//   - error: An error if the server fails to start\nfunc (s *OAuthServer) Start() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.running {\n\t\treturn fmt.Errorf(\"server is already running\")\n\t}\n\n\t// Check if port is available\n\tif !s.isPortAvailable() {\n\t\treturn fmt.Errorf(\"port %d is already in use\", s.port)\n\t}\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/callback\", s.handleCallback)\n\tmux.HandleFunc(\"/success\", s.handleSuccess)\n\n\ts.server = &http.Server{\n\t\tAddr:         fmt.Sprintf(\":%d\", s.port),\n\t\tHandler:      mux,\n\t\tReadTimeout:  10 * time.Second,\n\t\tWriteTimeout: 10 * time.Second,\n\t}\n\n\ts.running = true\n\n\t// Start server in goroutine\n\tgo func() {\n\t\tif err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\ts.errorChan <- fmt.Errorf(\"server failed to start: %w\", err)\n\t\t}\n\t}()\n\n\t// Give server a moment to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\treturn nil\n}\n\n// Stop gracefully stops the OAuth callback server.\n// It performs a graceful shutdown of the HTTP server with a timeout.\n//\n// Parameters:\n//   - ctx: The context for controlling the shutdown process\n//\n// Returns:\n//   - error: An error if the server fails to stop gracefully\nfunc (s *OAuthServer) Stop(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif !s.running || s.server == nil {\n\t\treturn nil\n\t}\n\n\tlog.Debug(\"Stopping OAuth callback server\")\n\n\t// Create a context with timeout for shutdown\n\tshutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer cancel()\n\n\terr := s.server.Shutdown(shutdownCtx)\n\ts.running = false\n\ts.server = nil\n\n\treturn err\n}\n\n// WaitForCallback waits for the OAuth callback with a timeout.\n// It blocks until either an OAuth result is received, an error occurs,\n// or the specified timeout is reached.\n//\n// Parameters:\n//   - timeout: The maximum time to wait for the callback\n//\n// Returns:\n//   - *OAuthResult: The OAuth result if successful\n//   - error: An error if the callback times out or an error occurs\nfunc (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {\n\tselect {\n\tcase result := <-s.resultChan:\n\t\treturn result, nil\n\tcase err := <-s.errorChan:\n\t\treturn nil, err\n\tcase <-time.After(timeout):\n\t\treturn nil, fmt.Errorf(\"timeout waiting for OAuth callback\")\n\t}\n}\n\n// handleCallback handles the OAuth callback endpoint.\n// It extracts the authorization code and state from the callback URL,\n// validates the parameters, and sends the result to the waiting channel.\n//\n// Parameters:\n//   - w: The HTTP response writer\n//   - r: The HTTP request\nfunc (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {\n\tlog.Debug(\"Received OAuth callback\")\n\n\t// Validate request method\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\t// Extract parameters\n\tquery := r.URL.Query()\n\tcode := query.Get(\"code\")\n\tstate := query.Get(\"state\")\n\terrorParam := query.Get(\"error\")\n\n\t// Validate required parameters\n\tif errorParam != \"\" {\n\t\tlog.Errorf(\"OAuth error received: %s\", errorParam)\n\t\tresult := &OAuthResult{\n\t\t\tError: errorParam,\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, fmt.Sprintf(\"OAuth error: %s\", errorParam), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif code == \"\" {\n\t\tlog.Error(\"No authorization code received\")\n\t\tresult := &OAuthResult{\n\t\t\tError: \"no_code\",\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, \"No authorization code received\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif state == \"\" {\n\t\tlog.Error(\"No state parameter received\")\n\t\tresult := &OAuthResult{\n\t\t\tError: \"no_state\",\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, \"No state parameter received\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Send successful result\n\tresult := &OAuthResult{\n\t\tCode:  code,\n\t\tState: state,\n\t}\n\ts.sendResult(result)\n\n\t// Redirect to success page\n\thttp.Redirect(w, r, \"/success\", http.StatusFound)\n}\n\n// handleSuccess handles the success page endpoint.\n// It serves a user-friendly HTML page indicating that authentication was successful.\n//\n// Parameters:\n//   - w: The HTTP response writer\n//   - r: The HTTP request\nfunc (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {\n\tlog.Debug(\"Serving success page\")\n\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tw.WriteHeader(http.StatusOK)\n\n\t// Parse query parameters for customization\n\tquery := r.URL.Query()\n\tsetupRequired := query.Get(\"setup_required\") == \"true\"\n\tplatformURL := query.Get(\"platform_url\")\n\tif platformURL == \"\" {\n\t\tplatformURL = \"https://console.anthropic.com/\"\n\t}\n\n\t// Generate success page HTML with dynamic content\n\tsuccessHTML := s.generateSuccessHTML(setupRequired, platformURL)\n\n\t_, err := w.Write([]byte(successHTML))\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to write success page: %v\", err)\n\t}\n}\n\n// generateSuccessHTML creates the HTML content for the success page.\n// It customizes the page based on whether additional setup is required\n// and includes a link to the platform.\n//\n// Parameters:\n//   - setupRequired: Whether additional setup is required after authentication\n//   - platformURL: The URL to the platform for additional setup\n//\n// Returns:\n//   - string: The HTML content for the success page\nfunc (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {\n\thtml := LoginSuccessHtml\n\n\t// Replace platform URL placeholder\n\thtml = strings.Replace(html, \"{{PLATFORM_URL}}\", platformURL, -1)\n\n\t// Add setup notice if required\n\tif setupRequired {\n\t\tsetupNotice := strings.Replace(SetupNoticeHtml, \"{{PLATFORM_URL}}\", platformURL, -1)\n\t\thtml = strings.Replace(html, \"{{SETUP_NOTICE}}\", setupNotice, 1)\n\t} else {\n\t\thtml = strings.Replace(html, \"{{SETUP_NOTICE}}\", \"\", 1)\n\t}\n\n\treturn html\n}\n\n// sendResult sends the OAuth result to the waiting channel.\n// It ensures that the result is sent without blocking the handler.\n//\n// Parameters:\n//   - result: The OAuth result to send\nfunc (s *OAuthServer) sendResult(result *OAuthResult) {\n\tselect {\n\tcase s.resultChan <- result:\n\t\tlog.Debug(\"OAuth result sent to channel\")\n\tdefault:\n\t\tlog.Warn(\"OAuth result channel is full, result dropped\")\n\t}\n}\n\n// isPortAvailable checks if the specified port is available.\n// It attempts to listen on the port to determine availability.\n//\n// Returns:\n//   - bool: True if the port is available, false otherwise\nfunc (s *OAuthServer) isPortAvailable() bool {\n\taddr := fmt.Sprintf(\":%d\", s.port)\n\tlistener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer func() {\n\t\t_ = listener.Close()\n\t}()\n\treturn true\n}\n\n// IsRunning returns whether the server is currently running.\n//\n// Returns:\n//   - bool: True if the server is running, false otherwise\nfunc (s *OAuthServer) IsRunning() bool {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn s.running\n}\n"
  },
  {
    "path": "internal/auth/claude/pkce.go",
    "content": "// Package claude provides authentication and token management functionality\n// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Claude API.\npackage claude\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n)\n\n// GeneratePKCECodes generates a PKCE code verifier and challenge pair\n// following RFC 7636 specifications for OAuth 2.0 PKCE extension.\n// This provides additional security for the OAuth flow by ensuring that\n// only the client that initiated the request can exchange the authorization code.\n//\n// Returns:\n//   - *PKCECodes: A struct containing the code verifier and challenge\n//   - error: An error if the generation fails, nil otherwise\nfunc GeneratePKCECodes() (*PKCECodes, error) {\n\t// Generate code verifier: 43-128 characters, URL-safe\n\tcodeVerifier, err := generateCodeVerifier()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate code verifier: %w\", err)\n\t}\n\n\t// Generate code challenge using S256 method\n\tcodeChallenge := generateCodeChallenge(codeVerifier)\n\n\treturn &PKCECodes{\n\t\tCodeVerifier:  codeVerifier,\n\t\tCodeChallenge: codeChallenge,\n\t}, nil\n}\n\n// generateCodeVerifier creates a cryptographically random string\n// of 128 characters using URL-safe base64 encoding\nfunc generateCodeVerifier() (string, error) {\n\t// Generate 96 random bytes (will result in 128 base64 characters)\n\tbytes := make([]byte, 96)\n\t_, err := rand.Read(bytes)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate random bytes: %w\", err)\n\t}\n\n\t// Encode to URL-safe base64 without padding\n\treturn base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil\n}\n\n// generateCodeChallenge creates a SHA256 hash of the code verifier\n// and encodes it using URL-safe base64 encoding without padding\nfunc generateCodeChallenge(codeVerifier string) string {\n\thash := sha256.Sum256([]byte(codeVerifier))\n\treturn base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])\n}\n"
  },
  {
    "path": "internal/auth/claude/token.go",
    "content": "// Package claude provides authentication and token management functionality\n// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Claude API.\npackage claude\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n)\n\n// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.\n// It maintains compatibility with the existing auth system while adding Claude-specific fields\n// for managing access tokens, refresh tokens, and user account information.\ntype ClaudeTokenStorage struct {\n\t// IDToken is the JWT ID token containing user claims and identity information.\n\tIDToken string `json:\"id_token\"`\n\n\t// AccessToken is the OAuth2 access token used for authenticating API requests.\n\tAccessToken string `json:\"access_token\"`\n\n\t// RefreshToken is used to obtain new access tokens when the current one expires.\n\tRefreshToken string `json:\"refresh_token\"`\n\n\t// LastRefresh is the timestamp of the last token refresh operation.\n\tLastRefresh string `json:\"last_refresh\"`\n\n\t// Email is the Anthropic account email address associated with this token.\n\tEmail string `json:\"email\"`\n\n\t// Type indicates the authentication provider type, always \"claude\" for this storage.\n\tType string `json:\"type\"`\n\n\t// Expire is the timestamp when the current access token expires.\n\tExpire string `json:\"expired\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *ClaudeTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// SaveTokenToFile serializes the Claude token storage to a JSON file.\n// This method creates the necessary directory structure and writes the token\n// data in JSON format to the specified file path for persistent storage.\n// It merges any injected metadata into the top-level JSON object.\n//\n// Parameters:\n//   - authFilePath: The full path where the token file should be saved\n//\n// Returns:\n//   - error: An error if the operation fails, nil otherwise\nfunc (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"claude\"\n\n\t// Create directory structure if it doesn't exist\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %v\", err)\n\t}\n\n\t// Create the token file\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create token file: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = f.Close()\n\t}()\n\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\n\t// Encode and write the token data as JSON\n\tif err = json.NewEncoder(f).Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write token to file: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/auth/claude/utls_transport.go",
    "content": "// Package claude provides authentication functionality for Anthropic's Claude API.\n// This file implements a custom HTTP transport using utls to bypass TLS fingerprinting.\npackage claude\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\n\ttls \"github.com/refraction-networking/utls\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/net/http2\"\n\t\"golang.org/x/net/proxy\"\n)\n\n// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint\n// to bypass Cloudflare's TLS fingerprinting on Anthropic domains.\ntype utlsRoundTripper struct {\n\t// mu protects the connections map and pending map\n\tmu sync.Mutex\n\t// connections caches HTTP/2 client connections per host\n\tconnections map[string]*http2.ClientConn\n\t// pending tracks hosts that are currently being connected to (prevents race condition)\n\tpending map[string]*sync.Cond\n\t// dialer is used to create network connections, supporting proxies\n\tdialer proxy.Dialer\n}\n\n// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support\nfunc newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {\n\tvar dialer proxy.Dialer = proxy.Direct\n\tif cfg != nil {\n\t\tproxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)\n\t\tif errBuild != nil {\n\t\t\tlog.Errorf(\"failed to configure proxy dialer for %q: %v\", cfg.ProxyURL, errBuild)\n\t\t} else if mode != proxyutil.ModeInherit && proxyDialer != nil {\n\t\t\tdialer = proxyDialer\n\t\t}\n\t}\n\n\treturn &utlsRoundTripper{\n\t\tconnections: make(map[string]*http2.ClientConn),\n\t\tpending:     make(map[string]*sync.Cond),\n\t\tdialer:      dialer,\n\t}\n}\n\n// getOrCreateConnection gets an existing connection or creates a new one.\n// It uses a per-host locking mechanism to prevent multiple goroutines from\n// creating connections to the same host simultaneously.\nfunc (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) {\n\tt.mu.Lock()\n\n\t// Check if connection exists and is usable\n\tif h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {\n\t\tt.mu.Unlock()\n\t\treturn h2Conn, nil\n\t}\n\n\t// Check if another goroutine is already creating a connection\n\tif cond, ok := t.pending[host]; ok {\n\t\t// Wait for the other goroutine to finish\n\t\tcond.Wait()\n\t\t// Check if connection is now available\n\t\tif h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {\n\t\t\tt.mu.Unlock()\n\t\t\treturn h2Conn, nil\n\t\t}\n\t\t// Connection still not available, we'll create one\n\t}\n\n\t// Mark this host as pending\n\tcond := sync.NewCond(&t.mu)\n\tt.pending[host] = cond\n\tt.mu.Unlock()\n\n\t// Create connection outside the lock\n\th2Conn, err := t.createConnection(host, addr)\n\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\n\t// Remove pending marker and wake up waiting goroutines\n\tdelete(t.pending, host)\n\tcond.Broadcast()\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Store the new connection\n\tt.connections[host] = h2Conn\n\treturn h2Conn, nil\n}\n\n// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint.\n// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses)\n// than Firefox, reducing the mismatch between TLS layer and HTTP headers.\nfunc (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {\n\tconn, err := t.dialer.Dial(\"tcp\", addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttlsConfig := &tls.Config{ServerName: host}\n\ttlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)\n\n\tif err := tlsConn.Handshake(); err != nil {\n\t\tconn.Close()\n\t\treturn nil, err\n\t}\n\n\ttr := &http2.Transport{}\n\th2Conn, err := tr.NewClientConn(tlsConn)\n\tif err != nil {\n\t\ttlsConn.Close()\n\t\treturn nil, err\n\t}\n\n\treturn h2Conn, nil\n}\n\n// RoundTrip implements http.RoundTripper\nfunc (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\thost := req.URL.Host\n\taddr := host\n\tif !strings.Contains(addr, \":\") {\n\t\taddr += \":443\"\n\t}\n\n\t// Get hostname without port for TLS ServerName\n\thostname := req.URL.Hostname()\n\n\th2Conn, err := t.getOrCreateConnection(hostname, addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := h2Conn.RoundTrip(req)\n\tif err != nil {\n\t\t// Connection failed, remove it from cache\n\t\tt.mu.Lock()\n\t\tif cached, ok := t.connections[hostname]; ok && cached == h2Conn {\n\t\t\tdelete(t.connections, hostname)\n\t\t}\n\t\tt.mu.Unlock()\n\t\treturn nil, err\n\t}\n\n\treturn resp, nil\n}\n\n// NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting\n// for Anthropic domains by using utls with Chrome fingerprint.\n// It accepts optional SDK configuration for proxy settings.\nfunc NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client {\n\treturn &http.Client{\n\t\tTransport: newUtlsRoundTripper(cfg),\n\t}\n}\n"
  },
  {
    "path": "internal/auth/codex/errors.go",
    "content": "package codex\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n)\n\n// OAuthError represents an OAuth-specific error.\ntype OAuthError struct {\n\t// Code is the OAuth error code.\n\tCode string `json:\"error\"`\n\t// Description is a human-readable description of the error.\n\tDescription string `json:\"error_description,omitempty\"`\n\t// URI is a URI identifying a human-readable web page with information about the error.\n\tURI string `json:\"error_uri,omitempty\"`\n\t// StatusCode is the HTTP status code associated with the error.\n\tStatusCode int `json:\"-\"`\n}\n\n// Error returns a string representation of the OAuth error.\nfunc (e *OAuthError) Error() string {\n\tif e.Description != \"\" {\n\t\treturn fmt.Sprintf(\"OAuth error %s: %s\", e.Code, e.Description)\n\t}\n\treturn fmt.Sprintf(\"OAuth error: %s\", e.Code)\n}\n\n// NewOAuthError creates a new OAuth error with the specified code, description, and status code.\nfunc NewOAuthError(code, description string, statusCode int) *OAuthError {\n\treturn &OAuthError{\n\t\tCode:        code,\n\t\tDescription: description,\n\t\tStatusCode:  statusCode,\n\t}\n}\n\n// AuthenticationError represents authentication-related errors.\ntype AuthenticationError struct {\n\t// Type is the type of authentication error.\n\tType string `json:\"type\"`\n\t// Message is a human-readable message describing the error.\n\tMessage string `json:\"message\"`\n\t// Code is the HTTP status code associated with the error.\n\tCode int `json:\"code\"`\n\t// Cause is the underlying error that caused this authentication error.\n\tCause error `json:\"-\"`\n}\n\n// Error returns a string representation of the authentication error.\nfunc (e *AuthenticationError) Error() string {\n\tif e.Cause != nil {\n\t\treturn fmt.Sprintf(\"%s: %s (caused by: %v)\", e.Type, e.Message, e.Cause)\n\t}\n\treturn fmt.Sprintf(\"%s: %s\", e.Type, e.Message)\n}\n\n// Common authentication error types.\nvar (\n\t// ErrTokenExpired = &AuthenticationError{\n\t// \tType:    \"token_expired\",\n\t// \tMessage: \"Access token has expired\",\n\t// \tCode:    http.StatusUnauthorized,\n\t// }\n\n\t// ErrInvalidState represents an error for invalid OAuth state parameter.\n\tErrInvalidState = &AuthenticationError{\n\t\tType:    \"invalid_state\",\n\t\tMessage: \"OAuth state parameter is invalid\",\n\t\tCode:    http.StatusBadRequest,\n\t}\n\n\t// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.\n\tErrCodeExchangeFailed = &AuthenticationError{\n\t\tType:    \"code_exchange_failed\",\n\t\tMessage: \"Failed to exchange authorization code for tokens\",\n\t\tCode:    http.StatusBadRequest,\n\t}\n\n\t// ErrServerStartFailed represents an error when starting the OAuth callback server fails.\n\tErrServerStartFailed = &AuthenticationError{\n\t\tType:    \"server_start_failed\",\n\t\tMessage: \"Failed to start OAuth callback server\",\n\t\tCode:    http.StatusInternalServerError,\n\t}\n\n\t// ErrPortInUse represents an error when the OAuth callback port is already in use.\n\tErrPortInUse = &AuthenticationError{\n\t\tType:    \"port_in_use\",\n\t\tMessage: \"OAuth callback port is already in use\",\n\t\tCode:    13, // Special exit code for port-in-use\n\t}\n\n\t// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.\n\tErrCallbackTimeout = &AuthenticationError{\n\t\tType:    \"callback_timeout\",\n\t\tMessage: \"Timeout waiting for OAuth callback\",\n\t\tCode:    http.StatusRequestTimeout,\n\t}\n\n\t// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.\n\tErrBrowserOpenFailed = &AuthenticationError{\n\t\tType:    \"browser_open_failed\",\n\t\tMessage: \"Failed to open browser for authentication\",\n\t\tCode:    http.StatusInternalServerError,\n\t}\n)\n\n// NewAuthenticationError creates a new authentication error with a cause based on a base error.\nfunc NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {\n\treturn &AuthenticationError{\n\t\tType:    baseErr.Type,\n\t\tMessage: baseErr.Message,\n\t\tCode:    baseErr.Code,\n\t\tCause:   cause,\n\t}\n}\n\n// IsAuthenticationError checks if an error is an authentication error.\nfunc IsAuthenticationError(err error) bool {\n\tvar authenticationError *AuthenticationError\n\tok := errors.As(err, &authenticationError)\n\treturn ok\n}\n\n// IsOAuthError checks if an error is an OAuth error.\nfunc IsOAuthError(err error) bool {\n\tvar oAuthError *OAuthError\n\tok := errors.As(err, &oAuthError)\n\treturn ok\n}\n\n// GetUserFriendlyMessage returns a user-friendly error message based on the error type.\nfunc GetUserFriendlyMessage(err error) string {\n\tswitch {\n\tcase IsAuthenticationError(err):\n\t\tvar authErr *AuthenticationError\n\t\terrors.As(err, &authErr)\n\t\tswitch authErr.Type {\n\t\tcase \"token_expired\":\n\t\t\treturn \"Your authentication has expired. Please log in again.\"\n\t\tcase \"token_invalid\":\n\t\t\treturn \"Your authentication is invalid. Please log in again.\"\n\t\tcase \"authentication_required\":\n\t\t\treturn \"Please log in to continue.\"\n\t\tcase \"port_in_use\":\n\t\t\treturn \"The required port is already in use. Please close any applications using port 3000 and try again.\"\n\t\tcase \"callback_timeout\":\n\t\t\treturn \"Authentication timed out. Please try again.\"\n\t\tcase \"browser_open_failed\":\n\t\t\treturn \"Could not open your browser automatically. Please copy and paste the URL manually.\"\n\t\tdefault:\n\t\t\treturn \"Authentication failed. Please try again.\"\n\t\t}\n\tcase IsOAuthError(err):\n\t\tvar oauthErr *OAuthError\n\t\terrors.As(err, &oauthErr)\n\t\tswitch oauthErr.Code {\n\t\tcase \"access_denied\":\n\t\t\treturn \"Authentication was cancelled or denied.\"\n\t\tcase \"invalid_request\":\n\t\t\treturn \"Invalid authentication request. Please try again.\"\n\t\tcase \"server_error\":\n\t\t\treturn \"Authentication server error. Please try again later.\"\n\t\tdefault:\n\t\t\treturn fmt.Sprintf(\"Authentication failed: %s\", oauthErr.Description)\n\t\t}\n\tdefault:\n\t\treturn \"An unexpected error occurred. Please try again.\"\n\t}\n}\n"
  },
  {
    "path": "internal/auth/codex/filename.go",
    "content": "package codex\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"unicode\"\n)\n\n// CredentialFileName returns the filename used to persist Codex OAuth credentials.\n// When planType is available (e.g. \"plus\", \"team\"), it is appended after the email\n// as a suffix to disambiguate subscriptions.\nfunc CredentialFileName(email, planType, hashAccountID string, includeProviderPrefix bool) string {\n\temail = strings.TrimSpace(email)\n\tplan := normalizePlanTypeForFilename(planType)\n\n\tprefix := \"\"\n\tif includeProviderPrefix {\n\t\tprefix = \"codex\"\n\t}\n\n\tif plan == \"\" {\n\t\treturn fmt.Sprintf(\"%s-%s.json\", prefix, email)\n\t} else if plan == \"team\" {\n\t\treturn fmt.Sprintf(\"%s-%s-%s-%s.json\", prefix, hashAccountID, email, plan)\n\t}\n\treturn fmt.Sprintf(\"%s-%s-%s.json\", prefix, email, plan)\n}\n\nfunc normalizePlanTypeForFilename(planType string) string {\n\tplanType = strings.TrimSpace(planType)\n\tif planType == \"\" {\n\t\treturn \"\"\n\t}\n\n\tparts := strings.FieldsFunc(planType, func(r rune) bool {\n\t\treturn !unicode.IsLetter(r) && !unicode.IsDigit(r)\n\t})\n\tif len(parts) == 0 {\n\t\treturn \"\"\n\t}\n\n\tfor i, part := range parts {\n\t\tparts[i] = strings.ToLower(strings.TrimSpace(part))\n\t}\n\treturn strings.Join(parts, \"-\")\n}\n"
  },
  {
    "path": "internal/auth/codex/html_templates.go",
    "content": "package codex\n\n// LoginSuccessHTML is the HTML template for the page shown after a successful\n// OAuth2 authentication with Codex. It informs the user that the authentication\n// was successful and provides a countdown timer to automatically close the window.\nconst LoginSuccessHtml = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Authentication Successful - Codex</title>\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E\">\n    <style>\n        * {\n            box-sizing: border-box;\n        }\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            min-height: 100vh;\n            margin: 0;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            padding: 1rem;\n        }\n        .container {\n            text-align: center;\n            background: white;\n            padding: 2.5rem;\n            border-radius: 12px;\n            box-shadow: 0 10px 25px rgba(0,0,0,0.1);\n            max-width: 480px;\n            width: 100%;\n            animation: slideIn 0.3s ease-out;\n        }\n        @keyframes slideIn {\n            from {\n                opacity: 0;\n                transform: translateY(-20px);\n            }\n            to {\n                opacity: 1;\n                transform: translateY(0);\n            }\n        }\n        .success-icon {\n            width: 64px;\n            height: 64px;\n            margin: 0 auto 1.5rem;\n            background: #10b981;\n            border-radius: 50%;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            color: white;\n            font-size: 2rem;\n            font-weight: bold;\n        }\n        h1 {\n            color: #1f2937;\n            margin-bottom: 1rem;\n            font-size: 1.75rem;\n            font-weight: 600;\n        }\n        .subtitle {\n            color: #6b7280;\n            margin-bottom: 1.5rem;\n            font-size: 1rem;\n            line-height: 1.5;\n        }\n        .setup-notice {\n            background: #fef3c7;\n            border: 1px solid #f59e0b;\n            border-radius: 6px;\n            padding: 1rem;\n            margin: 1rem 0;\n        }\n        .setup-notice h3 {\n            color: #92400e;\n            margin: 0 0 0.5rem 0;\n            font-size: 1rem;\n        }\n        .setup-notice p {\n            color: #92400e;\n            margin: 0;\n            font-size: 0.875rem;\n        }\n        .setup-notice a {\n            color: #1d4ed8;\n            text-decoration: none;\n        }\n        .setup-notice a:hover {\n            text-decoration: underline;\n        }\n        .actions {\n            display: flex;\n            gap: 1rem;\n            justify-content: center;\n            flex-wrap: wrap;\n            margin-top: 2rem;\n        }\n        .button {\n            padding: 0.75rem 1.5rem;\n            border-radius: 8px;\n            font-size: 0.875rem;\n            font-weight: 500;\n            text-decoration: none;\n            transition: all 0.2s;\n            cursor: pointer;\n            border: none;\n            display: inline-flex;\n            align-items: center;\n            gap: 0.5rem;\n        }\n        .button-primary {\n            background: #3b82f6;\n            color: white;\n        }\n        .button-primary:hover {\n            background: #2563eb;\n            transform: translateY(-1px);\n        }\n        .button-secondary {\n            background: #f3f4f6;\n            color: #374151;\n            border: 1px solid #d1d5db;\n        }\n        .button-secondary:hover {\n            background: #e5e7eb;\n        }\n        .countdown {\n            color: #9ca3af;\n            font-size: 0.75rem;\n            margin-top: 1rem;\n        }\n        .footer {\n            margin-top: 2rem;\n            padding-top: 1.5rem;\n            border-top: 1px solid #e5e7eb;\n            color: #9ca3af;\n            font-size: 0.75rem;\n        }\n        .footer a {\n            color: #3b82f6;\n            text-decoration: none;\n        }\n        .footer a:hover {\n            text-decoration: underline;\n        }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"success-icon\">✓</div>\n        <h1>Authentication Successful!</h1>\n        <p class=\"subtitle\">You have successfully authenticated with Codex. You can now close this window and return to your terminal to continue.</p>\n        \n        {{SETUP_NOTICE}}\n        \n        <div class=\"actions\">\n            <button class=\"button button-primary\" onclick=\"window.close()\">\n                <span>Close Window</span>\n            </button>\n            <a href=\"{{PLATFORM_URL}}\" target=\"_blank\" class=\"button button-secondary\">\n                <span>Open Platform</span>\n                <span>↗</span>\n            </a>\n        </div>\n        \n        <div class=\"countdown\">\n            This window will close automatically in <span id=\"countdown\">10</span> seconds\n        </div>\n        \n        <div class=\"footer\">\n            <p>Powered by <a href=\"https://chatgpt.com\" target=\"_blank\">ChatGPT</a></p>\n        </div>\n    </div>\n    \n    <script>\n        let countdown = 10;\n        const countdownElement = document.getElementById('countdown');\n        \n        const timer = setInterval(() => {\n            countdown--;\n            countdownElement.textContent = countdown;\n            \n            if (countdown <= 0) {\n                clearInterval(timer);\n                window.close();\n            }\n        }, 1000);\n        \n        // Close window when user presses Escape\n        document.addEventListener('keydown', (e) => {\n            if (e.key === 'Escape') {\n                window.close();\n            }\n        });\n        \n        // Focus the close button for keyboard accessibility\n        document.querySelector('.button-primary').focus();\n    </script>\n</body>\n</html>`\n\n// SetupNoticeHTML is the HTML template for the section that provides instructions\n// for additional setup. This is displayed on the success page when further actions\n// are required from the user.\nconst SetupNoticeHtml = `\n        <div class=\"setup-notice\">\n            <h3>Additional Setup Required</h3>\n            <p>To complete your setup, please visit the <a href=\"{{PLATFORM_URL}}\" target=\"_blank\">Codex</a> to configure your account.</p>\n        </div>`\n"
  },
  {
    "path": "internal/auth/codex/jwt_parser.go",
    "content": "package codex\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\n// JWTClaims represents the claims section of a JSON Web Token (JWT).\n// It includes standard claims like issuer, subject, and expiration time, as well as\n// custom claims specific to OpenAI's authentication.\ntype JWTClaims struct {\n\tAtHash        string        `json:\"at_hash\"`\n\tAud           []string      `json:\"aud\"`\n\tAuthProvider  string        `json:\"auth_provider\"`\n\tAuthTime      int           `json:\"auth_time\"`\n\tEmail         string        `json:\"email\"`\n\tEmailVerified bool          `json:\"email_verified\"`\n\tExp           int           `json:\"exp\"`\n\tCodexAuthInfo CodexAuthInfo `json:\"https://api.openai.com/auth\"`\n\tIat           int           `json:\"iat\"`\n\tIss           string        `json:\"iss\"`\n\tJti           string        `json:\"jti\"`\n\tRat           int           `json:\"rat\"`\n\tSid           string        `json:\"sid\"`\n\tSub           string        `json:\"sub\"`\n}\n\n// Organizations defines the structure for organization details within the JWT claims.\n// It holds information about the user's organization, such as ID, role, and title.\ntype Organizations struct {\n\tID        string `json:\"id\"`\n\tIsDefault bool   `json:\"is_default\"`\n\tRole      string `json:\"role\"`\n\tTitle     string `json:\"title\"`\n}\n\n// CodexAuthInfo contains authentication-related details specific to Codex.\n// This includes ChatGPT account information, subscription status, and user/organization IDs.\ntype CodexAuthInfo struct {\n\tChatgptAccountID               string          `json:\"chatgpt_account_id\"`\n\tChatgptPlanType                string          `json:\"chatgpt_plan_type\"`\n\tChatgptSubscriptionActiveStart any             `json:\"chatgpt_subscription_active_start\"`\n\tChatgptSubscriptionActiveUntil any             `json:\"chatgpt_subscription_active_until\"`\n\tChatgptSubscriptionLastChecked time.Time       `json:\"chatgpt_subscription_last_checked\"`\n\tChatgptUserID                  string          `json:\"chatgpt_user_id\"`\n\tGroups                         []any           `json:\"groups\"`\n\tOrganizations                  []Organizations `json:\"organizations\"`\n\tUserID                         string          `json:\"user_id\"`\n}\n\n// ParseJWTToken parses a JWT token string and extracts its claims without performing\n// cryptographic signature verification. This is useful for introspecting the token's\n// contents to retrieve user information from an ID token after it has been validated\n// by the authentication server.\nfunc ParseJWTToken(token string) (*JWTClaims, error) {\n\tparts := strings.Split(token, \".\")\n\tif len(parts) != 3 {\n\t\treturn nil, fmt.Errorf(\"invalid JWT token format: expected 3 parts, got %d\", len(parts))\n\t}\n\n\t// Decode the claims (payload) part\n\tclaimsData, err := base64URLDecode(parts[1])\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode JWT claims: %w\", err)\n\t}\n\n\tvar claims JWTClaims\n\tif err = json.Unmarshal(claimsData, &claims); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal JWT claims: %w\", err)\n\t}\n\n\treturn &claims, nil\n}\n\n// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.\n// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures\n// correct decoding by re-adding the padding before decoding.\nfunc base64URLDecode(data string) ([]byte, error) {\n\t// Add padding if necessary\n\tswitch len(data) % 4 {\n\tcase 2:\n\t\tdata += \"==\"\n\tcase 3:\n\t\tdata += \"=\"\n\t}\n\n\treturn base64.URLEncoding.DecodeString(data)\n}\n\n// GetUserEmail extracts the user's email address from the JWT claims.\nfunc (c *JWTClaims) GetUserEmail() string {\n\treturn c.Email\n}\n\n// GetAccountID extracts the user's account ID (subject) from the JWT claims.\n// It retrieves the unique identifier for the user's ChatGPT account.\nfunc (c *JWTClaims) GetAccountID() string {\n\treturn c.CodexAuthInfo.ChatgptAccountID\n}\n"
  },
  {
    "path": "internal/auth/codex/oauth_server.go",
    "content": "package codex\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// OAuthServer handles the local HTTP server for OAuth callbacks.\n// It listens for the authorization code response from the OAuth provider\n// and captures the necessary parameters to complete the authentication flow.\ntype OAuthServer struct {\n\t// server is the underlying HTTP server instance\n\tserver *http.Server\n\t// port is the port number on which the server listens\n\tport int\n\t// resultChan is a channel for sending OAuth results\n\tresultChan chan *OAuthResult\n\t// errorChan is a channel for sending OAuth errors\n\terrorChan chan error\n\t// mu is a mutex for protecting server state\n\tmu sync.Mutex\n\t// running indicates whether the server is currently running\n\trunning bool\n}\n\n// OAuthResult contains the result of the OAuth callback.\n// It holds either the authorization code and state for successful authentication\n// or an error message if the authentication failed.\ntype OAuthResult struct {\n\t// Code is the authorization code received from the OAuth provider\n\tCode string\n\t// State is the state parameter used to prevent CSRF attacks\n\tState string\n\t// Error contains any error message if the OAuth flow failed\n\tError string\n}\n\n// NewOAuthServer creates a new OAuth callback server.\n// It initializes the server with the specified port and creates channels\n// for handling OAuth results and errors.\n//\n// Parameters:\n//   - port: The port number on which the server should listen\n//\n// Returns:\n//   - *OAuthServer: A new OAuthServer instance\nfunc NewOAuthServer(port int) *OAuthServer {\n\treturn &OAuthServer{\n\t\tport:       port,\n\t\tresultChan: make(chan *OAuthResult, 1),\n\t\terrorChan:  make(chan error, 1),\n\t}\n}\n\n// Start starts the OAuth callback server.\n// It sets up the HTTP handlers for the callback and success endpoints,\n// and begins listening on the specified port.\n//\n// Returns:\n//   - error: An error if the server fails to start\nfunc (s *OAuthServer) Start() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.running {\n\t\treturn fmt.Errorf(\"server is already running\")\n\t}\n\n\t// Check if port is available\n\tif !s.isPortAvailable() {\n\t\treturn fmt.Errorf(\"port %d is already in use\", s.port)\n\t}\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/auth/callback\", s.handleCallback)\n\tmux.HandleFunc(\"/success\", s.handleSuccess)\n\n\ts.server = &http.Server{\n\t\tAddr:         fmt.Sprintf(\":%d\", s.port),\n\t\tHandler:      mux,\n\t\tReadTimeout:  10 * time.Second,\n\t\tWriteTimeout: 10 * time.Second,\n\t}\n\n\ts.running = true\n\n\t// Start server in goroutine\n\tgo func() {\n\t\tif err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\ts.errorChan <- fmt.Errorf(\"server failed to start: %w\", err)\n\t\t}\n\t}()\n\n\t// Give server a moment to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\treturn nil\n}\n\n// Stop gracefully stops the OAuth callback server.\n// It performs a graceful shutdown of the HTTP server with a timeout.\n//\n// Parameters:\n//   - ctx: The context for controlling the shutdown process\n//\n// Returns:\n//   - error: An error if the server fails to stop gracefully\nfunc (s *OAuthServer) Stop(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif !s.running || s.server == nil {\n\t\treturn nil\n\t}\n\n\tlog.Debug(\"Stopping OAuth callback server\")\n\n\t// Create a context with timeout for shutdown\n\tshutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer cancel()\n\n\terr := s.server.Shutdown(shutdownCtx)\n\ts.running = false\n\ts.server = nil\n\n\treturn err\n}\n\n// WaitForCallback waits for the OAuth callback with a timeout.\n// It blocks until either an OAuth result is received, an error occurs,\n// or the specified timeout is reached.\n//\n// Parameters:\n//   - timeout: The maximum time to wait for the callback\n//\n// Returns:\n//   - *OAuthResult: The OAuth result if successful\n//   - error: An error if the callback times out or an error occurs\nfunc (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {\n\tselect {\n\tcase result := <-s.resultChan:\n\t\treturn result, nil\n\tcase err := <-s.errorChan:\n\t\treturn nil, err\n\tcase <-time.After(timeout):\n\t\treturn nil, fmt.Errorf(\"timeout waiting for OAuth callback\")\n\t}\n}\n\n// handleCallback handles the OAuth callback endpoint.\n// It extracts the authorization code and state from the callback URL,\n// validates the parameters, and sends the result to the waiting channel.\n//\n// Parameters:\n//   - w: The HTTP response writer\n//   - r: The HTTP request\nfunc (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {\n\tlog.Debug(\"Received OAuth callback\")\n\n\t// Validate request method\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\t// Extract parameters\n\tquery := r.URL.Query()\n\tcode := query.Get(\"code\")\n\tstate := query.Get(\"state\")\n\terrorParam := query.Get(\"error\")\n\n\t// Validate required parameters\n\tif errorParam != \"\" {\n\t\tlog.Errorf(\"OAuth error received: %s\", errorParam)\n\t\tresult := &OAuthResult{\n\t\t\tError: errorParam,\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, fmt.Sprintf(\"OAuth error: %s\", errorParam), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif code == \"\" {\n\t\tlog.Error(\"No authorization code received\")\n\t\tresult := &OAuthResult{\n\t\t\tError: \"no_code\",\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, \"No authorization code received\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif state == \"\" {\n\t\tlog.Error(\"No state parameter received\")\n\t\tresult := &OAuthResult{\n\t\t\tError: \"no_state\",\n\t\t}\n\t\ts.sendResult(result)\n\t\thttp.Error(w, \"No state parameter received\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Send successful result\n\tresult := &OAuthResult{\n\t\tCode:  code,\n\t\tState: state,\n\t}\n\ts.sendResult(result)\n\n\t// Redirect to success page\n\thttp.Redirect(w, r, \"/success\", http.StatusFound)\n}\n\n// handleSuccess handles the success page endpoint.\n// It serves a user-friendly HTML page indicating that authentication was successful.\n//\n// Parameters:\n//   - w: The HTTP response writer\n//   - r: The HTTP request\nfunc (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {\n\tlog.Debug(\"Serving success page\")\n\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tw.WriteHeader(http.StatusOK)\n\n\t// Parse query parameters for customization\n\tquery := r.URL.Query()\n\tsetupRequired := query.Get(\"setup_required\") == \"true\"\n\tplatformURL := query.Get(\"platform_url\")\n\tif platformURL == \"\" {\n\t\tplatformURL = \"https://platform.openai.com\"\n\t}\n\n\t// Generate success page HTML with dynamic content\n\tsuccessHTML := s.generateSuccessHTML(setupRequired, platformURL)\n\n\t_, err := w.Write([]byte(successHTML))\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to write success page: %v\", err)\n\t}\n}\n\n// generateSuccessHTML creates the HTML content for the success page.\n// It customizes the page based on whether additional setup is required\n// and includes a link to the platform.\n//\n// Parameters:\n//   - setupRequired: Whether additional setup is required after authentication\n//   - platformURL: The URL to the platform for additional setup\n//\n// Returns:\n//   - string: The HTML content for the success page\nfunc (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {\n\thtml := LoginSuccessHtml\n\n\t// Replace platform URL placeholder\n\thtml = strings.Replace(html, \"{{PLATFORM_URL}}\", platformURL, -1)\n\n\t// Add setup notice if required\n\tif setupRequired {\n\t\tsetupNotice := strings.Replace(SetupNoticeHtml, \"{{PLATFORM_URL}}\", platformURL, -1)\n\t\thtml = strings.Replace(html, \"{{SETUP_NOTICE}}\", setupNotice, 1)\n\t} else {\n\t\thtml = strings.Replace(html, \"{{SETUP_NOTICE}}\", \"\", 1)\n\t}\n\n\treturn html\n}\n\n// sendResult sends the OAuth result to the waiting channel.\n// It ensures that the result is sent without blocking the handler.\n//\n// Parameters:\n//   - result: The OAuth result to send\nfunc (s *OAuthServer) sendResult(result *OAuthResult) {\n\tselect {\n\tcase s.resultChan <- result:\n\t\tlog.Debug(\"OAuth result sent to channel\")\n\tdefault:\n\t\tlog.Warn(\"OAuth result channel is full, result dropped\")\n\t}\n}\n\n// isPortAvailable checks if the specified port is available.\n// It attempts to listen on the port to determine availability.\n//\n// Returns:\n//   - bool: True if the port is available, false otherwise\nfunc (s *OAuthServer) isPortAvailable() bool {\n\taddr := fmt.Sprintf(\":%d\", s.port)\n\tlistener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer func() {\n\t\t_ = listener.Close()\n\t}()\n\treturn true\n}\n\n// IsRunning returns whether the server is currently running.\n//\n// Returns:\n//   - bool: True if the server is running, false otherwise\nfunc (s *OAuthServer) IsRunning() bool {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn s.running\n}\n"
  },
  {
    "path": "internal/auth/codex/openai.go",
    "content": "package codex\n\n// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.\n// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.\ntype PKCECodes struct {\n\t// CodeVerifier is the cryptographically random string used to correlate\n\t// the authorization request to the token request\n\tCodeVerifier string `json:\"code_verifier\"`\n\t// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded\n\tCodeChallenge string `json:\"code_challenge\"`\n}\n\n// CodexTokenData holds the OAuth token information obtained from OpenAI.\n// It includes the ID token, access token, refresh token, and associated user details.\ntype CodexTokenData struct {\n\t// IDToken is the JWT ID token containing user claims\n\tIDToken string `json:\"id_token\"`\n\t// AccessToken is the OAuth2 access token for API access\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain new access tokens\n\tRefreshToken string `json:\"refresh_token\"`\n\t// AccountID is the OpenAI account identifier\n\tAccountID string `json:\"account_id\"`\n\t// Email is the OpenAI account email\n\tEmail string `json:\"email\"`\n\t// Expire is the timestamp of the token expire\n\tExpire string `json:\"expired\"`\n}\n\n// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.\n// This includes the API key, token data, and the timestamp of the last refresh.\ntype CodexAuthBundle struct {\n\t// APIKey is the OpenAI API key obtained from token exchange\n\tAPIKey string `json:\"api_key\"`\n\t// TokenData contains the OAuth tokens from the authentication flow\n\tTokenData CodexTokenData `json:\"token_data\"`\n\t// LastRefresh is the timestamp of the last token refresh\n\tLastRefresh string `json:\"last_refresh\"`\n}\n"
  },
  {
    "path": "internal/auth/codex/openai_auth.go",
    "content": "// Package codex provides authentication and token management for OpenAI's Codex API.\n// It handles the OAuth2 flow, including generating authorization URLs, exchanging\n// authorization codes for tokens, and refreshing expired tokens. The package also\n// defines data structures for storing and managing Codex authentication credentials.\npackage codex\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// OAuth configuration constants for OpenAI Codex\nconst (\n\tAuthURL     = \"https://auth.openai.com/oauth/authorize\"\n\tTokenURL    = \"https://auth.openai.com/oauth/token\"\n\tClientID    = \"app_EMoamEEZ73f0CkXaXp7hrann\"\n\tRedirectURI = \"http://localhost:1455/auth/callback\"\n)\n\n// CodexAuth handles the OpenAI OAuth2 authentication flow.\n// It manages the HTTP client and provides methods for generating authorization URLs,\n// exchanging authorization codes for tokens, and refreshing access tokens.\ntype CodexAuth struct {\n\thttpClient *http.Client\n}\n\n// NewCodexAuth creates a new CodexAuth service instance.\n// It initializes an HTTP client with proxy settings from the provided configuration.\nfunc NewCodexAuth(cfg *config.Config) *CodexAuth {\n\treturn &CodexAuth{\n\t\thttpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),\n\t}\n}\n\n// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).\n// It constructs the URL with the necessary parameters, including the client ID,\n// response type, redirect URI, scopes, and PKCE challenge.\nfunc (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {\n\tif pkceCodes == nil {\n\t\treturn \"\", fmt.Errorf(\"PKCE codes are required\")\n\t}\n\n\tparams := url.Values{\n\t\t\"client_id\":                  {ClientID},\n\t\t\"response_type\":              {\"code\"},\n\t\t\"redirect_uri\":               {RedirectURI},\n\t\t\"scope\":                      {\"openid email profile offline_access\"},\n\t\t\"state\":                      {state},\n\t\t\"code_challenge\":             {pkceCodes.CodeChallenge},\n\t\t\"code_challenge_method\":      {\"S256\"},\n\t\t\"prompt\":                     {\"login\"},\n\t\t\"id_token_add_organizations\": {\"true\"},\n\t\t\"codex_cli_simplified_flow\":  {\"true\"},\n\t}\n\n\tauthURL := fmt.Sprintf(\"%s?%s\", AuthURL, params.Encode())\n\treturn authURL, nil\n}\n\n// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.\n// It performs an HTTP POST request to the OpenAI token endpoint with the provided\n// authorization code and PKCE verifier.\nfunc (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {\n\treturn o.ExchangeCodeForTokensWithRedirect(ctx, code, RedirectURI, pkceCodes)\n}\n\n// ExchangeCodeForTokensWithRedirect exchanges an authorization code for tokens using\n// a caller-provided redirect URI. This supports alternate auth flows such as device\n// login while preserving the existing token parsing and storage behavior.\nfunc (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {\n\tif pkceCodes == nil {\n\t\treturn nil, fmt.Errorf(\"PKCE codes are required for token exchange\")\n\t}\n\tif strings.TrimSpace(redirectURI) == \"\" {\n\t\treturn nil, fmt.Errorf(\"redirect URI is required for token exchange\")\n\t}\n\n\t// Prepare token exchange request\n\tdata := url.Values{\n\t\t\"grant_type\":    {\"authorization_code\"},\n\t\t\"client_id\":     {ClientID},\n\t\t\"code\":          {code},\n\t\t\"redirect_uri\":  {strings.TrimSpace(redirectURI)},\n\t\t\"code_verifier\": {pkceCodes.CodeVerifier},\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", TokenURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create token request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := o.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"token exchange request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read token response: %w\", err)\n\t}\n\t// log.Debugf(\"Token response: %s\", string(body))\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token exchange failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\t// Parse token response\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tIDToken      string `json:\"id_token\"`\n\t\tTokenType    string `json:\"token_type\"`\n\t\tExpiresIn    int    `json:\"expires_in\"`\n\t}\n\n\tif err = json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse token response: %w\", err)\n\t}\n\n\t// Extract account ID from ID token\n\tclaims, err := ParseJWTToken(tokenResp.IDToken)\n\tif err != nil {\n\t\tlog.Warnf(\"Failed to parse ID token: %v\", err)\n\t}\n\n\taccountID := \"\"\n\temail := \"\"\n\tif claims != nil {\n\t\taccountID = claims.GetAccountID()\n\t\temail = claims.GetUserEmail()\n\t}\n\n\t// Create token data\n\ttokenData := CodexTokenData{\n\t\tIDToken:      tokenResp.IDToken,\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tAccountID:    accountID,\n\t\tEmail:        email,\n\t\tExpire:       time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}\n\n\t// Create auth bundle\n\tbundle := &CodexAuthBundle{\n\t\tTokenData:   tokenData,\n\t\tLastRefresh: time.Now().Format(time.RFC3339),\n\t}\n\n\treturn bundle, nil\n}\n\n// RefreshTokens refreshes an access token using a refresh token.\n// This method is called when an access token has expired. It makes a request to the\n// token endpoint to obtain a new set of tokens.\nfunc (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {\n\tif refreshToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"refresh token is required\")\n\t}\n\n\tdata := url.Values{\n\t\t\"client_id\":     {ClientID},\n\t\t\"grant_type\":    {\"refresh_token\"},\n\t\t\"refresh_token\": {refreshToken},\n\t\t\"scope\":         {\"openid profile email\"},\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", TokenURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create refresh request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := o.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"token refresh request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read refresh response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token refresh failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tIDToken      string `json:\"id_token\"`\n\t\tTokenType    string `json:\"token_type\"`\n\t\tExpiresIn    int    `json:\"expires_in\"`\n\t}\n\n\tif err = json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse refresh response: %w\", err)\n\t}\n\n\t// Extract account ID from ID token\n\tclaims, err := ParseJWTToken(tokenResp.IDToken)\n\tif err != nil {\n\t\tlog.Warnf(\"Failed to parse refreshed ID token: %v\", err)\n\t}\n\n\taccountID := \"\"\n\temail := \"\"\n\tif claims != nil {\n\t\taccountID = claims.GetAccountID()\n\t\temail = claims.Email\n\t}\n\n\treturn &CodexTokenData{\n\t\tIDToken:      tokenResp.IDToken,\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tAccountID:    accountID,\n\t\tEmail:        email,\n\t\tExpire:       time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}, nil\n}\n\n// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.\n// It populates the storage struct with token data, user information, and timestamps.\nfunc (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {\n\tstorage := &CodexTokenStorage{\n\t\tIDToken:      bundle.TokenData.IDToken,\n\t\tAccessToken:  bundle.TokenData.AccessToken,\n\t\tRefreshToken: bundle.TokenData.RefreshToken,\n\t\tAccountID:    bundle.TokenData.AccountID,\n\t\tLastRefresh:  bundle.LastRefresh,\n\t\tEmail:        bundle.TokenData.Email,\n\t\tExpire:       bundle.TokenData.Expire,\n\t}\n\n\treturn storage\n}\n\n// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.\n// It attempts to refresh the tokens up to a specified maximum number of retries,\n// with an exponential backoff strategy to handle transient network errors.\nfunc (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// Wait before retry\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(time.Duration(attempt) * time.Second):\n\t\t\t}\n\t\t}\n\n\t\ttokenData, err := o.RefreshTokens(ctx, refreshToken)\n\t\tif err == nil {\n\t\t\treturn tokenData, nil\n\t\t}\n\t\tif isNonRetryableRefreshErr(err) {\n\t\t\tlog.Warnf(\"Token refresh attempt %d failed with non-retryable error: %v\", attempt+1, err)\n\t\t\treturn nil, err\n\t\t}\n\n\t\tlastErr = err\n\t\tlog.Warnf(\"Token refresh attempt %d failed: %v\", attempt+1, err)\n\t}\n\n\treturn nil, fmt.Errorf(\"token refresh failed after %d attempts: %w\", maxRetries, lastErr)\n}\n\nfunc isNonRetryableRefreshErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\traw := strings.ToLower(err.Error())\n\treturn strings.Contains(raw, \"refresh_token_reused\")\n}\n\n// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.\n// This is typically called after a successful token refresh to persist the new credentials.\nfunc (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {\n\tstorage.IDToken = tokenData.IDToken\n\tstorage.AccessToken = tokenData.AccessToken\n\tstorage.RefreshToken = tokenData.RefreshToken\n\tstorage.AccountID = tokenData.AccountID\n\tstorage.LastRefresh = time.Now().Format(time.RFC3339)\n\tstorage.Email = tokenData.Email\n\tstorage.Expire = tokenData.Expire\n}\n"
  },
  {
    "path": "internal/auth/codex/openai_auth_test.go",
    "content": "package codex\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n)\n\ntype roundTripFunc func(*http.Request) (*http.Response, error)\n\nfunc (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {\n\treturn f(req)\n}\n\nfunc TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) {\n\tvar calls int32\n\tauth := &CodexAuth{\n\t\thttpClient: &http.Client{\n\t\t\tTransport: roundTripFunc(func(req *http.Request) (*http.Response, error) {\n\t\t\t\tatomic.AddInt32(&calls, 1)\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\t\t\tBody:       io.NopCloser(strings.NewReader(`{\"error\":\"invalid_grant\",\"code\":\"refresh_token_reused\"}`)),\n\t\t\t\t\tHeader:     make(http.Header),\n\t\t\t\t\tRequest:    req,\n\t\t\t\t}, nil\n\t\t\t}),\n\t\t},\n\t}\n\n\t_, err := auth.RefreshTokensWithRetry(context.Background(), \"dummy_refresh_token\", 3)\n\tif err == nil {\n\t\tt.Fatalf(\"expected error for non-retryable refresh failure\")\n\t}\n\tif !strings.Contains(strings.ToLower(err.Error()), \"refresh_token_reused\") {\n\t\tt.Fatalf(\"expected refresh_token_reused in error, got: %v\", err)\n\t}\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"expected 1 refresh attempt, got %d\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/auth/codex/pkce.go",
    "content": "// Package codex provides authentication and token management functionality\n// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)\n// code generation for secure authentication flows.\npackage codex\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n)\n\n// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.\n// It creates a cryptographically random code verifier and its corresponding\n// SHA256 code challenge, as specified in RFC 7636. This is a critical security\n// feature for the OAuth 2.0 authorization code flow.\nfunc GeneratePKCECodes() (*PKCECodes, error) {\n\t// Generate code verifier: 43-128 characters, URL-safe\n\tcodeVerifier, err := generateCodeVerifier()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate code verifier: %w\", err)\n\t}\n\n\t// Generate code challenge using S256 method\n\tcodeChallenge := generateCodeChallenge(codeVerifier)\n\n\treturn &PKCECodes{\n\t\tCodeVerifier:  codeVerifier,\n\t\tCodeChallenge: codeChallenge,\n\t}, nil\n}\n\n// generateCodeVerifier creates a cryptographically secure random string to be used\n// as the code verifier in the PKCE flow. The verifier is a high-entropy string\n// that is later used to prove possession of the client that initiated the\n// authorization request.\nfunc generateCodeVerifier() (string, error) {\n\t// Generate 96 random bytes (will result in 128 base64 characters)\n\tbytes := make([]byte, 96)\n\t_, err := rand.Read(bytes)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate random bytes: %w\", err)\n\t}\n\n\t// Encode to URL-safe base64 without padding\n\treturn base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil\n}\n\n// generateCodeChallenge creates a code challenge from a given code verifier.\n// The challenge is derived by taking the SHA256 hash of the verifier and then\n// Base64 URL-encoding the result. This is sent in the initial authorization\n// request and later verified against the verifier.\nfunc generateCodeChallenge(codeVerifier string) string {\n\thash := sha256.Sum256([]byte(codeVerifier))\n\treturn base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])\n}\n"
  },
  {
    "path": "internal/auth/codex/token.go",
    "content": "// Package codex provides authentication and token management functionality\n// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Codex API.\npackage codex\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n)\n\n// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.\n// It maintains compatibility with the existing auth system while adding Codex-specific fields\n// for managing access tokens, refresh tokens, and user account information.\ntype CodexTokenStorage struct {\n\t// IDToken is the JWT ID token containing user claims and identity information.\n\tIDToken string `json:\"id_token\"`\n\t// AccessToken is the OAuth2 access token used for authenticating API requests.\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain new access tokens when the current one expires.\n\tRefreshToken string `json:\"refresh_token\"`\n\t// AccountID is the OpenAI account identifier associated with this token.\n\tAccountID string `json:\"account_id\"`\n\t// LastRefresh is the timestamp of the last token refresh operation.\n\tLastRefresh string `json:\"last_refresh\"`\n\t// Email is the OpenAI account email address associated with this token.\n\tEmail string `json:\"email\"`\n\t// Type indicates the authentication provider type, always \"codex\" for this storage.\n\tType string `json:\"type\"`\n\t// Expire is the timestamp when the current access token expires.\n\tExpire string `json:\"expired\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *CodexTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// SaveTokenToFile serializes the Codex token storage to a JSON file.\n// This method creates the necessary directory structure and writes the token\n// data in JSON format to the specified file path for persistent storage.\n// It merges any injected metadata into the top-level JSON object.\n//\n// Parameters:\n//   - authFilePath: The full path where the token file should be saved\n//\n// Returns:\n//   - error: An error if the operation fails, nil otherwise\nfunc (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"codex\"\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %v\", err)\n\t}\n\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create token file: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = f.Close()\n\t}()\n\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\n\tif err = json.NewEncoder(f).Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write token to file: %w\", err)\n\t}\n\treturn nil\n\n}\n"
  },
  {
    "path": "internal/auth/empty/token.go",
    "content": "// Package empty provides a no-operation token storage implementation.\n// This package is used when authentication tokens are not required or when\n// using API key-based authentication instead of OAuth tokens for any provider.\npackage empty\n\n// EmptyStorage is a no-operation implementation of the TokenStorage interface.\n// It provides empty implementations for scenarios where token storage is not needed,\n// such as when using API keys instead of OAuth tokens for authentication.\ntype EmptyStorage struct {\n\t// Type indicates the authentication provider type, always \"empty\" for this implementation.\n\tType string `json:\"type\"`\n}\n\n// SaveTokenToFile is a no-operation implementation that always succeeds.\n// This method satisfies the TokenStorage interface but performs no actual file operations\n// since empty storage doesn't require persistent token data.\n//\n// Parameters:\n//   - _: The file path parameter is ignored in this implementation\n//\n// Returns:\n//   - error: Always returns nil (no error)\nfunc (ts *EmptyStorage) SaveTokenToFile(_ string) error {\n\tts.Type = \"empty\"\n\treturn nil\n}\n"
  },
  {
    "path": "internal/auth/gemini/gemini_auth.go",
    "content": "// Package gemini provides authentication and token management functionality\n// for Google's Gemini AI services. It handles OAuth2 authentication flows,\n// including obtaining tokens via web-based authorization, storing tokens,\n// and refreshing them when they expire.\npackage gemini\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/google\"\n)\n\n// OAuth configuration constants for Gemini\nconst (\n\tClientID            = \"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com\"\n\tClientSecret        = \"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\"\n\tDefaultCallbackPort = 8085\n)\n\n// OAuth scopes for Gemini authentication\nvar Scopes = []string{\n\t\"https://www.googleapis.com/auth/cloud-platform\",\n\t\"https://www.googleapis.com/auth/userinfo.email\",\n\t\"https://www.googleapis.com/auth/userinfo.profile\",\n}\n\n// GeminiAuth provides methods for handling the Gemini OAuth2 authentication flow.\n// It encapsulates the logic for obtaining, storing, and refreshing authentication tokens\n// for Google's Gemini AI services.\ntype GeminiAuth struct {\n}\n\n// WebLoginOptions customizes the interactive OAuth flow.\ntype WebLoginOptions struct {\n\tNoBrowser    bool\n\tCallbackPort int\n\tPrompt       func(string) (string, error)\n}\n\n// NewGeminiAuth creates a new instance of GeminiAuth.\nfunc NewGeminiAuth() *GeminiAuth {\n\treturn &GeminiAuth{}\n}\n\n// GetAuthenticatedClient configures and returns an HTTP client ready for making authenticated API calls.\n// It manages the entire OAuth2 flow, including handling proxies, loading existing tokens,\n// initiating a new web-based OAuth flow if necessary, and refreshing tokens.\n//\n// Parameters:\n//   - ctx: The context for the HTTP client\n//   - ts: The Gemini token storage containing authentication tokens\n//   - cfg: The configuration containing proxy settings\n//   - opts: Optional parameters to customize browser and prompt behavior\n//\n// Returns:\n//   - *http.Client: An HTTP client configured with authentication\n//   - error: An error if the client configuration fails, nil otherwise\nfunc (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, opts *WebLoginOptions) (*http.Client, error) {\n\tcallbackPort := DefaultCallbackPort\n\tif opts != nil && opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\tcallbackURL := fmt.Sprintf(\"http://localhost:%d/oauth2callback\", callbackPort)\n\n\ttransport, _, errBuild := proxyutil.BuildHTTPTransport(cfg.ProxyURL)\n\tif errBuild != nil {\n\t\tlog.Errorf(\"%v\", errBuild)\n\t} else if transport != nil {\n\t\tproxyClient := &http.Client{Transport: transport}\n\t\tctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)\n\t}\n\n\tvar err error\n\n\t// Configure the OAuth2 client.\n\tconf := &oauth2.Config{\n\t\tClientID:     ClientID,\n\t\tClientSecret: ClientSecret,\n\t\tRedirectURL:  callbackURL, // This will be used by the local server.\n\t\tScopes:       Scopes,\n\t\tEndpoint:     google.Endpoint,\n\t}\n\n\tvar token *oauth2.Token\n\n\t// If no token is found in storage, initiate the web-based OAuth flow.\n\tif ts.Token == nil {\n\t\tfmt.Printf(\"Could not load token from file, starting OAuth flow.\\n\")\n\t\ttoken, err = g.getTokenFromWeb(ctx, conf, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get token from web: %w\", err)\n\t\t}\n\t\t// After getting a new token, create a new token storage object with user info.\n\t\tnewTs, errCreateTokenStorage := g.createTokenStorage(ctx, conf, token, ts.ProjectID)\n\t\tif errCreateTokenStorage != nil {\n\t\t\tlog.Errorf(\"Warning: failed to create token storage: %v\", errCreateTokenStorage)\n\t\t\treturn nil, errCreateTokenStorage\n\t\t}\n\t\t*ts = *newTs\n\t}\n\n\t// Unmarshal the stored token into an oauth2.Token object.\n\ttsToken, _ := json.Marshal(ts.Token)\n\tif err = json.Unmarshal(tsToken, &token); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal token: %w\", err)\n\t}\n\n\t// Return an HTTP client that automatically handles token refreshing.\n\treturn conf.Client(ctx, token), nil\n}\n\n// createTokenStorage creates a new GeminiTokenStorage object. It fetches the user's email\n// using the provided token and populates the storage structure.\n//\n// Parameters:\n//   - ctx: The context for the HTTP request\n//   - config: The OAuth2 configuration\n//   - token: The OAuth2 token to use for authentication\n//   - projectID: The Google Cloud Project ID to associate with this token\n//\n// Returns:\n//   - *GeminiTokenStorage: A new token storage object with user information\n//   - error: An error if the token storage creation fails, nil otherwise\nfunc (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {\n\thttpClient := config.Client(ctx, token)\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"https://www.googleapis.com/oauth2/v1/userinfo?alt=json\", nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not get user info: %v\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token.AccessToken))\n\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to execute request: %w\", err)\n\t}\n\tdefer func() {\n\t\tif err = resp.Body.Close(); err != nil {\n\t\t\tlog.Printf(\"warn: failed to close response body: %v\", err)\n\t\t}\n\t}()\n\n\tbodyBytes, _ := io.ReadAll(resp.Body)\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\treturn nil, fmt.Errorf(\"get user info request failed with status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\temailResult := gjson.GetBytes(bodyBytes, \"email\")\n\tif emailResult.Exists() && emailResult.Type == gjson.String {\n\t\tfmt.Printf(\"Authenticated user email: %s\\n\", emailResult.String())\n\t} else {\n\t\tfmt.Println(\"Failed to get user email from token\")\n\t}\n\n\tvar ifToken map[string]any\n\tjsonData, _ := json.Marshal(token)\n\terr = json.Unmarshal(jsonData, &ifToken)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal token: %w\", err)\n\t}\n\n\tifToken[\"token_uri\"] = \"https://oauth2.googleapis.com/token\"\n\tifToken[\"client_id\"] = ClientID\n\tifToken[\"client_secret\"] = ClientSecret\n\tifToken[\"scopes\"] = Scopes\n\tifToken[\"universe_domain\"] = \"googleapis.com\"\n\n\tts := GeminiTokenStorage{\n\t\tToken:     ifToken,\n\t\tProjectID: projectID,\n\t\tEmail:     emailResult.String(),\n\t}\n\n\treturn &ts, nil\n}\n\n// getTokenFromWeb initiates the web-based OAuth2 authorization flow.\n// It starts a local HTTP server to listen for the callback from Google's auth server,\n// opens the user's browser to the authorization URL, and exchanges the received\n// authorization code for an access token.\n//\n// Parameters:\n//   - ctx: The context for the HTTP client\n//   - config: The OAuth2 configuration\n//   - opts: Optional parameters to customize browser and prompt behavior\n//\n// Returns:\n//   - *oauth2.Token: The OAuth2 token obtained from the authorization flow\n//   - error: An error if the token acquisition fails, nil otherwise\nfunc (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, opts *WebLoginOptions) (*oauth2.Token, error) {\n\tcallbackPort := DefaultCallbackPort\n\tif opts != nil && opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\tcallbackURL := fmt.Sprintf(\"http://localhost:%d/oauth2callback\", callbackPort)\n\n\t// Use a channel to pass the authorization code from the HTTP handler to the main function.\n\tcodeChan := make(chan string, 1)\n\terrChan := make(chan error, 1)\n\n\t// Create a new HTTP server with its own multiplexer.\n\tmux := http.NewServeMux()\n\tserver := &http.Server{Addr: fmt.Sprintf(\":%d\", callbackPort), Handler: mux}\n\tconfig.RedirectURL = callbackURL\n\n\tmux.HandleFunc(\"/oauth2callback\", func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := r.URL.Query().Get(\"error\"); err != \"\" {\n\t\t\t_, _ = fmt.Fprintf(w, \"Authentication failed: %s\", err)\n\t\t\tselect {\n\t\t\tcase errChan <- fmt.Errorf(\"authentication failed via callback: %s\", err):\n\t\t\tdefault:\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tcode := r.URL.Query().Get(\"code\")\n\t\tif code == \"\" {\n\t\t\t_, _ = fmt.Fprint(w, \"Authentication failed: code not found.\")\n\t\t\tselect {\n\t\t\tcase errChan <- fmt.Errorf(\"code not found in callback\"):\n\t\t\tdefault:\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t_, _ = fmt.Fprint(w, \"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>\")\n\t\tselect {\n\t\tcase codeChan <- code:\n\t\tdefault:\n\t\t}\n\t})\n\n\t// Start the server in a goroutine.\n\tgo func() {\n\t\tif err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {\n\t\t\tlog.Errorf(\"ListenAndServe(): %v\", err)\n\t\t\tselect {\n\t\t\tcase errChan <- err:\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Open the authorization URL in the user's browser.\n\tauthURL := config.AuthCodeURL(\"state-token\", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam(\"prompt\", \"consent\"))\n\n\tnoBrowser := false\n\tif opts != nil {\n\t\tnoBrowser = opts.NoBrowser\n\t}\n\n\tif !noBrowser {\n\t\tfmt.Println(\"Opening browser for authentication...\")\n\n\t\t// Check if browser is available\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available on this system\")\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Please manually open this URL in your browser:\\n\\n%s\\n\", authURL)\n\t\t} else {\n\t\t\tif err := browser.OpenURL(authURL); err != nil {\n\t\t\t\tauthErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)\n\t\t\t\tlog.Warn(codex.GetUserFriendlyMessage(authErr))\n\t\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\t\tfmt.Printf(\"Please manually open this URL in your browser:\\n\\n%s\\n\", authURL)\n\n\t\t\t\t// Log platform info for debugging\n\t\t\t\tplatformInfo := browser.GetPlatformInfo()\n\t\t\t\tlog.Debugf(\"Browser platform info: %+v\", platformInfo)\n\t\t\t} else {\n\t\t\t\tlog.Debug(\"Browser opened successfully\")\n\t\t\t}\n\t\t}\n\t} else {\n\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\tfmt.Printf(\"Please open this URL in your browser:\\n\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for authentication callback...\")\n\n\t// Wait for the authorization code or an error.\n\tvar authCode string\n\ttimeoutTimer := time.NewTimer(5 * time.Minute)\n\tdefer timeoutTimer.Stop()\n\n\tvar manualPromptTimer *time.Timer\n\tvar manualPromptC <-chan time.Time\n\tif opts != nil && opts.Prompt != nil {\n\t\tmanualPromptTimer = time.NewTimer(15 * time.Second)\n\t\tmanualPromptC = manualPromptTimer.C\n\t\tdefer manualPromptTimer.Stop()\n\t}\n\nwaitForCallback:\n\tfor {\n\t\tselect {\n\t\tcase code := <-codeChan:\n\t\t\tauthCode = code\n\t\t\tbreak waitForCallback\n\t\tcase err := <-errChan:\n\t\t\treturn nil, err\n\t\tcase <-manualPromptC:\n\t\t\tmanualPromptC = nil\n\t\t\tif manualPromptTimer != nil {\n\t\t\t\tmanualPromptTimer.Stop()\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase code := <-codeChan:\n\t\t\t\tauthCode = code\n\t\t\t\tbreak waitForCallback\n\t\t\tcase err := <-errChan:\n\t\t\t\treturn nil, err\n\t\t\tdefault:\n\t\t\t}\n\t\t\tinput, err := opts.Prompt(\"Paste the Gemini callback URL (or press Enter to keep waiting): \")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tparsed, err := misc.ParseOAuthCallback(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif parsed == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif parsed.Error != \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"authentication failed via callback: %s\", parsed.Error)\n\t\t\t}\n\t\t\tif parsed.Code == \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"code not found in callback\")\n\t\t\t}\n\t\t\tauthCode = parsed.Code\n\t\t\tbreak waitForCallback\n\t\tcase <-timeoutTimer.C:\n\t\t\treturn nil, fmt.Errorf(\"oauth flow timed out\")\n\t\t}\n\t}\n\n\t// Shutdown the server.\n\tif err := server.Shutdown(ctx); err != nil {\n\t\tlog.Errorf(\"Failed to shut down server: %v\", err)\n\t}\n\n\t// Exchange the authorization code for a token.\n\ttoken, err := config.Exchange(ctx, authCode)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to exchange token: %w\", err)\n\t}\n\n\tfmt.Println(\"Authentication successful.\")\n\treturn token, nil\n}\n"
  },
  {
    "path": "internal/auth/gemini/gemini_token.go",
    "content": "// Package gemini provides authentication and token management functionality\n// for Google's Gemini AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Gemini API.\npackage gemini\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication.\n// It maintains compatibility with the existing auth system while adding Gemini-specific fields\n// for managing access tokens, refresh tokens, and user account information.\ntype GeminiTokenStorage struct {\n\t// Token holds the raw OAuth2 token data, including access and refresh tokens.\n\tToken any `json:\"token\"`\n\n\t// ProjectID is the Google Cloud Project ID associated with this token.\n\tProjectID string `json:\"project_id\"`\n\n\t// Email is the email address of the authenticated user.\n\tEmail string `json:\"email\"`\n\n\t// Auto indicates if the project ID was automatically selected.\n\tAuto bool `json:\"auto\"`\n\n\t// Checked indicates if the associated Cloud AI API has been verified as enabled.\n\tChecked bool `json:\"checked\"`\n\n\t// Type indicates the authentication provider type, always \"gemini\" for this storage.\n\tType string `json:\"type\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *GeminiTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// SaveTokenToFile serializes the Gemini token storage to a JSON file.\n// This method creates the necessary directory structure and writes the token\n// data in JSON format to the specified file path for persistent storage.\n// It merges any injected metadata into the top-level JSON object.\n//\n// Parameters:\n//   - authFilePath: The full path where the token file should be saved\n//\n// Returns:\n//   - error: An error if the operation fails, nil otherwise\nfunc (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"gemini\"\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %v\", err)\n\t}\n\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create token file: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := f.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"failed to close file: %v\", errClose)\n\t\t}\n\t}()\n\n\tenc := json.NewEncoder(f)\n\tenc.SetIndent(\"\", \"  \")\n\tif err := enc.Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write token to file: %w\", err)\n\t}\n\treturn nil\n}\n\n// CredentialFileName returns the filename used to persist Gemini CLI credentials.\n// When projectID represents multiple projects (comma-separated or literal ALL),\n// the suffix is normalized to \"all\" and a \"gemini-\" prefix is enforced to keep\n// web and CLI generated files consistent.\nfunc CredentialFileName(email, projectID string, includeProviderPrefix bool) string {\n\temail = strings.TrimSpace(email)\n\tproject := strings.TrimSpace(projectID)\n\tif strings.EqualFold(project, \"all\") || strings.Contains(project, \",\") {\n\t\treturn fmt.Sprintf(\"gemini-%s-all.json\", email)\n\t}\n\tprefix := \"\"\n\tif includeProviderPrefix {\n\t\tprefix = \"gemini-\"\n\t}\n\treturn fmt.Sprintf(\"%s%s-%s.json\", prefix, email, project)\n}\n"
  },
  {
    "path": "internal/auth/iflow/cookie_helpers.go",
    "content": "package iflow\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// NormalizeCookie normalizes raw cookie strings for iFlow authentication flows.\nfunc NormalizeCookie(raw string) (string, error) {\n\ttrimmed := strings.TrimSpace(raw)\n\tif trimmed == \"\" {\n\t\treturn \"\", fmt.Errorf(\"cookie cannot be empty\")\n\t}\n\n\tcombined := strings.Join(strings.Fields(trimmed), \" \")\n\tif !strings.HasSuffix(combined, \";\") {\n\t\tcombined += \";\"\n\t}\n\tif !strings.Contains(combined, \"BXAuth=\") {\n\t\treturn \"\", fmt.Errorf(\"cookie missing BXAuth field\")\n\t}\n\treturn combined, nil\n}\n\n// SanitizeIFlowFileName normalizes user identifiers for safe filename usage.\nfunc SanitizeIFlowFileName(raw string) string {\n\tif raw == \"\" {\n\t\treturn \"\"\n\t}\n\tcleanEmail := strings.ReplaceAll(raw, \"*\", \"x\")\n\tvar result strings.Builder\n\tfor _, r := range cleanEmail {\n\t\tif (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '@' || r == '.' || r == '-' {\n\t\t\tresult.WriteRune(r)\n\t\t}\n\t}\n\treturn strings.TrimSpace(result.String())\n}\n\n// ExtractBXAuth extracts the BXAuth value from a cookie string.\nfunc ExtractBXAuth(cookie string) string {\n\tparts := strings.Split(cookie, \";\")\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif strings.HasPrefix(part, \"BXAuth=\") {\n\t\t\treturn strings.TrimPrefix(part, \"BXAuth=\")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// CheckDuplicateBXAuth checks if the given BXAuth value already exists in any iflow auth file.\n// Returns the path of the existing file if found, empty string otherwise.\nfunc CheckDuplicateBXAuth(authDir, bxAuth string) (string, error) {\n\tif bxAuth == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tentries, err := os.ReadDir(authDir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn \"\", nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"read auth dir failed: %w\", err)\n\t}\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !strings.HasPrefix(name, \"iflow-\") || !strings.HasSuffix(name, \".json\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tfilePath := filepath.Join(authDir, name)\n\t\tdata, err := os.ReadFile(filePath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar tokenData struct {\n\t\t\tCookie string `json:\"cookie\"`\n\t\t}\n\t\tif err := json.Unmarshal(data, &tokenData); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\texistingBXAuth := ExtractBXAuth(tokenData.Cookie)\n\t\tif existingBXAuth != \"\" && existingBXAuth == bxAuth {\n\t\t\treturn filePath, nil\n\t\t}\n\t}\n\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "internal/auth/iflow/iflow_auth.go",
    "content": "package iflow\n\nimport (\n\t\"compress/gzip\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\t// OAuth endpoints and client metadata are derived from the reference Python implementation.\n\tiFlowOAuthTokenEndpoint     = \"https://iflow.cn/oauth/token\"\n\tiFlowOAuthAuthorizeEndpoint = \"https://iflow.cn/oauth\"\n\tiFlowUserInfoEndpoint       = \"https://iflow.cn/api/oauth/getUserInfo\"\n\tiFlowSuccessRedirectURL     = \"https://iflow.cn/oauth/success\"\n\n\t// Cookie authentication endpoints\n\tiFlowAPIKeyEndpoint = \"https://platform.iflow.cn/api/openapi/apikey\"\n\n\t// Client credentials provided by iFlow for the Code Assist integration.\n\tiFlowOAuthClientID     = \"10009311001\"\n\tiFlowOAuthClientSecret = \"4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW\"\n)\n\n// DefaultAPIBaseURL is the canonical chat completions endpoint.\nconst DefaultAPIBaseURL = \"https://apis.iflow.cn/v1\"\n\n// SuccessRedirectURL is exposed for consumers needing the official success page.\nconst SuccessRedirectURL = iFlowSuccessRedirectURL\n\n// CallbackPort defines the local port used for OAuth callbacks.\nconst CallbackPort = 11451\n\n// IFlowAuth encapsulates the HTTP client helpers for the OAuth flow.\ntype IFlowAuth struct {\n\thttpClient *http.Client\n}\n\n// NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport.\nfunc NewIFlowAuth(cfg *config.Config) *IFlowAuth {\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\treturn &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, client)}\n}\n\n// AuthorizationURL builds the authorization URL and matching redirect URI.\nfunc (ia *IFlowAuth) AuthorizationURL(state string, port int) (authURL, redirectURI string) {\n\tredirectURI = fmt.Sprintf(\"http://localhost:%d/oauth2callback\", port)\n\tvalues := url.Values{}\n\tvalues.Set(\"loginMethod\", \"phone\")\n\tvalues.Set(\"type\", \"phone\")\n\tvalues.Set(\"redirect\", redirectURI)\n\tvalues.Set(\"state\", state)\n\tvalues.Set(\"client_id\", iFlowOAuthClientID)\n\tauthURL = fmt.Sprintf(\"%s?%s\", iFlowOAuthAuthorizeEndpoint, values.Encode())\n\treturn authURL, redirectURI\n}\n\n// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.\nfunc (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*IFlowTokenData, error) {\n\tform := url.Values{}\n\tform.Set(\"grant_type\", \"authorization_code\")\n\tform.Set(\"code\", code)\n\tform.Set(\"redirect_uri\", redirectURI)\n\tform.Set(\"client_id\", iFlowOAuthClientID)\n\tform.Set(\"client_secret\", iFlowOAuthClientSecret)\n\n\treq, err := ia.newTokenRequest(ctx, form)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ia.doTokenRequest(ctx, req)\n}\n\n// RefreshTokens exchanges a refresh token for a new access token.\nfunc (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*IFlowTokenData, error) {\n\tform := url.Values{}\n\tform.Set(\"grant_type\", \"refresh_token\")\n\tform.Set(\"refresh_token\", refreshToken)\n\tform.Set(\"client_id\", iFlowOAuthClientID)\n\tform.Set(\"client_secret\", iFlowOAuthClientSecret)\n\n\treq, err := ia.newTokenRequest(ctx, form)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ia.doTokenRequest(ctx, req)\n}\n\nfunc (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*http.Request, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowOAuthTokenEndpoint, strings.NewReader(form.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow token: create request failed: %w\", err)\n\t}\n\n\tbasic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + \":\" + iFlowOAuthClientSecret))\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Basic \"+basic)\n\treturn req, nil\n}\n\nfunc (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IFlowTokenData, error) {\n\tresp, err := ia.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow token: request failed: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow token: read response failed: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlog.Debugf(\"iflow token request failed: status=%d body=%s\", resp.StatusCode, string(body))\n\t\treturn nil, fmt.Errorf(\"iflow token: %d %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tvar tokenResp IFlowTokenResponse\n\tif err = json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow token: decode response failed: %w\", err)\n\t}\n\n\tdata := &IFlowTokenData{\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tTokenType:    tokenResp.TokenType,\n\t\tScope:        tokenResp.Scope,\n\t\tExpire:       time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}\n\n\tif tokenResp.AccessToken == \"\" {\n\t\tlog.Debug(string(body))\n\t\treturn nil, fmt.Errorf(\"iflow token: missing access token in response\")\n\t}\n\n\tinfo, errAPI := ia.FetchUserInfo(ctx, tokenResp.AccessToken)\n\tif errAPI != nil {\n\t\treturn nil, fmt.Errorf(\"iflow token: fetch user info failed: %w\", errAPI)\n\t}\n\tif strings.TrimSpace(info.APIKey) == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow token: empty api key returned\")\n\t}\n\temail := strings.TrimSpace(info.Email)\n\tif email == \"\" {\n\t\temail = strings.TrimSpace(info.Phone)\n\t}\n\tif email == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow token: missing account email/phone in user info\")\n\t}\n\tdata.APIKey = info.APIKey\n\tdata.Email = email\n\n\treturn data, nil\n}\n\n// FetchUserInfo retrieves account metadata (including API key) for the provided access token.\nfunc (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*userInfoData, error) {\n\tif strings.TrimSpace(accessToken) == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow api key: access token is empty\")\n\t}\n\n\tendpoint := fmt.Sprintf(\"%s?accessToken=%s\", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow api key: create request failed: %w\", err)\n\t}\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := ia.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow api key: request failed: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow api key: read response failed: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlog.Debugf(\"iflow api key failed: status=%d body=%s\", resp.StatusCode, string(body))\n\t\treturn nil, fmt.Errorf(\"iflow api key: %d %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tvar result userInfoResponse\n\tif err = json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow api key: decode body failed: %w\", err)\n\t}\n\n\tif !result.Success {\n\t\treturn nil, fmt.Errorf(\"iflow api key: request not successful\")\n\t}\n\n\tif result.Data.APIKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow api key: missing api key in response\")\n\t}\n\n\treturn &result.Data, nil\n}\n\n// CreateTokenStorage converts token data into persistence storage.\nfunc (ia *IFlowAuth) CreateTokenStorage(data *IFlowTokenData) *IFlowTokenStorage {\n\tif data == nil {\n\t\treturn nil\n\t}\n\treturn &IFlowTokenStorage{\n\t\tAccessToken:  data.AccessToken,\n\t\tRefreshToken: data.RefreshToken,\n\t\tLastRefresh:  time.Now().Format(time.RFC3339),\n\t\tExpire:       data.Expire,\n\t\tAPIKey:       data.APIKey,\n\t\tEmail:        data.Email,\n\t\tTokenType:    data.TokenType,\n\t\tScope:        data.Scope,\n\t}\n}\n\n// UpdateTokenStorage updates the persisted token storage with latest token data.\nfunc (ia *IFlowAuth) UpdateTokenStorage(storage *IFlowTokenStorage, data *IFlowTokenData) {\n\tif storage == nil || data == nil {\n\t\treturn\n\t}\n\tstorage.AccessToken = data.AccessToken\n\tstorage.RefreshToken = data.RefreshToken\n\tstorage.LastRefresh = time.Now().Format(time.RFC3339)\n\tstorage.Expire = data.Expire\n\tif data.APIKey != \"\" {\n\t\tstorage.APIKey = data.APIKey\n\t}\n\tif data.Email != \"\" {\n\t\tstorage.Email = data.Email\n\t}\n\tstorage.TokenType = data.TokenType\n\tstorage.Scope = data.Scope\n}\n\n// IFlowTokenResponse models the OAuth token endpoint response.\ntype IFlowTokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tExpiresIn    int    `json:\"expires_in\"`\n\tTokenType    string `json:\"token_type\"`\n\tScope        string `json:\"scope\"`\n}\n\n// IFlowTokenData captures processed token details.\ntype IFlowTokenData struct {\n\tAccessToken  string\n\tRefreshToken string\n\tTokenType    string\n\tScope        string\n\tExpire       string\n\tAPIKey       string\n\tEmail        string\n\tCookie       string\n}\n\n// userInfoResponse represents the structure returned by the user info endpoint.\ntype userInfoResponse struct {\n\tSuccess bool         `json:\"success\"`\n\tData    userInfoData `json:\"data\"`\n}\n\ntype userInfoData struct {\n\tAPIKey string `json:\"apiKey\"`\n\tEmail  string `json:\"email\"`\n\tPhone  string `json:\"phone\"`\n}\n\n// iFlowAPIKeyResponse represents the response from the API key endpoint\ntype iFlowAPIKeyResponse struct {\n\tSuccess bool         `json:\"success\"`\n\tCode    string       `json:\"code\"`\n\tMessage string       `json:\"message\"`\n\tData    iFlowKeyData `json:\"data\"`\n\tExtra   interface{}  `json:\"extra\"`\n}\n\n// iFlowKeyData contains the API key information\ntype iFlowKeyData struct {\n\tHasExpired bool   `json:\"hasExpired\"`\n\tExpireTime string `json:\"expireTime\"`\n\tName       string `json:\"name\"`\n\tAPIKey     string `json:\"apiKey\"`\n\tAPIKeyMask string `json:\"apiKeyMask\"`\n}\n\n// iFlowRefreshRequest represents the request body for refreshing API key\ntype iFlowRefreshRequest struct {\n\tName string `json:\"name\"`\n}\n\n// AuthenticateWithCookie performs authentication using browser cookies\nfunc (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string) (*IFlowTokenData, error) {\n\tif strings.TrimSpace(cookie) == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow cookie authentication: cookie is empty\")\n\t}\n\n\t// First, get initial API key information using GET request to obtain the name\n\tkeyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie authentication: fetch initial API key info failed: %w\", err)\n\t}\n\n\t// Refresh the API key using POST request\n\trefreshedKeyInfo, err := ia.RefreshAPIKey(ctx, cookie, keyInfo.Name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie authentication: refresh API key failed: %w\", err)\n\t}\n\n\t// Convert to token data format using refreshed key\n\tdata := &IFlowTokenData{\n\t\tAPIKey: refreshedKeyInfo.APIKey,\n\t\tExpire: refreshedKeyInfo.ExpireTime,\n\t\tEmail:  refreshedKeyInfo.Name,\n\t\tCookie: cookie,\n\t}\n\n\treturn data, nil\n}\n\n// fetchAPIKeyInfo retrieves API key information using GET request with cookie\nfunc (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie: create GET request failed: %w\", err)\n\t}\n\n\t// Set cookie and other headers to mimic browser\n\treq.Header.Set(\"Cookie\", cookie)\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate, br\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\n\treq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\n\treq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\n\n\tresp, err := ia.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie: GET request failed: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\t// Handle gzip compression\n\tvar reader io.Reader = resp.Body\n\tif resp.Header.Get(\"Content-Encoding\") == \"gzip\" {\n\t\tgzipReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"iflow cookie: create gzip reader failed: %w\", err)\n\t\t}\n\t\tdefer func() { _ = gzipReader.Close() }()\n\t\treader = gzipReader\n\t}\n\n\tbody, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie: read GET response failed: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlog.Debugf(\"iflow cookie GET request failed: status=%d body=%s\", resp.StatusCode, string(body))\n\t\treturn nil, fmt.Errorf(\"iflow cookie: GET request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tvar keyResp iFlowAPIKeyResponse\n\tif err = json.Unmarshal(body, &keyResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie: decode GET response failed: %w\", err)\n\t}\n\n\tif !keyResp.Success {\n\t\treturn nil, fmt.Errorf(\"iflow cookie: GET request not successful: %s\", keyResp.Message)\n\t}\n\n\t// Handle initial response where apiKey field might be apiKeyMask\n\tif keyResp.Data.APIKey == \"\" && keyResp.Data.APIKeyMask != \"\" {\n\t\tkeyResp.Data.APIKey = keyResp.Data.APIKeyMask\n\t}\n\n\treturn &keyResp.Data, nil\n}\n\n// RefreshAPIKey refreshes the API key using POST request\nfunc (ia *IFlowAuth) RefreshAPIKey(ctx context.Context, cookie, name string) (*iFlowKeyData, error) {\n\tif strings.TrimSpace(cookie) == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: cookie is empty\")\n\t}\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: name is empty\")\n\t}\n\n\t// Prepare request body\n\trefreshReq := iFlowRefreshRequest{\n\t\tName: name,\n\t}\n\n\tbodyBytes, err := json.Marshal(refreshReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: marshal request failed: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowAPIKeyEndpoint, strings.NewReader(string(bodyBytes)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: create POST request failed: %w\", err)\n\t}\n\n\t// Set cookie and other headers to mimic browser\n\treq.Header.Set(\"Cookie\", cookie)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate, br\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Origin\", \"https://platform.iflow.cn\")\n\treq.Header.Set(\"Referer\", \"https://platform.iflow.cn/\")\n\n\tresp, err := ia.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: POST request failed: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\t// Handle gzip compression\n\tvar reader io.Reader = resp.Body\n\tif resp.Header.Get(\"Content-Encoding\") == \"gzip\" {\n\t\tgzipReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: create gzip reader failed: %w\", err)\n\t\t}\n\t\tdefer func() { _ = gzipReader.Close() }()\n\t\treader = gzipReader\n\t}\n\n\tbody, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: read POST response failed: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlog.Debugf(\"iflow cookie POST request failed: status=%d body=%s\", resp.StatusCode, string(body))\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: POST request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tvar keyResp iFlowAPIKeyResponse\n\tif err = json.Unmarshal(body, &keyResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: decode POST response failed: %w\", err)\n\t}\n\n\tif !keyResp.Success {\n\t\treturn nil, fmt.Errorf(\"iflow cookie refresh: POST request not successful: %s\", keyResp.Message)\n\t}\n\n\treturn &keyResp.Data, nil\n}\n\n// ShouldRefreshAPIKey checks if the API key needs to be refreshed (within 2 days of expiry)\nfunc ShouldRefreshAPIKey(expireTime string) (bool, time.Duration, error) {\n\tif strings.TrimSpace(expireTime) == \"\" {\n\t\treturn false, 0, fmt.Errorf(\"iflow cookie: expire time is empty\")\n\t}\n\n\texpire, err := time.Parse(\"2006-01-02 15:04\", expireTime)\n\tif err != nil {\n\t\treturn false, 0, fmt.Errorf(\"iflow cookie: parse expire time failed: %w\", err)\n\t}\n\n\tnow := time.Now()\n\ttwoDaysFromNow := now.Add(48 * time.Hour)\n\n\tneedsRefresh := expire.Before(twoDaysFromNow)\n\ttimeUntilExpiry := expire.Sub(now)\n\n\treturn needsRefresh, timeUntilExpiry, nil\n}\n\n// CreateCookieTokenStorage converts cookie-based token data into persistence storage\nfunc (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenStorage {\n\tif data == nil {\n\t\treturn nil\n\t}\n\n\t// Only save the BXAuth field from the cookie\n\tbxAuth := ExtractBXAuth(data.Cookie)\n\tcookieToSave := \"\"\n\tif bxAuth != \"\" {\n\t\tcookieToSave = \"BXAuth=\" + bxAuth + \";\"\n\t}\n\n\treturn &IFlowTokenStorage{\n\t\tAPIKey:      data.APIKey,\n\t\tEmail:       data.Email,\n\t\tExpire:      data.Expire,\n\t\tCookie:      cookieToSave,\n\t\tLastRefresh: time.Now().Format(time.RFC3339),\n\t\tType:        \"iflow\",\n\t}\n}\n\n// UpdateCookieTokenStorage updates the persisted token storage with refreshed API key data\nfunc (ia *IFlowAuth) UpdateCookieTokenStorage(storage *IFlowTokenStorage, keyData *iFlowKeyData) {\n\tif storage == nil || keyData == nil {\n\t\treturn\n\t}\n\n\tstorage.APIKey = keyData.APIKey\n\tstorage.Expire = keyData.ExpireTime\n\tstorage.LastRefresh = time.Now().Format(time.RFC3339)\n}\n"
  },
  {
    "path": "internal/auth/iflow/iflow_token.go",
    "content": "package iflow\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n)\n\n// IFlowTokenStorage persists iFlow OAuth credentials alongside the derived API key.\ntype IFlowTokenStorage struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tLastRefresh  string `json:\"last_refresh\"`\n\tExpire       string `json:\"expired\"`\n\tAPIKey       string `json:\"api_key\"`\n\tEmail        string `json:\"email\"`\n\tTokenType    string `json:\"token_type\"`\n\tScope        string `json:\"scope\"`\n\tCookie       string `json:\"cookie\"`\n\tType         string `json:\"type\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *IFlowTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// SaveTokenToFile serialises the token storage to disk.\nfunc (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"iflow\"\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil {\n\t\treturn fmt.Errorf(\"iflow token: create directory failed: %w\", err)\n\t}\n\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"iflow token: create file failed: %w\", err)\n\t}\n\tdefer func() { _ = f.Close() }()\n\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\n\tif err = json.NewEncoder(f).Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"iflow token: encode token failed: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/auth/iflow/oauth_server.go",
    "content": "package iflow\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst errorRedirectURL = \"https://iflow.cn/oauth/error\"\n\n// OAuthResult captures the outcome of the local OAuth callback.\ntype OAuthResult struct {\n\tCode  string\n\tState string\n\tError string\n}\n\n// OAuthServer provides a minimal HTTP server for handling the iFlow OAuth callback.\ntype OAuthServer struct {\n\tserver  *http.Server\n\tport    int\n\tresult  chan *OAuthResult\n\terrChan chan error\n\tmu      sync.Mutex\n\trunning bool\n}\n\n// NewOAuthServer constructs a new OAuthServer bound to the provided port.\nfunc NewOAuthServer(port int) *OAuthServer {\n\treturn &OAuthServer{\n\t\tport:    port,\n\t\tresult:  make(chan *OAuthResult, 1),\n\t\terrChan: make(chan error, 1),\n\t}\n}\n\n// Start launches the callback listener.\nfunc (s *OAuthServer) Start() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif s.running {\n\t\treturn fmt.Errorf(\"iflow oauth server already running\")\n\t}\n\tif !s.isPortAvailable() {\n\t\treturn fmt.Errorf(\"port %d is already in use\", s.port)\n\t}\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/oauth2callback\", s.handleCallback)\n\n\ts.server = &http.Server{\n\t\tAddr:         fmt.Sprintf(\":%d\", s.port),\n\t\tHandler:      mux,\n\t\tReadTimeout:  10 * time.Second,\n\t\tWriteTimeout: 10 * time.Second,\n\t}\n\n\ts.running = true\n\n\tgo func() {\n\t\tif err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\ts.errChan <- err\n\t\t}\n\t}()\n\n\ttime.Sleep(100 * time.Millisecond)\n\treturn nil\n}\n\n// Stop gracefully terminates the callback listener.\nfunc (s *OAuthServer) Stop(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif !s.running || s.server == nil {\n\t\treturn nil\n\t}\n\tdefer func() {\n\t\ts.running = false\n\t\ts.server = nil\n\t}()\n\treturn s.server.Shutdown(ctx)\n}\n\n// WaitForCallback blocks until a callback result, server error, or timeout occurs.\nfunc (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {\n\tselect {\n\tcase res := <-s.result:\n\t\treturn res, nil\n\tcase err := <-s.errChan:\n\t\treturn nil, err\n\tcase <-time.After(timeout):\n\t\treturn nil, fmt.Errorf(\"timeout waiting for OAuth callback\")\n\t}\n}\n\nfunc (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tquery := r.URL.Query()\n\tif errParam := strings.TrimSpace(query.Get(\"error\")); errParam != \"\" {\n\t\ts.sendResult(&OAuthResult{Error: errParam})\n\t\thttp.Redirect(w, r, errorRedirectURL, http.StatusFound)\n\t\treturn\n\t}\n\n\tcode := strings.TrimSpace(query.Get(\"code\"))\n\tif code == \"\" {\n\t\ts.sendResult(&OAuthResult{Error: \"missing_code\"})\n\t\thttp.Redirect(w, r, errorRedirectURL, http.StatusFound)\n\t\treturn\n\t}\n\n\tstate := query.Get(\"state\")\n\ts.sendResult(&OAuthResult{Code: code, State: state})\n\thttp.Redirect(w, r, SuccessRedirectURL, http.StatusFound)\n}\n\nfunc (s *OAuthServer) sendResult(res *OAuthResult) {\n\tselect {\n\tcase s.result <- res:\n\tdefault:\n\t\tlog.Debug(\"iflow oauth result channel full, dropping result\")\n\t}\n}\n\nfunc (s *OAuthServer) isPortAvailable() bool {\n\taddr := fmt.Sprintf(\":%d\", s.port)\n\tlistener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn false\n\t}\n\t_ = listener.Close()\n\treturn true\n}\n"
  },
  {
    "path": "internal/auth/kimi/kimi.go",
    "content": "// Package kimi provides authentication and token management for Kimi (Moonshot AI) API.\n// It handles the RFC 8628 OAuth2 Device Authorization Grant flow for secure authentication.\npackage kimi\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\t// kimiClientID is Kimi Code's OAuth client ID.\n\tkimiClientID = \"17e5f671-d194-4dfb-9706-5516cb48c098\"\n\t// kimiOAuthHost is the OAuth server endpoint.\n\tkimiOAuthHost = \"https://auth.kimi.com\"\n\t// kimiDeviceCodeURL is the endpoint for requesting device codes.\n\tkimiDeviceCodeURL = kimiOAuthHost + \"/api/oauth/device_authorization\"\n\t// kimiTokenURL is the endpoint for exchanging device codes for tokens.\n\tkimiTokenURL = kimiOAuthHost + \"/api/oauth/token\"\n\t// KimiAPIBaseURL is the base URL for Kimi API requests.\n\tKimiAPIBaseURL = \"https://api.kimi.com/coding\"\n\t// defaultPollInterval is the default interval for polling token endpoint.\n\tdefaultPollInterval = 5 * time.Second\n\t// maxPollDuration is the maximum time to wait for user authorization.\n\tmaxPollDuration = 15 * time.Minute\n\t// refreshThresholdSeconds is when to refresh token before expiry (5 minutes).\n\trefreshThresholdSeconds = 300\n)\n\n// KimiAuth handles Kimi authentication flow.\ntype KimiAuth struct {\n\tdeviceClient *DeviceFlowClient\n\tcfg          *config.Config\n}\n\n// NewKimiAuth creates a new KimiAuth service instance.\nfunc NewKimiAuth(cfg *config.Config) *KimiAuth {\n\treturn &KimiAuth{\n\t\tdeviceClient: NewDeviceFlowClient(cfg),\n\t\tcfg:          cfg,\n\t}\n}\n\n// StartDeviceFlow initiates the device flow authentication.\nfunc (k *KimiAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {\n\treturn k.deviceClient.RequestDeviceCode(ctx)\n}\n\n// WaitForAuthorization polls for user authorization and returns the auth bundle.\nfunc (k *KimiAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiAuthBundle, error) {\n\ttokenData, err := k.deviceClient.PollForToken(ctx, deviceCode)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &KimiAuthBundle{\n\t\tTokenData: tokenData,\n\t\tDeviceID:  k.deviceClient.deviceID,\n\t}, nil\n}\n\n// CreateTokenStorage creates a new KimiTokenStorage from auth bundle.\nfunc (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage {\n\texpired := \"\"\n\tif bundle.TokenData.ExpiresAt > 0 {\n\t\texpired = time.Unix(bundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)\n\t}\n\treturn &KimiTokenStorage{\n\t\tAccessToken:  bundle.TokenData.AccessToken,\n\t\tRefreshToken: bundle.TokenData.RefreshToken,\n\t\tTokenType:    bundle.TokenData.TokenType,\n\t\tScope:        bundle.TokenData.Scope,\n\t\tDeviceID:     strings.TrimSpace(bundle.DeviceID),\n\t\tExpired:      expired,\n\t\tType:         \"kimi\",\n\t}\n}\n\n// DeviceFlowClient handles the OAuth2 device flow for Kimi.\ntype DeviceFlowClient struct {\n\thttpClient *http.Client\n\tcfg        *config.Config\n\tdeviceID   string\n}\n\n// NewDeviceFlowClient creates a new device flow client.\nfunc NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {\n\treturn NewDeviceFlowClientWithDeviceID(cfg, \"\")\n}\n\n// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID.\nfunc NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient {\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\tif cfg != nil {\n\t\tclient = util.SetProxy(&cfg.SDKConfig, client)\n\t}\n\tresolvedDeviceID := strings.TrimSpace(deviceID)\n\tif resolvedDeviceID == \"\" {\n\t\tresolvedDeviceID = getOrCreateDeviceID()\n\t}\n\treturn &DeviceFlowClient{\n\t\thttpClient: client,\n\t\tcfg:        cfg,\n\t\tdeviceID:   resolvedDeviceID,\n\t}\n}\n\n// getOrCreateDeviceID returns an in-memory device ID for the current authentication flow.\nfunc getOrCreateDeviceID() string {\n\treturn uuid.New().String()\n}\n\n// getDeviceModel returns a device model string.\nfunc getDeviceModel() string {\n\tosName := runtime.GOOS\n\tarch := runtime.GOARCH\n\n\tswitch osName {\n\tcase \"darwin\":\n\t\treturn fmt.Sprintf(\"macOS %s\", arch)\n\tcase \"windows\":\n\t\treturn fmt.Sprintf(\"Windows %s\", arch)\n\tcase \"linux\":\n\t\treturn fmt.Sprintf(\"Linux %s\", arch)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%s %s\", osName, arch)\n\t}\n}\n\n// getHostname returns the machine hostname.\nfunc getHostname() string {\n\thostname, err := os.Hostname()\n\tif err != nil {\n\t\treturn \"unknown\"\n\t}\n\treturn hostname\n}\n\n// commonHeaders returns headers required for Kimi API requests.\nfunc (c *DeviceFlowClient) commonHeaders() map[string]string {\n\treturn map[string]string{\n\t\t\"X-Msh-Platform\":     \"cli-proxy-api\",\n\t\t\"X-Msh-Version\":      \"1.0.0\",\n\t\t\"X-Msh-Device-Name\":  getHostname(),\n\t\t\"X-Msh-Device-Model\": getDeviceModel(),\n\t\t\"X-Msh-Device-Id\":    c.deviceID,\n\t}\n}\n\n// RequestDeviceCode initiates the device flow by requesting a device code from Kimi.\nfunc (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {\n\tdata := url.Values{}\n\tdata.Set(\"client_id\", kimiClientID)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiDeviceCodeURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to create device code request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tfor k, v := range c.commonHeaders() {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: device code request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"kimi device code: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to read device code response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"kimi: device code request failed with status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\tvar deviceCode DeviceCodeResponse\n\tif err = json.Unmarshal(bodyBytes, &deviceCode); err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to parse device code response: %w\", err)\n\t}\n\n\treturn &deviceCode, nil\n}\n\n// PollForToken polls the token endpoint until the user authorizes or the device code expires.\nfunc (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiTokenData, error) {\n\tif deviceCode == nil {\n\t\treturn nil, fmt.Errorf(\"kimi: device code is nil\")\n\t}\n\n\tinterval := time.Duration(deviceCode.Interval) * time.Second\n\tif interval < defaultPollInterval {\n\t\tinterval = defaultPollInterval\n\t}\n\n\tdeadline := time.Now().Add(maxPollDuration)\n\tif deviceCode.ExpiresIn > 0 {\n\t\tcodeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)\n\t\tif codeDeadline.Before(deadline) {\n\t\t\tdeadline = codeDeadline\n\t\t}\n\t}\n\n\tticker := time.NewTicker(interval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, fmt.Errorf(\"kimi: context cancelled: %w\", ctx.Err())\n\t\tcase <-ticker.C:\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\treturn nil, fmt.Errorf(\"kimi: device code expired\")\n\t\t\t}\n\n\t\t\ttoken, pollErr, shouldContinue := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)\n\t\t\tif token != nil {\n\t\t\t\treturn token, nil\n\t\t\t}\n\t\t\tif !shouldContinue {\n\t\t\t\treturn nil, pollErr\n\t\t\t}\n\t\t\t// Continue polling\n\t\t}\n\t}\n}\n\n// exchangeDeviceCode attempts to exchange the device code for an access token.\n// Returns (token, error, shouldContinue).\nfunc (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*KimiTokenData, error, bool) {\n\tdata := url.Values{}\n\tdata.Set(\"client_id\", kimiClientID)\n\tdata.Set(\"device_code\", deviceCode)\n\tdata.Set(\"grant_type\", \"urn:ietf:params:oauth:grant-type:device_code\")\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to create token request: %w\", err), false\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tfor k, v := range c.commonHeaders() {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: token request failed: %w\", err), false\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"kimi token exchange: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to read token response: %w\", err), false\n\t}\n\n\t// Parse response - Kimi returns 200 for both success and pending states\n\tvar oauthResp struct {\n\t\tError            string  `json:\"error\"`\n\t\tErrorDescription string  `json:\"error_description\"`\n\t\tAccessToken      string  `json:\"access_token\"`\n\t\tRefreshToken     string  `json:\"refresh_token\"`\n\t\tTokenType        string  `json:\"token_type\"`\n\t\tExpiresIn        float64 `json:\"expires_in\"`\n\t\tScope            string  `json:\"scope\"`\n\t}\n\n\tif err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to parse token response: %w\", err), false\n\t}\n\n\tif oauthResp.Error != \"\" {\n\t\tswitch oauthResp.Error {\n\t\tcase \"authorization_pending\":\n\t\t\treturn nil, nil, true // Continue polling\n\t\tcase \"slow_down\":\n\t\t\treturn nil, nil, true // Continue polling (with increased interval handled by caller)\n\t\tcase \"expired_token\":\n\t\t\treturn nil, fmt.Errorf(\"kimi: device code expired\"), false\n\t\tcase \"access_denied\":\n\t\t\treturn nil, fmt.Errorf(\"kimi: access denied by user\"), false\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"kimi: OAuth error: %s - %s\", oauthResp.Error, oauthResp.ErrorDescription), false\n\t\t}\n\t}\n\n\tif oauthResp.AccessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"kimi: empty access token in response\"), false\n\t}\n\n\tvar expiresAt int64\n\tif oauthResp.ExpiresIn > 0 {\n\t\texpiresAt = time.Now().Unix() + int64(oauthResp.ExpiresIn)\n\t}\n\n\treturn &KimiTokenData{\n\t\tAccessToken:  oauthResp.AccessToken,\n\t\tRefreshToken: oauthResp.RefreshToken,\n\t\tTokenType:    oauthResp.TokenType,\n\t\tExpiresAt:    expiresAt,\n\t\tScope:        oauthResp.Scope,\n\t}, nil, false\n}\n\n// RefreshToken exchanges a refresh token for a new access token.\nfunc (c *DeviceFlowClient) RefreshToken(ctx context.Context, refreshToken string) (*KimiTokenData, error) {\n\tdata := url.Values{}\n\tdata.Set(\"client_id\", kimiClientID)\n\tdata.Set(\"grant_type\", \"refresh_token\")\n\tdata.Set(\"refresh_token\", refreshToken)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to create refresh request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tfor k, v := range c.commonHeaders() {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: refresh request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"kimi refresh token: close body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to read refresh response: %w\", err)\n\t}\n\n\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\treturn nil, fmt.Errorf(\"kimi: refresh token rejected (status %d)\", resp.StatusCode)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"kimi: refresh failed with status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\tvar tokenResp struct {\n\t\tAccessToken  string  `json:\"access_token\"`\n\t\tRefreshToken string  `json:\"refresh_token\"`\n\t\tTokenType    string  `json:\"token_type\"`\n\t\tExpiresIn    float64 `json:\"expires_in\"`\n\t\tScope        string  `json:\"scope\"`\n\t}\n\n\tif err = json.Unmarshal(bodyBytes, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to parse refresh response: %w\", err)\n\t}\n\n\tif tokenResp.AccessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"kimi: empty access token in refresh response\")\n\t}\n\n\tvar expiresAt int64\n\tif tokenResp.ExpiresIn > 0 {\n\t\texpiresAt = time.Now().Unix() + int64(tokenResp.ExpiresIn)\n\t}\n\n\treturn &KimiTokenData{\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tTokenType:    tokenResp.TokenType,\n\t\tExpiresAt:    expiresAt,\n\t\tScope:        tokenResp.Scope,\n\t}, nil\n}\n"
  },
  {
    "path": "internal/auth/kimi/token.go",
    "content": "// Package kimi provides authentication and token management functionality\n// for Kimi (Moonshot AI) services. It handles OAuth2 device flow token storage,\n// serialization, and retrieval for maintaining authenticated sessions with the Kimi API.\npackage kimi\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n)\n\n// KimiTokenStorage stores OAuth2 token information for Kimi API authentication.\ntype KimiTokenStorage struct {\n\t// AccessToken is the OAuth2 access token used for authenticating API requests.\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is the OAuth2 refresh token used to obtain new access tokens.\n\tRefreshToken string `json:\"refresh_token\"`\n\t// TokenType is the type of token, typically \"Bearer\".\n\tTokenType string `json:\"token_type\"`\n\t// Scope is the OAuth2 scope granted to the token.\n\tScope string `json:\"scope,omitempty\"`\n\t// DeviceID is the OAuth device flow identifier used for Kimi requests.\n\tDeviceID string `json:\"device_id,omitempty\"`\n\t// Expired is the RFC3339 timestamp when the access token expires.\n\tExpired string `json:\"expired,omitempty\"`\n\t// Type indicates the authentication provider type, always \"kimi\" for this storage.\n\tType string `json:\"type\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *KimiTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// KimiTokenData holds the raw OAuth token response from Kimi.\ntype KimiTokenData struct {\n\t// AccessToken is the OAuth2 access token.\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is the OAuth2 refresh token.\n\tRefreshToken string `json:\"refresh_token\"`\n\t// TokenType is the type of token, typically \"Bearer\".\n\tTokenType string `json:\"token_type\"`\n\t// ExpiresAt is the Unix timestamp when the token expires.\n\tExpiresAt int64 `json:\"expires_at\"`\n\t// Scope is the OAuth2 scope granted to the token.\n\tScope string `json:\"scope\"`\n}\n\n// KimiAuthBundle bundles authentication data for storage.\ntype KimiAuthBundle struct {\n\t// TokenData contains the OAuth token information.\n\tTokenData *KimiTokenData\n\t// DeviceID is the device identifier used during OAuth device flow.\n\tDeviceID string\n}\n\n// DeviceCodeResponse represents Kimi's device code response.\ntype DeviceCodeResponse struct {\n\t// DeviceCode is the device verification code.\n\tDeviceCode string `json:\"device_code\"`\n\t// UserCode is the code the user must enter at the verification URI.\n\tUserCode string `json:\"user_code\"`\n\t// VerificationURI is the URL where the user should enter the code.\n\tVerificationURI string `json:\"verification_uri,omitempty\"`\n\t// VerificationURIComplete is the URL with the code pre-filled.\n\tVerificationURIComplete string `json:\"verification_uri_complete\"`\n\t// ExpiresIn is the number of seconds until the device code expires.\n\tExpiresIn int `json:\"expires_in\"`\n\t// Interval is the minimum number of seconds to wait between polling requests.\n\tInterval int `json:\"interval\"`\n}\n\n// SaveTokenToFile serializes the Kimi token storage to a JSON file.\nfunc (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"kimi\"\n\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %v\", err)\n\t}\n\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create token file: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = f.Close()\n\t}()\n\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\n\tencoder := json.NewEncoder(f)\n\tencoder.SetIndent(\"\", \"  \")\n\tif err = encoder.Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write token to file: %w\", err)\n\t}\n\treturn nil\n}\n\n// IsExpired checks if the token has expired.\nfunc (ts *KimiTokenStorage) IsExpired() bool {\n\tif ts.Expired == \"\" {\n\t\treturn false // No expiry set, assume valid\n\t}\n\tt, err := time.Parse(time.RFC3339, ts.Expired)\n\tif err != nil {\n\t\treturn true // Has expiry string but can't parse\n\t}\n\t// Consider expired if within refresh threshold\n\treturn time.Now().Add(time.Duration(refreshThresholdSeconds) * time.Second).After(t)\n}\n\n// NeedsRefresh checks if the token should be refreshed.\nfunc (ts *KimiTokenStorage) NeedsRefresh() bool {\n\tif ts.RefreshToken == \"\" {\n\t\treturn false // Can't refresh without refresh token\n\t}\n\treturn ts.IsExpired()\n}\n"
  },
  {
    "path": "internal/auth/models.go",
    "content": "// Package auth provides authentication functionality for various AI service providers.\n// It includes interfaces and implementations for token storage and authentication methods.\npackage auth\n\n// TokenStorage defines the interface for storing authentication tokens.\n// Implementations of this interface should provide methods to persist\n// authentication tokens to a file system location.\ntype TokenStorage interface {\n\t// SaveTokenToFile persists authentication tokens to the specified file path.\n\t//\n\t// Parameters:\n\t//   - authFilePath: The file path where the authentication tokens should be saved\n\t//\n\t// Returns:\n\t//   - error: An error if the save operation fails, nil otherwise\n\tSaveTokenToFile(authFilePath string) error\n}\n"
  },
  {
    "path": "internal/auth/qwen/qwen_auth.go",
    "content": "package qwen\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\t// QwenOAuthDeviceCodeEndpoint is the URL for initiating the OAuth 2.0 device authorization flow.\n\tQwenOAuthDeviceCodeEndpoint = \"https://chat.qwen.ai/api/v1/oauth2/device/code\"\n\t// QwenOAuthTokenEndpoint is the URL for exchanging device codes or refresh tokens for access tokens.\n\tQwenOAuthTokenEndpoint = \"https://chat.qwen.ai/api/v1/oauth2/token\"\n\t// QwenOAuthClientID is the client identifier for the Qwen OAuth 2.0 application.\n\tQwenOAuthClientID = \"f0304373b74a44d2b584a3fb70ca9e56\"\n\t// QwenOAuthScope defines the permissions requested by the application.\n\tQwenOAuthScope = \"openid profile email model.completion\"\n\t// QwenOAuthGrantType specifies the grant type for the device code flow.\n\tQwenOAuthGrantType = \"urn:ietf:params:oauth:grant-type:device_code\"\n)\n\n// QwenTokenData represents the OAuth credentials, including access and refresh tokens.\ntype QwenTokenData struct {\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain a new access token when the current one expires.\n\tRefreshToken string `json:\"refresh_token,omitempty\"`\n\t// TokenType indicates the type of token, typically \"Bearer\".\n\tTokenType string `json:\"token_type\"`\n\t// ResourceURL specifies the base URL of the resource server.\n\tResourceURL string `json:\"resource_url,omitempty\"`\n\t// Expire indicates the expiration date and time of the access token.\n\tExpire string `json:\"expiry_date,omitempty\"`\n}\n\n// DeviceFlow represents the response from the device authorization endpoint.\ntype DeviceFlow struct {\n\t// DeviceCode is the code that the client uses to poll for an access token.\n\tDeviceCode string `json:\"device_code\"`\n\t// UserCode is the code that the user enters at the verification URI.\n\tUserCode string `json:\"user_code\"`\n\t// VerificationURI is the URL where the user can enter the user code to authorize the device.\n\tVerificationURI string `json:\"verification_uri\"`\n\t// VerificationURIComplete is a URI that includes the user_code, which can be used to automatically\n\t// fill in the code on the verification page.\n\tVerificationURIComplete string `json:\"verification_uri_complete\"`\n\t// ExpiresIn is the time in seconds until the device_code and user_code expire.\n\tExpiresIn int `json:\"expires_in\"`\n\t// Interval is the minimum time in seconds that the client should wait between polling requests.\n\tInterval int `json:\"interval\"`\n\t// CodeVerifier is the cryptographically random string used in the PKCE flow.\n\tCodeVerifier string `json:\"code_verifier\"`\n}\n\n// QwenTokenResponse represents the successful token response from the token endpoint.\ntype QwenTokenResponse struct {\n\t// AccessToken is the token used to access protected resources.\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain a new access token.\n\tRefreshToken string `json:\"refresh_token,omitempty\"`\n\t// TokenType indicates the type of token, typically \"Bearer\".\n\tTokenType string `json:\"token_type\"`\n\t// ResourceURL specifies the base URL of the resource server.\n\tResourceURL string `json:\"resource_url,omitempty\"`\n\t// ExpiresIn is the time in seconds until the access token expires.\n\tExpiresIn int `json:\"expires_in\"`\n}\n\n// QwenAuth manages authentication and token handling for the Qwen API.\ntype QwenAuth struct {\n\thttpClient *http.Client\n}\n\n// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.\nfunc NewQwenAuth(cfg *config.Config) *QwenAuth {\n\treturn &QwenAuth{\n\t\thttpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),\n\t}\n}\n\n// generateCodeVerifier generates a cryptographically random string for the PKCE code verifier.\nfunc (qa *QwenAuth) generateCodeVerifier() (string, error) {\n\tbytes := make([]byte, 32)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(bytes), nil\n}\n\n// generateCodeChallenge creates a SHA-256 hash of the code verifier, used as the PKCE code challenge.\nfunc (qa *QwenAuth) generateCodeChallenge(codeVerifier string) string {\n\thash := sha256.Sum256([]byte(codeVerifier))\n\treturn base64.RawURLEncoding.EncodeToString(hash[:])\n}\n\n// generatePKCEPair creates a new code verifier and its corresponding code challenge for PKCE.\nfunc (qa *QwenAuth) generatePKCEPair() (string, string, error) {\n\tcodeVerifier, err := qa.generateCodeVerifier()\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tcodeChallenge := qa.generateCodeChallenge(codeVerifier)\n\treturn codeVerifier, codeChallenge, nil\n}\n\n// RefreshTokens exchanges a refresh token for a new access token.\nfunc (qa *QwenAuth) RefreshTokens(ctx context.Context, refreshToken string) (*QwenTokenData, error) {\n\tdata := url.Values{}\n\tdata.Set(\"grant_type\", \"refresh_token\")\n\tdata.Set(\"refresh_token\", refreshToken)\n\tdata.Set(\"client_id\", QwenOAuthClientID)\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", QwenOAuthTokenEndpoint, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create token request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := qa.httpClient.Do(req)\n\n\t// resp, err := qa.httpClient.PostForm(QwenOAuthTokenEndpoint, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"token refresh request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tvar errorData map[string]interface{}\n\t\tif err = json.Unmarshal(body, &errorData); err == nil {\n\t\t\treturn nil, fmt.Errorf(\"token refresh failed: %v - %v\", errorData[\"error\"], errorData[\"error_description\"])\n\t\t}\n\t\treturn nil, fmt.Errorf(\"token refresh failed: %s\", string(body))\n\t}\n\n\tvar tokenData QwenTokenResponse\n\tif err = json.Unmarshal(body, &tokenData); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse token response: %w\", err)\n\t}\n\n\treturn &QwenTokenData{\n\t\tAccessToken:  tokenData.AccessToken,\n\t\tTokenType:    tokenData.TokenType,\n\t\tRefreshToken: tokenData.RefreshToken,\n\t\tResourceURL:  tokenData.ResourceURL,\n\t\tExpire:       time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}, nil\n}\n\n// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow and returns the device flow details.\nfunc (qa *QwenAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlow, error) {\n\t// Generate PKCE code verifier and challenge\n\tcodeVerifier, codeChallenge, err := qa.generatePKCEPair()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate PKCE pair: %w\", err)\n\t}\n\n\tdata := url.Values{}\n\tdata.Set(\"client_id\", QwenOAuthClientID)\n\tdata.Set(\"scope\", QwenOAuthScope)\n\tdata.Set(\"code_challenge\", codeChallenge)\n\tdata.Set(\"code_challenge_method\", \"S256\")\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", QwenOAuthDeviceCodeEndpoint, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create token request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := qa.httpClient.Do(req)\n\n\t// resp, err := qa.httpClient.PostForm(QwenOAuthDeviceCodeEndpoint, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"device authorization request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"device authorization failed: %d %s. Response: %s\", resp.StatusCode, resp.Status, string(body))\n\t}\n\n\tvar result DeviceFlow\n\tif err = json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse device flow response: %w\", err)\n\t}\n\n\t// Check if the response indicates success\n\tif result.DeviceCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"device authorization failed: device_code not found in response\")\n\t}\n\n\t// Add the code_verifier to the result so it can be used later for polling\n\tresult.CodeVerifier = codeVerifier\n\n\treturn &result, nil\n}\n\n// PollForToken polls the token endpoint with the device code to obtain an access token.\nfunc (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenData, error) {\n\tpollInterval := 5 * time.Second\n\tmaxAttempts := 60 // 5 minutes max\n\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tdata := url.Values{}\n\t\tdata.Set(\"grant_type\", QwenOAuthGrantType)\n\t\tdata.Set(\"client_id\", QwenOAuthClientID)\n\t\tdata.Set(\"device_code\", deviceCode)\n\t\tdata.Set(\"code_verifier\", codeVerifier)\n\n\t\tresp, err := http.PostForm(QwenOAuthTokenEndpoint, data)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Polling attempt %d/%d failed: %v\\n\", attempt+1, maxAttempts, err)\n\t\t\ttime.Sleep(pollInterval)\n\t\t\tcontinue\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\t_ = resp.Body.Close()\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Polling attempt %d/%d failed: %v\\n\", attempt+1, maxAttempts, err)\n\t\t\ttime.Sleep(pollInterval)\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t// Parse the response as JSON to check for OAuth RFC 8628 standard errors\n\t\t\tvar errorData map[string]interface{}\n\t\t\tif err = json.Unmarshal(body, &errorData); err == nil {\n\t\t\t\t// According to OAuth RFC 8628, handle standard polling responses\n\t\t\t\tif resp.StatusCode == http.StatusBadRequest {\n\t\t\t\t\terrorType, _ := errorData[\"error\"].(string)\n\t\t\t\t\tswitch errorType {\n\t\t\t\t\tcase \"authorization_pending\":\n\t\t\t\t\t\t// User has not yet approved the authorization request. Continue polling.\n\t\t\t\t\t\tfmt.Printf(\"Polling attempt %d/%d...\\n\\n\", attempt+1, maxAttempts)\n\t\t\t\t\t\ttime.Sleep(pollInterval)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\tcase \"slow_down\":\n\t\t\t\t\t\t// Client is polling too frequently. Increase poll interval.\n\t\t\t\t\t\tpollInterval = time.Duration(float64(pollInterval) * 1.5)\n\t\t\t\t\t\tif pollInterval > 10*time.Second {\n\t\t\t\t\t\t\tpollInterval = 10 * time.Second\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfmt.Printf(\"Server requested to slow down, increasing poll interval to %v\\n\\n\", pollInterval)\n\t\t\t\t\t\ttime.Sleep(pollInterval)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\tcase \"expired_token\":\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"device code expired. Please restart the authentication process\")\n\t\t\t\t\tcase \"access_denied\":\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"authorization denied by user. Please restart the authentication process\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// For other errors, return with proper error information\n\t\t\t\terrorType, _ := errorData[\"error\"].(string)\n\t\t\t\terrorDesc, _ := errorData[\"error_description\"].(string)\n\t\t\t\treturn nil, fmt.Errorf(\"device token poll failed: %s - %s\", errorType, errorDesc)\n\t\t\t}\n\n\t\t\t// If JSON parsing fails, fall back to text response\n\t\t\treturn nil, fmt.Errorf(\"device token poll failed: %d %s. Response: %s\", resp.StatusCode, resp.Status, string(body))\n\t\t}\n\t\t// log.Debugf(\"%s\", string(body))\n\t\t// Success - parse token data\n\t\tvar response QwenTokenResponse\n\t\tif err = json.Unmarshal(body, &response); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to parse token response: %w\", err)\n\t\t}\n\n\t\t// Convert to QwenTokenData format and save\n\t\ttokenData := &QwenTokenData{\n\t\t\tAccessToken:  response.AccessToken,\n\t\t\tRefreshToken: response.RefreshToken,\n\t\t\tTokenType:    response.TokenType,\n\t\t\tResourceURL:  response.ResourceURL,\n\t\t\tExpire:       time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t\t}\n\n\t\treturn tokenData, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"authentication timeout. Please restart the authentication process\")\n}\n\n// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure.\nfunc (o *QwenAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*QwenTokenData, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// Wait before retry\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(time.Duration(attempt) * time.Second):\n\t\t\t}\n\t\t}\n\n\t\ttokenData, err := o.RefreshTokens(ctx, refreshToken)\n\t\tif err == nil {\n\t\t\treturn tokenData, nil\n\t\t}\n\n\t\tlastErr = err\n\t\tlog.Warnf(\"Token refresh attempt %d failed: %v\", attempt+1, err)\n\t}\n\n\treturn nil, fmt.Errorf(\"token refresh failed after %d attempts: %w\", maxRetries, lastErr)\n}\n\n// CreateTokenStorage creates a QwenTokenStorage object from a QwenTokenData object.\nfunc (o *QwenAuth) CreateTokenStorage(tokenData *QwenTokenData) *QwenTokenStorage {\n\tstorage := &QwenTokenStorage{\n\t\tAccessToken:  tokenData.AccessToken,\n\t\tRefreshToken: tokenData.RefreshToken,\n\t\tLastRefresh:  time.Now().Format(time.RFC3339),\n\t\tResourceURL:  tokenData.ResourceURL,\n\t\tExpire:       tokenData.Expire,\n\t}\n\n\treturn storage\n}\n\n// UpdateTokenStorage updates an existing token storage with new token data\nfunc (o *QwenAuth) UpdateTokenStorage(storage *QwenTokenStorage, tokenData *QwenTokenData) {\n\tstorage.AccessToken = tokenData.AccessToken\n\tstorage.RefreshToken = tokenData.RefreshToken\n\tstorage.LastRefresh = time.Now().Format(time.RFC3339)\n\tstorage.ResourceURL = tokenData.ResourceURL\n\tstorage.Expire = tokenData.Expire\n}\n"
  },
  {
    "path": "internal/auth/qwen/qwen_token.go",
    "content": "// Package qwen provides authentication and token management functionality\n// for Alibaba's Qwen AI services. It handles OAuth2 token storage, serialization,\n// and retrieval for maintaining authenticated sessions with the Qwen API.\npackage qwen\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n)\n\n// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.\n// It maintains compatibility with the existing auth system while adding Qwen-specific fields\n// for managing access tokens, refresh tokens, and user account information.\ntype QwenTokenStorage struct {\n\t// AccessToken is the OAuth2 access token used for authenticating API requests.\n\tAccessToken string `json:\"access_token\"`\n\t// RefreshToken is used to obtain new access tokens when the current one expires.\n\tRefreshToken string `json:\"refresh_token\"`\n\t// LastRefresh is the timestamp of the last token refresh operation.\n\tLastRefresh string `json:\"last_refresh\"`\n\t// ResourceURL is the base URL for API requests.\n\tResourceURL string `json:\"resource_url\"`\n\t// Email is the Qwen account email address associated with this token.\n\tEmail string `json:\"email\"`\n\t// Type indicates the authentication provider type, always \"qwen\" for this storage.\n\tType string `json:\"type\"`\n\t// Expire is the timestamp when the current access token expires.\n\tExpire string `json:\"expired\"`\n\n\t// Metadata holds arbitrary key-value pairs injected via hooks.\n\t// It is not exported to JSON directly to allow flattening during serialization.\n\tMetadata map[string]any `json:\"-\"`\n}\n\n// SetMetadata allows external callers to inject metadata into the storage before saving.\nfunc (ts *QwenTokenStorage) SetMetadata(meta map[string]any) {\n\tts.Metadata = meta\n}\n\n// SaveTokenToFile serializes the Qwen token storage to a JSON file.\n// This method creates the necessary directory structure and writes the token\n// data in JSON format to the specified file path for persistent storage.\n// It merges any injected metadata into the top-level JSON object.\n//\n// Parameters:\n//   - authFilePath: The full path where the token file should be saved\n//\n// Returns:\n//   - error: An error if the operation fails, nil otherwise\nfunc (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tts.Type = \"qwen\"\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %v\", err)\n\t}\n\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create token file: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = f.Close()\n\t}()\n\n\t// Merge metadata using helper\n\tdata, errMerge := misc.MergeMetadata(ts, ts.Metadata)\n\tif errMerge != nil {\n\t\treturn fmt.Errorf(\"failed to merge metadata: %w\", errMerge)\n\t}\n\n\tif err = json.NewEncoder(f).Encode(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write token to file: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/auth/vertex/keyutil.go",
    "content": "package vertex\n\nimport (\n\t\"crypto/rsa\"\n\t\"crypto/x509\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"encoding/pem\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// NormalizeServiceAccountJSON normalizes the given JSON-encoded service account payload.\n// It returns the normalized JSON (with sanitized private_key) or, if normalization fails,\n// the original bytes and the encountered error.\nfunc NormalizeServiceAccountJSON(raw []byte) ([]byte, error) {\n\tif len(raw) == 0 {\n\t\treturn raw, nil\n\t}\n\tvar payload map[string]any\n\tif err := json.Unmarshal(raw, &payload); err != nil {\n\t\treturn raw, err\n\t}\n\tnormalized, err := NormalizeServiceAccountMap(payload)\n\tif err != nil {\n\t\treturn raw, err\n\t}\n\tout, err := json.Marshal(normalized)\n\tif err != nil {\n\t\treturn raw, err\n\t}\n\treturn out, nil\n}\n\n// NormalizeServiceAccountMap returns a copy of the given service account map with\n// a sanitized private_key field that is guaranteed to contain a valid RSA PRIVATE KEY PEM block.\nfunc NormalizeServiceAccountMap(sa map[string]any) (map[string]any, error) {\n\tif sa == nil {\n\t\treturn nil, fmt.Errorf(\"service account payload is empty\")\n\t}\n\tpk, _ := sa[\"private_key\"].(string)\n\tif strings.TrimSpace(pk) == \"\" {\n\t\treturn nil, fmt.Errorf(\"service account missing private_key\")\n\t}\n\tnormalized, err := sanitizePrivateKey(pk)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclone := make(map[string]any, len(sa))\n\tfor k, v := range sa {\n\t\tclone[k] = v\n\t}\n\tclone[\"private_key\"] = normalized\n\treturn clone, nil\n}\n\nfunc sanitizePrivateKey(raw string) (string, error) {\n\tpk := strings.ReplaceAll(raw, \"\\r\\n\", \"\\n\")\n\tpk = strings.ReplaceAll(pk, \"\\r\", \"\\n\")\n\tpk = stripANSIEscape(pk)\n\tpk = strings.ToValidUTF8(pk, \"\")\n\tpk = strings.TrimSpace(pk)\n\n\tnormalized := pk\n\tif block, _ := pem.Decode([]byte(pk)); block == nil {\n\t\t// Attempt to reconstruct from the textual payload.\n\t\tif reconstructed, err := rebuildPEM(pk); err == nil {\n\t\t\tnormalized = reconstructed\n\t\t} else {\n\t\t\treturn \"\", fmt.Errorf(\"private_key is not valid pem: %w\", err)\n\t\t}\n\t}\n\n\tblock, _ := pem.Decode([]byte(normalized))\n\tif block == nil {\n\t\treturn \"\", fmt.Errorf(\"private_key pem decode failed\")\n\t}\n\n\trsaBlock, err := ensureRSAPrivateKey(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(pem.EncodeToMemory(rsaBlock)), nil\n}\n\nfunc ensureRSAPrivateKey(block *pem.Block) (*pem.Block, error) {\n\tif block == nil {\n\t\treturn nil, fmt.Errorf(\"pem block is nil\")\n\t}\n\n\tif block.Type == \"RSA PRIVATE KEY\" {\n\t\tif _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"private_key invalid rsa: %w\", err)\n\t\t}\n\t\treturn block, nil\n\t}\n\n\tif block.Type == \"PRIVATE KEY\" {\n\t\tkey, err := x509.ParsePKCS8PrivateKey(block.Bytes)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"private_key invalid pkcs8: %w\", err)\n\t\t}\n\t\trsaKey, ok := key.(*rsa.PrivateKey)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"private_key is not an RSA key\")\n\t\t}\n\t\tder := x509.MarshalPKCS1PrivateKey(rsaKey)\n\t\treturn &pem.Block{Type: \"RSA PRIVATE KEY\", Bytes: der}, nil\n\t}\n\n\t// Attempt auto-detection: try PKCS#1 first, then PKCS#8.\n\tif rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {\n\t\tder := x509.MarshalPKCS1PrivateKey(rsaKey)\n\t\treturn &pem.Block{Type: \"RSA PRIVATE KEY\", Bytes: der}, nil\n\t}\n\tif key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {\n\t\tif rsaKey, ok := key.(*rsa.PrivateKey); ok {\n\t\t\tder := x509.MarshalPKCS1PrivateKey(rsaKey)\n\t\t\treturn &pem.Block{Type: \"RSA PRIVATE KEY\", Bytes: der}, nil\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"private_key uses unsupported format\")\n}\n\nfunc rebuildPEM(raw string) (string, error) {\n\tkind := \"PRIVATE KEY\"\n\tif strings.Contains(raw, \"RSA PRIVATE KEY\") {\n\t\tkind = \"RSA PRIVATE KEY\"\n\t}\n\theader := \"-----BEGIN \" + kind + \"-----\"\n\tfooter := \"-----END \" + kind + \"-----\"\n\tstart := strings.Index(raw, header)\n\tend := strings.Index(raw, footer)\n\tif start < 0 || end <= start {\n\t\treturn \"\", fmt.Errorf(\"missing pem markers\")\n\t}\n\tbody := raw[start+len(header) : end]\n\tpayload := filterBase64(body)\n\tif payload == \"\" {\n\t\treturn \"\", fmt.Errorf(\"private_key base64 payload empty\")\n\t}\n\tder, err := base64.StdEncoding.DecodeString(payload)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"private_key base64 decode failed: %w\", err)\n\t}\n\tblock := &pem.Block{Type: kind, Bytes: der}\n\treturn string(pem.EncodeToMemory(block)), nil\n}\n\nfunc filterBase64(s string) string {\n\tvar b strings.Builder\n\tfor _, r := range s {\n\t\tswitch {\n\t\tcase r >= 'A' && r <= 'Z':\n\t\t\tb.WriteRune(r)\n\t\tcase r >= 'a' && r <= 'z':\n\t\t\tb.WriteRune(r)\n\t\tcase r >= '0' && r <= '9':\n\t\t\tb.WriteRune(r)\n\t\tcase r == '+' || r == '/' || r == '=':\n\t\t\tb.WriteRune(r)\n\t\tdefault:\n\t\t\t// skip\n\t\t}\n\t}\n\treturn b.String()\n}\n\nfunc stripANSIEscape(s string) string {\n\tin := []rune(s)\n\tvar out []rune\n\tfor i := 0; i < len(in); i++ {\n\t\tr := in[i]\n\t\tif r != 0x1b {\n\t\t\tout = append(out, r)\n\t\t\tcontinue\n\t\t}\n\t\tif i+1 >= len(in) {\n\t\t\tcontinue\n\t\t}\n\t\tnext := in[i+1]\n\t\tswitch next {\n\t\tcase ']':\n\t\t\ti += 2\n\t\t\tfor i < len(in) {\n\t\t\t\tif in[i] == 0x07 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif in[i] == 0x1b && i+1 < len(in) && in[i+1] == '\\\\' {\n\t\t\t\t\ti++\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ti++\n\t\t\t}\n\t\tcase '[':\n\t\t\ti += 2\n\t\t\tfor i < len(in) {\n\t\t\t\tif (in[i] >= 'A' && in[i] <= 'Z') || (in[i] >= 'a' && in[i] <= 'z') {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ti++\n\t\t\t}\n\t\tdefault:\n\t\t\t// skip single ESC\n\t\t}\n\t}\n\treturn string(out)\n}\n"
  },
  {
    "path": "internal/auth/vertex/vertex_credentials.go",
    "content": "// Package vertex provides token storage for Google Vertex AI Gemini via service account credentials.\n// It serialises service account JSON into an auth file that is consumed by the runtime executor.\npackage vertex\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// VertexCredentialStorage stores the service account JSON for Vertex AI access.\n// The content is persisted verbatim under the \"service_account\" key, together with\n// helper fields for project, location and email to improve logging and discovery.\ntype VertexCredentialStorage struct {\n\t// ServiceAccount holds the parsed service account JSON content.\n\tServiceAccount map[string]any `json:\"service_account\"`\n\n\t// ProjectID is derived from the service account JSON (project_id).\n\tProjectID string `json:\"project_id\"`\n\n\t// Email is the client_email from the service account JSON.\n\tEmail string `json:\"email\"`\n\n\t// Location optionally sets a default region (e.g., us-central1) for Vertex endpoints.\n\tLocation string `json:\"location,omitempty\"`\n\n\t// Type is the provider identifier stored alongside credentials. Always \"vertex\".\n\tType string `json:\"type\"`\n}\n\n// SaveTokenToFile writes the credential payload to the given file path in JSON format.\n// It ensures the parent directory exists and logs the operation for transparency.\nfunc (s *VertexCredentialStorage) SaveTokenToFile(authFilePath string) error {\n\tmisc.LogSavingCredentials(authFilePath)\n\tif s == nil {\n\t\treturn fmt.Errorf(\"vertex credential: storage is nil\")\n\t}\n\tif s.ServiceAccount == nil {\n\t\treturn fmt.Errorf(\"vertex credential: service account content is empty\")\n\t}\n\t// Ensure we tag the file with the provider type.\n\ts.Type = \"vertex\"\n\n\tif err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil {\n\t\treturn fmt.Errorf(\"vertex credential: create directory failed: %w\", err)\n\t}\n\tf, err := os.Create(authFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"vertex credential: create file failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := f.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex credential: failed to close file: %v\", errClose)\n\t\t}\n\t}()\n\tenc := json.NewEncoder(f)\n\tenc.SetIndent(\"\", \"  \")\n\tif err = enc.Encode(s); err != nil {\n\t\treturn fmt.Errorf(\"vertex credential: encode failed: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/browser/browser.go",
    "content": "// Package browser provides cross-platform functionality for opening URLs in the default web browser.\n// It abstracts the underlying operating system commands and provides a simple interface.\npackage browser\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"runtime\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/skratchdot/open-golang/open\"\n)\n\n// OpenURL opens the specified URL in the default web browser.\n// It first attempts to use a platform-agnostic library and falls back to\n// platform-specific commands if that fails.\n//\n// Parameters:\n//   - url: The URL to open.\n//\n// Returns:\n//   - An error if the URL cannot be opened, otherwise nil.\nfunc OpenURL(url string) error {\n\tfmt.Printf(\"Attempting to open URL in browser: %s\\n\", url)\n\n\t// Try using the open-golang library first\n\terr := open.Run(url)\n\tif err == nil {\n\t\tlog.Debug(\"Successfully opened URL using open-golang library\")\n\t\treturn nil\n\t}\n\n\tlog.Debugf(\"open-golang failed: %v, trying platform-specific commands\", err)\n\n\t// Fallback to platform-specific commands\n\treturn openURLPlatformSpecific(url)\n}\n\n// openURLPlatformSpecific is a helper function that opens a URL using OS-specific commands.\n// This serves as a fallback mechanism for OpenURL.\n//\n// Parameters:\n//   - url: The URL to open.\n//\n// Returns:\n//   - An error if the URL cannot be opened, otherwise nil.\nfunc openURLPlatformSpecific(url string) error {\n\tvar cmd *exec.Cmd\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\": // macOS\n\t\tcmd = exec.Command(\"open\", url)\n\tcase \"windows\":\n\t\tcmd = exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url)\n\tcase \"linux\":\n\t\t// Try common Linux browsers in order of preference\n\t\tbrowsers := []string{\"xdg-open\", \"x-www-browser\", \"www-browser\", \"firefox\", \"chromium\", \"google-chrome\"}\n\t\tfor _, browser := range browsers {\n\t\t\tif _, err := exec.LookPath(browser); err == nil {\n\t\t\t\tcmd = exec.Command(browser, url)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif cmd == nil {\n\t\t\treturn fmt.Errorf(\"no suitable browser found on Linux system\")\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported operating system: %s\", runtime.GOOS)\n\t}\n\n\tlog.Debugf(\"Running command: %s %v\", cmd.Path, cmd.Args[1:])\n\terr := cmd.Start()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to start browser command: %w\", err)\n\t}\n\n\tlog.Debug(\"Successfully opened URL using platform-specific command\")\n\treturn nil\n}\n\n// IsAvailable checks if the system has a command available to open a web browser.\n// It verifies the presence of necessary commands for the current operating system.\n//\n// Returns:\n//   - true if a browser can be opened, false otherwise.\nfunc IsAvailable() bool {\n\t// First check if open-golang can work\n\ttestErr := open.Run(\"about:blank\")\n\tif testErr == nil {\n\t\treturn true\n\t}\n\n\t// Check platform-specific commands\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\t_, err := exec.LookPath(\"open\")\n\t\treturn err == nil\n\tcase \"windows\":\n\t\t_, err := exec.LookPath(\"rundll32\")\n\t\treturn err == nil\n\tcase \"linux\":\n\t\tbrowsers := []string{\"xdg-open\", \"x-www-browser\", \"www-browser\", \"firefox\", \"chromium\", \"google-chrome\"}\n\t\tfor _, browser := range browsers {\n\t\t\tif _, err := exec.LookPath(browser); err == nil {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// GetPlatformInfo returns a map containing details about the current platform's\n// browser opening capabilities, including the OS, architecture, and available commands.\n//\n// Returns:\n//   - A map with platform-specific browser support information.\nfunc GetPlatformInfo() map[string]interface{} {\n\tinfo := map[string]interface{}{\n\t\t\"os\":        runtime.GOOS,\n\t\t\"arch\":      runtime.GOARCH,\n\t\t\"available\": IsAvailable(),\n\t}\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\tinfo[\"default_command\"] = \"open\"\n\tcase \"windows\":\n\t\tinfo[\"default_command\"] = \"rundll32\"\n\tcase \"linux\":\n\t\tbrowsers := []string{\"xdg-open\", \"x-www-browser\", \"www-browser\", \"firefox\", \"chromium\", \"google-chrome\"}\n\t\tvar availableBrowsers []string\n\t\tfor _, browser := range browsers {\n\t\t\tif _, err := exec.LookPath(browser); err == nil {\n\t\t\t\tavailableBrowsers = append(availableBrowsers, browser)\n\t\t\t}\n\t\t}\n\t\tinfo[\"available_browsers\"] = availableBrowsers\n\t\tif len(availableBrowsers) > 0 {\n\t\t\tinfo[\"default_command\"] = availableBrowsers[0]\n\t\t}\n\t}\n\n\treturn info\n}\n"
  },
  {
    "path": "internal/buildinfo/buildinfo.go",
    "content": "// Package buildinfo exposes compile-time metadata shared across the server.\npackage buildinfo\n\n// The following variables are overridden via ldflags during release builds.\n// Defaults cover local development builds.\nvar (\n\t// Version is the semantic version or git describe output of the binary.\n\tVersion = \"dev\"\n\n\t// Commit is the git commit SHA baked into the binary.\n\tCommit = \"none\"\n\n\t// BuildDate records when the binary was built in UTC.\n\tBuildDate = \"unknown\"\n)\n"
  },
  {
    "path": "internal/cache/signature_cache.go",
    "content": "package cache\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// SignatureEntry holds a cached thinking signature with timestamp\ntype SignatureEntry struct {\n\tSignature string\n\tTimestamp time.Time\n}\n\nconst (\n\t// SignatureCacheTTL is how long signatures are valid\n\tSignatureCacheTTL = 3 * time.Hour\n\n\t// SignatureTextHashLen is the length of the hash key (16 hex chars = 64-bit key space)\n\tSignatureTextHashLen = 16\n\n\t// MinValidSignatureLen is the minimum length for a signature to be considered valid\n\tMinValidSignatureLen = 50\n\n\t// CacheCleanupInterval controls how often stale entries are purged\n\tCacheCleanupInterval = 10 * time.Minute\n)\n\n// signatureCache stores signatures by model group -> textHash -> SignatureEntry\nvar signatureCache sync.Map\n\n// cacheCleanupOnce ensures the background cleanup goroutine starts only once\nvar cacheCleanupOnce sync.Once\n\n// groupCache is the inner map type\ntype groupCache struct {\n\tmu      sync.RWMutex\n\tentries map[string]SignatureEntry\n}\n\n// hashText creates a stable, Unicode-safe key from text content\nfunc hashText(text string) string {\n\th := sha256.Sum256([]byte(text))\n\treturn hex.EncodeToString(h[:])[:SignatureTextHashLen]\n}\n\n// getOrCreateGroupCache gets or creates a cache bucket for a model group\nfunc getOrCreateGroupCache(groupKey string) *groupCache {\n\t// Start background cleanup on first access\n\tcacheCleanupOnce.Do(startCacheCleanup)\n\n\tif val, ok := signatureCache.Load(groupKey); ok {\n\t\treturn val.(*groupCache)\n\t}\n\tsc := &groupCache{entries: make(map[string]SignatureEntry)}\n\tactual, _ := signatureCache.LoadOrStore(groupKey, sc)\n\treturn actual.(*groupCache)\n}\n\n// startCacheCleanup launches a background goroutine that periodically\n// removes caches where all entries have expired.\nfunc startCacheCleanup() {\n\tgo func() {\n\t\tticker := time.NewTicker(CacheCleanupInterval)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tpurgeExpiredCaches()\n\t\t}\n\t}()\n}\n\n// purgeExpiredCaches removes caches with no valid (non-expired) entries.\nfunc purgeExpiredCaches() {\n\tnow := time.Now()\n\tsignatureCache.Range(func(key, value any) bool {\n\t\tsc := value.(*groupCache)\n\t\tsc.mu.Lock()\n\t\t// Remove expired entries\n\t\tfor k, entry := range sc.entries {\n\t\t\tif now.Sub(entry.Timestamp) > SignatureCacheTTL {\n\t\t\t\tdelete(sc.entries, k)\n\t\t\t}\n\t\t}\n\t\tisEmpty := len(sc.entries) == 0\n\t\tsc.mu.Unlock()\n\t\t// Remove cache bucket if empty\n\t\tif isEmpty {\n\t\t\tsignatureCache.Delete(key)\n\t\t}\n\t\treturn true\n\t})\n}\n\n// CacheSignature stores a thinking signature for a given model group and text.\n// Used for Claude models that require signed thinking blocks in multi-turn conversations.\nfunc CacheSignature(modelName, text, signature string) {\n\tif text == \"\" || signature == \"\" {\n\t\treturn\n\t}\n\tif len(signature) < MinValidSignatureLen {\n\t\treturn\n\t}\n\n\tgroupKey := GetModelGroup(modelName)\n\ttextHash := hashText(text)\n\tsc := getOrCreateGroupCache(groupKey)\n\tsc.mu.Lock()\n\tdefer sc.mu.Unlock()\n\n\tsc.entries[textHash] = SignatureEntry{\n\t\tSignature: signature,\n\t\tTimestamp: time.Now(),\n\t}\n}\n\n// GetCachedSignature retrieves a cached signature for a given model group and text.\n// Returns empty string if not found or expired.\nfunc GetCachedSignature(modelName, text string) string {\n\tgroupKey := GetModelGroup(modelName)\n\n\tif text == \"\" {\n\t\tif groupKey == \"gemini\" {\n\t\t\treturn \"skip_thought_signature_validator\"\n\t\t}\n\t\treturn \"\"\n\t}\n\tval, ok := signatureCache.Load(groupKey)\n\tif !ok {\n\t\tif groupKey == \"gemini\" {\n\t\t\treturn \"skip_thought_signature_validator\"\n\t\t}\n\t\treturn \"\"\n\t}\n\tsc := val.(*groupCache)\n\n\ttextHash := hashText(text)\n\n\tnow := time.Now()\n\n\tsc.mu.Lock()\n\tentry, exists := sc.entries[textHash]\n\tif !exists {\n\t\tsc.mu.Unlock()\n\t\tif groupKey == \"gemini\" {\n\t\t\treturn \"skip_thought_signature_validator\"\n\t\t}\n\t\treturn \"\"\n\t}\n\tif now.Sub(entry.Timestamp) > SignatureCacheTTL {\n\t\tdelete(sc.entries, textHash)\n\t\tsc.mu.Unlock()\n\t\tif groupKey == \"gemini\" {\n\t\t\treturn \"skip_thought_signature_validator\"\n\t\t}\n\t\treturn \"\"\n\t}\n\n\t// Refresh TTL on access (sliding expiration).\n\tentry.Timestamp = now\n\tsc.entries[textHash] = entry\n\tsc.mu.Unlock()\n\n\treturn entry.Signature\n}\n\n// ClearSignatureCache clears signature cache for a specific model group or all groups.\nfunc ClearSignatureCache(modelName string) {\n\tif modelName == \"\" {\n\t\tsignatureCache.Range(func(key, _ any) bool {\n\t\t\tsignatureCache.Delete(key)\n\t\t\treturn true\n\t\t})\n\t\treturn\n\t}\n\tgroupKey := GetModelGroup(modelName)\n\tsignatureCache.Delete(groupKey)\n}\n\n// HasValidSignature checks if a signature is valid (non-empty and long enough)\nfunc HasValidSignature(modelName, signature string) bool {\n\treturn (signature != \"\" && len(signature) >= MinValidSignatureLen) || (signature == \"skip_thought_signature_validator\" && GetModelGroup(modelName) == \"gemini\")\n}\n\nfunc GetModelGroup(modelName string) string {\n\tif strings.Contains(modelName, \"gpt\") {\n\t\treturn \"gpt\"\n\t} else if strings.Contains(modelName, \"claude\") {\n\t\treturn \"claude\"\n\t} else if strings.Contains(modelName, \"gemini\") {\n\t\treturn \"gemini\"\n\t}\n\treturn modelName\n}\n"
  },
  {
    "path": "internal/cache/signature_cache_test.go",
    "content": "package cache\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nconst testModelName = \"claude-sonnet-4-5\"\n\nfunc TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\ttext := \"This is some thinking text content\"\n\tsignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\n\t// Store signature\n\tCacheSignature(testModelName, text, signature)\n\n\t// Retrieve signature\n\tretrieved := GetCachedSignature(testModelName, text)\n\tif retrieved != signature {\n\t\tt.Errorf(\"Expected signature '%s', got '%s'\", signature, retrieved)\n\t}\n}\n\nfunc TestCacheSignature_DifferentModelGroups(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\ttext := \"Same text across models\"\n\tsig1 := \"signature1_1234567890123456789012345678901234567890123456\"\n\tsig2 := \"signature2_1234567890123456789012345678901234567890123456\"\n\n\tgeminiModel := \"gemini-3-pro-preview\"\n\tCacheSignature(testModelName, text, sig1)\n\tCacheSignature(geminiModel, text, sig2)\n\n\tif GetCachedSignature(testModelName, text) != sig1 {\n\t\tt.Error(\"Claude signature mismatch\")\n\t}\n\tif GetCachedSignature(geminiModel, text) != sig2 {\n\t\tt.Error(\"Gemini signature mismatch\")\n\t}\n}\n\nfunc TestCacheSignature_NotFound(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\t// Non-existent session\n\tif got := GetCachedSignature(testModelName, \"some text\"); got != \"\" {\n\t\tt.Errorf(\"Expected empty string for nonexistent session, got '%s'\", got)\n\t}\n\n\t// Existing session but different text\n\tCacheSignature(testModelName, \"text-a\", \"sigA12345678901234567890123456789012345678901234567890\")\n\tif got := GetCachedSignature(testModelName, \"text-b\"); got != \"\" {\n\t\tt.Errorf(\"Expected empty string for different text, got '%s'\", got)\n\t}\n}\n\nfunc TestCacheSignature_EmptyInputs(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\t// All empty/invalid inputs should be no-ops\n\tCacheSignature(testModelName, \"\", \"sig12345678901234567890123456789012345678901234567890\")\n\tCacheSignature(testModelName, \"text\", \"\")\n\tCacheSignature(testModelName, \"text\", \"short\") // Too short\n\n\tif got := GetCachedSignature(testModelName, \"text\"); got != \"\" {\n\t\tt.Errorf(\"Expected empty after invalid cache attempts, got '%s'\", got)\n\t}\n}\n\nfunc TestCacheSignature_ShortSignatureRejected(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\ttext := \"Some text\"\n\tshortSig := \"abc123\" // Less than 50 chars\n\n\tCacheSignature(testModelName, text, shortSig)\n\n\tif got := GetCachedSignature(testModelName, text); got != \"\" {\n\t\tt.Errorf(\"Short signature should be rejected, got '%s'\", got)\n\t}\n}\n\nfunc TestClearSignatureCache_ModelGroup(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\tsig := \"validSig1234567890123456789012345678901234567890123456\"\n\tCacheSignature(testModelName, \"text\", sig)\n\tCacheSignature(testModelName, \"text-2\", sig)\n\n\tClearSignatureCache(\"session-1\")\n\n\tif got := GetCachedSignature(testModelName, \"text\"); got != sig {\n\t\tt.Error(\"signature should remain when clearing unknown session\")\n\t}\n}\n\nfunc TestClearSignatureCache_AllSessions(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\tsig := \"validSig1234567890123456789012345678901234567890123456\"\n\tCacheSignature(testModelName, \"text\", sig)\n\tCacheSignature(testModelName, \"text-2\", sig)\n\n\tClearSignatureCache(\"\")\n\n\tif got := GetCachedSignature(testModelName, \"text\"); got != \"\" {\n\t\tt.Error(\"text should be cleared\")\n\t}\n\tif got := GetCachedSignature(testModelName, \"text-2\"); got != \"\" {\n\t\tt.Error(\"text-2 should be cleared\")\n\t}\n}\n\nfunc TestHasValidSignature(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tmodelName string\n\t\tsignature string\n\t\texpected  bool\n\t}{\n\t\t{\"valid long signature\", testModelName, \"abc123validSignature1234567890123456789012345678901234567890\", true},\n\t\t{\"exactly 50 chars\", testModelName, \"12345678901234567890123456789012345678901234567890\", true},\n\t\t{\"49 chars - invalid\", testModelName, \"1234567890123456789012345678901234567890123456789\", false},\n\t\t{\"empty string\", testModelName, \"\", false},\n\t\t{\"short signature\", testModelName, \"abc\", false},\n\t\t{\"gemini sentinel\", \"gemini-3-pro-preview\", \"skip_thought_signature_validator\", true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := HasValidSignature(tt.modelName, tt.signature)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"HasValidSignature(%q) = %v, expected %v\", tt.signature, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCacheSignature_TextHashCollisionResistance(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\t// Different texts should produce different hashes\n\ttext1 := \"First thinking text\"\n\ttext2 := \"Second thinking text\"\n\tsig1 := \"signature1_1234567890123456789012345678901234567890123456\"\n\tsig2 := \"signature2_1234567890123456789012345678901234567890123456\"\n\n\tCacheSignature(testModelName, text1, sig1)\n\tCacheSignature(testModelName, text2, sig2)\n\n\tif GetCachedSignature(testModelName, text1) != sig1 {\n\t\tt.Error(\"text1 signature mismatch\")\n\t}\n\tif GetCachedSignature(testModelName, text2) != sig2 {\n\t\tt.Error(\"text2 signature mismatch\")\n\t}\n}\n\nfunc TestCacheSignature_UnicodeText(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\ttext := \"한글 텍스트와 이모지 🎉 그리고 特殊文字\"\n\tsig := \"unicodeSig123456789012345678901234567890123456789012345\"\n\n\tCacheSignature(testModelName, text, sig)\n\n\tif got := GetCachedSignature(testModelName, text); got != sig {\n\t\tt.Errorf(\"Unicode text signature retrieval failed, got '%s'\", got)\n\t}\n}\n\nfunc TestCacheSignature_Overwrite(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\ttext := \"Same text\"\n\tsig1 := \"firstSignature12345678901234567890123456789012345678901\"\n\tsig2 := \"secondSignature1234567890123456789012345678901234567890\"\n\n\tCacheSignature(testModelName, text, sig1)\n\tCacheSignature(testModelName, text, sig2) // Overwrite\n\n\tif got := GetCachedSignature(testModelName, text); got != sig2 {\n\t\tt.Errorf(\"Expected overwritten signature '%s', got '%s'\", sig2, got)\n\t}\n}\n\n// Note: TTL expiration test is tricky to test without mocking time\n// We test the logic path exists but actual expiration would require time manipulation\nfunc TestCacheSignature_ExpirationLogic(t *testing.T) {\n\tClearSignatureCache(\"\")\n\n\t// This test verifies the expiration check exists\n\t// In a real scenario, we'd mock time.Now()\n\ttext := \"text\"\n\tsig := \"validSig1234567890123456789012345678901234567890123456\"\n\n\tCacheSignature(testModelName, text, sig)\n\n\t// Fresh entry should be retrievable\n\tif got := GetCachedSignature(testModelName, text); got != sig {\n\t\tt.Errorf(\"Fresh entry should be retrievable, got '%s'\", got)\n\t}\n\n\t// We can't easily test actual expiration without time mocking\n\t// but the logic is verified by the implementation\n\t_ = time.Now() // Acknowledge we're not testing time passage\n}\n"
  },
  {
    "path": "internal/cmd/anthropic_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoClaudeLogin triggers the Claude OAuth flow through the shared authentication manager.\n// It initiates the OAuth authentication process for Anthropic Claude services and saves\n// the authentication tokens to the configured auth directory.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - options: Login options including browser behavior and prompts\nfunc DoClaudeLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\tmanager := newAuthManager()\n\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       promptFn,\n\t}\n\n\t_, savedPath, err := manager.Login(context.Background(), \"claude\", cfg, authOpts)\n\tif err != nil {\n\t\tif authErr, ok := errors.AsType[*claude.AuthenticationError](err); ok {\n\t\t\tlog.Error(claude.GetUserFriendlyMessage(authErr))\n\t\t\tif authErr.Type == claude.ErrPortInUse.Type {\n\t\t\t\tos.Exit(claude.ErrPortInUse.Code)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"Claude authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\n\tfmt.Println(\"Claude authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/antigravity_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoAntigravityLogin triggers the OAuth flow for the antigravity provider and saves tokens.\nfunc DoAntigravityLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\tmanager := newAuthManager()\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       promptFn,\n\t}\n\n\trecord, savedPath, err := manager.Login(context.Background(), \"antigravity\", cfg, authOpts)\n\tif err != nil {\n\t\tlog.Errorf(\"Antigravity authentication failed: %v\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\tif record != nil && record.Label != \"\" {\n\t\tfmt.Printf(\"Authenticated as %s\\n\", record.Label)\n\t}\n\tfmt.Println(\"Antigravity authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/auth_manager.go",
    "content": "package cmd\n\nimport (\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n)\n\n// newAuthManager creates a new authentication manager instance with all supported\n// authenticators and a file-based token store. It initializes authenticators for\n// Gemini, Codex, Claude, and Qwen providers.\n//\n// Returns:\n//   - *sdkAuth.Manager: A configured authentication manager instance\nfunc newAuthManager() *sdkAuth.Manager {\n\tstore := sdkAuth.GetTokenStore()\n\tmanager := sdkAuth.NewManager(store,\n\t\tsdkAuth.NewGeminiAuthenticator(),\n\t\tsdkAuth.NewCodexAuthenticator(),\n\t\tsdkAuth.NewClaudeAuthenticator(),\n\t\tsdkAuth.NewQwenAuthenticator(),\n\t\tsdkAuth.NewIFlowAuthenticator(),\n\t\tsdkAuth.NewAntigravityAuthenticator(),\n\t\tsdkAuth.NewKimiAuthenticator(),\n\t)\n\treturn manager\n}\n"
  },
  {
    "path": "internal/cmd/iflow_cookie.go",
    "content": "package cmd\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// DoIFlowCookieAuth performs the iFlow cookie-based authentication.\nfunc DoIFlowCookieAuth(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\treader := bufio.NewReader(os.Stdin)\n\t\tpromptFn = func(prompt string) (string, error) {\n\t\t\tfmt.Print(prompt)\n\t\t\tvalue, err := reader.ReadString('\\n')\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t\treturn strings.TrimSpace(value), nil\n\t\t}\n\t}\n\n\t// Prompt user for cookie\n\tcookie, err := promptForCookie(promptFn)\n\tif err != nil {\n\t\tfmt.Printf(\"Failed to get cookie: %v\\n\", err)\n\t\treturn\n\t}\n\n\t// Check for duplicate BXAuth before authentication\n\tbxAuth := iflow.ExtractBXAuth(cookie)\n\tif existingFile, err := iflow.CheckDuplicateBXAuth(cfg.AuthDir, bxAuth); err != nil {\n\t\tfmt.Printf(\"Failed to check duplicate: %v\\n\", err)\n\t\treturn\n\t} else if existingFile != \"\" {\n\t\tfmt.Printf(\"Duplicate BXAuth found, authentication already exists: %s\\n\", filepath.Base(existingFile))\n\t\treturn\n\t}\n\n\t// Authenticate with cookie\n\tauth := iflow.NewIFlowAuth(cfg)\n\tctx := context.Background()\n\n\ttokenData, err := auth.AuthenticateWithCookie(ctx, cookie)\n\tif err != nil {\n\t\tfmt.Printf(\"iFlow cookie authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\t// Create token storage\n\ttokenStorage := auth.CreateCookieTokenStorage(tokenData)\n\n\t// Get auth file path using email in filename\n\tauthFilePath := getAuthFilePath(cfg, \"iflow\", tokenData.Email)\n\n\t// Save token to file\n\tif err := tokenStorage.SaveTokenToFile(authFilePath); err != nil {\n\t\tfmt.Printf(\"Failed to save authentication: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Authentication successful! API key: %s\\n\", tokenData.APIKey)\n\tfmt.Printf(\"Expires at: %s\\n\", tokenData.Expire)\n\tfmt.Printf(\"Authentication saved to: %s\\n\", authFilePath)\n}\n\n// promptForCookie prompts the user to enter their iFlow cookie\nfunc promptForCookie(promptFn func(string) (string, error)) (string, error) {\n\tline, err := promptFn(\"Enter iFlow Cookie (from browser cookies): \")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read cookie: %w\", err)\n\t}\n\n\tcookie, err := iflow.NormalizeCookie(line)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn cookie, nil\n}\n\n// getAuthFilePath returns the auth file path for the given provider and email\nfunc getAuthFilePath(cfg *config.Config, provider, email string) string {\n\tfileName := iflow.SanitizeIFlowFileName(email)\n\treturn fmt.Sprintf(\"%s/%s-%s-%d.json\", cfg.AuthDir, provider, fileName, time.Now().Unix())\n}\n"
  },
  {
    "path": "internal/cmd/iflow_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoIFlowLogin performs the iFlow OAuth login via the shared authentication manager.\nfunc DoIFlowLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tmanager := newAuthManager()\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       promptFn,\n\t}\n\n\t_, savedPath, err := manager.Login(context.Background(), \"iflow\", cfg, authOpts)\n\tif err != nil {\n\t\tif emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok {\n\t\t\tlog.Error(emailErr.Error())\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"iFlow authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\n\tfmt.Println(\"iFlow authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/kimi_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoKimiLogin triggers the OAuth device flow for Kimi (Moonshot AI) and saves tokens.\n// It initiates the device flow authentication, displays the verification URL for the user,\n// and waits for authorization before saving the tokens.\n//\n// Parameters:\n//   - cfg: The application configuration containing proxy and auth directory settings\n//   - options: Login options including browser behavior settings\nfunc DoKimiLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tmanager := newAuthManager()\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser: options.NoBrowser,\n\t\tMetadata:  map[string]string{},\n\t\tPrompt:    options.Prompt,\n\t}\n\n\trecord, savedPath, err := manager.Login(context.Background(), \"kimi\", cfg, authOpts)\n\tif err != nil {\n\t\tlog.Errorf(\"Kimi authentication failed: %v\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\tif record != nil && record.Label != \"\" {\n\t\tfmt.Printf(\"Authenticated as %s\\n\", record.Label)\n\t}\n\tfmt.Println(\"Kimi authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/login.go",
    "content": "// Package cmd provides command-line interface functionality for the CLI Proxy API server.\n// It includes authentication flows for various AI service providers, service startup,\n// and other command-line operations.\npackage cmd\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\nconst (\n\tgeminiCLIEndpoint = \"https://cloudcode-pa.googleapis.com\"\n\tgeminiCLIVersion  = \"v1internal\"\n)\n\ntype projectSelectionRequiredError struct{}\n\nfunc (e *projectSelectionRequiredError) Error() string {\n\treturn \"gemini cli: project selection required\"\n}\n\n// DoLogin handles Google Gemini authentication using the shared authentication manager.\n// It initiates the OAuth flow for Google Gemini services, performs the legacy CLI user setup,\n// and saves the authentication tokens to the configured auth directory.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - projectID: Optional Google Cloud project ID for Gemini services\n//   - options: Login options including browser behavior and prompts\nfunc DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tctx := context.Background()\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\ttrimmedProjectID := strings.TrimSpace(projectID)\n\tcallbackPrompt := promptFn\n\tif trimmedProjectID == \"\" {\n\t\tcallbackPrompt = nil\n\t}\n\n\tloginOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tProjectID:    trimmedProjectID,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       callbackPrompt,\n\t}\n\n\tauthenticator := sdkAuth.NewGeminiAuthenticator()\n\trecord, errLogin := authenticator.Login(ctx, cfg, loginOpts)\n\tif errLogin != nil {\n\t\tlog.Errorf(\"Gemini authentication failed: %v\", errLogin)\n\t\treturn\n\t}\n\n\tstorage, okStorage := record.Storage.(*gemini.GeminiTokenStorage)\n\tif !okStorage || storage == nil {\n\t\tlog.Error(\"Gemini authentication failed: unsupported token storage\")\n\t\treturn\n\t}\n\n\tgeminiAuth := gemini.NewGeminiAuth()\n\thttpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, &gemini.WebLoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tPrompt:       callbackPrompt,\n\t})\n\tif errClient != nil {\n\t\tlog.Errorf(\"Gemini authentication failed: %v\", errClient)\n\t\treturn\n\t}\n\n\tlog.Info(\"Authentication successful.\")\n\n\tvar activatedProjects []string\n\n\tuseGoogleOne := false\n\tif trimmedProjectID == \"\" && promptFn != nil {\n\t\tfmt.Println(\"\\nSelect login mode:\")\n\t\tfmt.Println(\"  1. Code Assist  (GCP project, manual selection)\")\n\t\tfmt.Println(\"  2. Google One   (personal account, auto-discover project)\")\n\t\tchoice, errPrompt := promptFn(\"Enter choice [1/2] (default: 1): \")\n\t\tif errPrompt == nil && strings.TrimSpace(choice) == \"2\" {\n\t\t\tuseGoogleOne = true\n\t\t}\n\t}\n\n\tif useGoogleOne {\n\t\tlog.Info(\"Google One mode: auto-discovering project...\")\n\t\tif errSetup := performGeminiCLISetup(ctx, httpClient, storage, \"\"); errSetup != nil {\n\t\t\tlog.Errorf(\"Google One auto-discovery failed: %v\", errSetup)\n\t\t\treturn\n\t\t}\n\t\tautoProject := strings.TrimSpace(storage.ProjectID)\n\t\tif autoProject == \"\" {\n\t\t\tlog.Error(\"Google One auto-discovery returned empty project ID\")\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"Auto-discovered project: %s\", autoProject)\n\t\tactivatedProjects = []string{autoProject}\n\t} else {\n\t\tprojects, errProjects := fetchGCPProjects(ctx, httpClient)\n\t\tif errProjects != nil {\n\t\t\tlog.Errorf(\"Failed to get project list: %v\", errProjects)\n\t\t\treturn\n\t\t}\n\n\t\tselectedProjectID := promptForProjectSelection(projects, trimmedProjectID, promptFn)\n\t\tprojectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects)\n\t\tif errSelection != nil {\n\t\t\tlog.Errorf(\"Invalid project selection: %v\", errSelection)\n\t\t\treturn\n\t\t}\n\t\tif len(projectSelections) == 0 {\n\t\t\tlog.Error(\"No project selected; aborting login.\")\n\t\t\treturn\n\t\t}\n\n\t\tseenProjects := make(map[string]bool)\n\t\tfor _, candidateID := range projectSelections {\n\t\t\tlog.Infof(\"Activating project %s\", candidateID)\n\t\t\tif errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil {\n\t\t\t\tif _, ok := errors.AsType[*projectSelectionRequiredError](errSetup); ok {\n\t\t\t\t\tlog.Error(\"Failed to start user onboarding: A project ID is required.\")\n\t\t\t\t\tshowProjectSelectionHelp(storage.Email, projects)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlog.Errorf(\"Failed to complete user setup: %v\", errSetup)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfinalID := strings.TrimSpace(storage.ProjectID)\n\t\t\tif finalID == \"\" {\n\t\t\t\tfinalID = candidateID\n\t\t\t}\n\n\t\t\tif seenProjects[finalID] {\n\t\t\t\tlog.Infof(\"Project %s already activated, skipping\", finalID)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenProjects[finalID] = true\n\t\t\tactivatedProjects = append(activatedProjects, finalID)\n\t\t}\n\t}\n\n\tstorage.Auto = false\n\tstorage.ProjectID = strings.Join(activatedProjects, \",\")\n\n\tif !storage.Auto && !storage.Checked {\n\t\tfor _, pid := range activatedProjects {\n\t\t\tisChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid)\n\t\t\tif errCheck != nil {\n\t\t\t\tlog.Errorf(\"Failed to check if Cloud AI API is enabled for %s: %v\", pid, errCheck)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !isChecked {\n\t\t\t\tlog.Errorf(\"Failed to check if Cloud AI API is enabled for project %s. If you encounter an error message, please create an issue.\", pid)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tstorage.Checked = true\n\t}\n\n\tupdateAuthRecord(record, storage)\n\n\tstore := sdkAuth.GetTokenStore()\n\tif setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter && cfg != nil {\n\t\tsetter.SetBaseDir(cfg.AuthDir)\n\t}\n\n\tsavedPath, errSave := store.Save(ctx, record)\n\tif errSave != nil {\n\t\tlog.Errorf(\"Failed to save token to file: %v\", errSave)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\n\tfmt.Println(\"Gemini authentication successful!\")\n}\n\nfunc performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *gemini.GeminiTokenStorage, requestedProject string) error {\n\tmetadata := map[string]string{\n\t\t\"ideType\":    \"IDE_UNSPECIFIED\",\n\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\"pluginType\": \"GEMINI\",\n\t}\n\n\ttrimmedRequest := strings.TrimSpace(requestedProject)\n\texplicitProject := trimmedRequest != \"\"\n\n\tloadReqBody := map[string]any{\n\t\t\"metadata\": metadata,\n\t}\n\tif explicitProject {\n\t\tloadReqBody[\"cloudaicompanionProject\"] = trimmedRequest\n\t}\n\n\tvar loadResp map[string]any\n\tif errLoad := callGeminiCLI(ctx, httpClient, \"loadCodeAssist\", loadReqBody, &loadResp); errLoad != nil {\n\t\treturn fmt.Errorf(\"load code assist: %w\", errLoad)\n\t}\n\n\ttierID := \"legacy-tier\"\n\tif tiers, okTiers := loadResp[\"allowedTiers\"].([]any); okTiers {\n\t\tfor _, rawTier := range tiers {\n\t\t\ttier, okTier := rawTier.(map[string]any)\n\t\t\tif !okTier {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif isDefault, okDefault := tier[\"isDefault\"].(bool); okDefault && isDefault {\n\t\t\t\tif id, okID := tier[\"id\"].(string); okID && strings.TrimSpace(id) != \"\" {\n\t\t\t\t\ttierID = strings.TrimSpace(id)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprojectID := trimmedRequest\n\tif projectID == \"\" {\n\t\tif id, okProject := loadResp[\"cloudaicompanionProject\"].(string); okProject {\n\t\t\tprojectID = strings.TrimSpace(id)\n\t\t}\n\t\tif projectID == \"\" {\n\t\t\tif projectMap, okProject := loadResp[\"cloudaicompanionProject\"].(map[string]any); okProject {\n\t\t\t\tif id, okID := projectMap[\"id\"].(string); okID {\n\t\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif projectID == \"\" {\n\t\t// Auto-discovery: try onboardUser without specifying a project\n\t\t// to let Google auto-provision one (matches Gemini CLI headless behavior\n\t\t// and Antigravity's FetchProjectID pattern).\n\t\tautoOnboardReq := map[string]any{\n\t\t\t\"tierId\":   tierID,\n\t\t\t\"metadata\": metadata,\n\t\t}\n\n\t\tautoCtx, autoCancel := context.WithTimeout(ctx, 30*time.Second)\n\t\tdefer autoCancel()\n\t\tfor attempt := 1; ; attempt++ {\n\t\t\tvar onboardResp map[string]any\n\t\t\tif errOnboard := callGeminiCLI(autoCtx, httpClient, \"onboardUser\", autoOnboardReq, &onboardResp); errOnboard != nil {\n\t\t\t\treturn fmt.Errorf(\"auto-discovery onboardUser: %w\", errOnboard)\n\t\t\t}\n\n\t\t\tif done, okDone := onboardResp[\"done\"].(bool); okDone && done {\n\t\t\t\tif resp, okResp := onboardResp[\"response\"].(map[string]any); okResp {\n\t\t\t\t\tswitch v := resp[\"cloudaicompanionProject\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tprojectID = strings.TrimSpace(v)\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tif id, okID := v[\"id\"].(string); okID {\n\t\t\t\t\t\t\tprojectID = strings.TrimSpace(id)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tlog.Debugf(\"Auto-discovery: onboarding in progress, attempt %d...\", attempt)\n\t\t\tselect {\n\t\t\tcase <-autoCtx.Done():\n\t\t\t\treturn &projectSelectionRequiredError{}\n\t\t\tcase <-time.After(2 * time.Second):\n\t\t\t}\n\t\t}\n\n\t\tif projectID == \"\" {\n\t\t\treturn &projectSelectionRequiredError{}\n\t\t}\n\t\tlog.Infof(\"Auto-discovered project ID via onboarding: %s\", projectID)\n\t}\n\n\tonboardReqBody := map[string]any{\n\t\t\"tierId\":                  tierID,\n\t\t\"metadata\":                metadata,\n\t\t\"cloudaicompanionProject\": projectID,\n\t}\n\n\t// Store the requested project as a fallback in case the response omits it.\n\tstorage.ProjectID = projectID\n\n\tfor {\n\t\tvar onboardResp map[string]any\n\t\tif errOnboard := callGeminiCLI(ctx, httpClient, \"onboardUser\", onboardReqBody, &onboardResp); errOnboard != nil {\n\t\t\treturn fmt.Errorf(\"onboard user: %w\", errOnboard)\n\t\t}\n\n\t\tif done, okDone := onboardResp[\"done\"].(bool); okDone && done {\n\t\t\tresponseProjectID := \"\"\n\t\t\tif resp, okResp := onboardResp[\"response\"].(map[string]any); okResp {\n\t\t\t\tswitch projectValue := resp[\"cloudaicompanionProject\"].(type) {\n\t\t\t\tcase map[string]any:\n\t\t\t\t\tif id, okID := projectValue[\"id\"].(string); okID {\n\t\t\t\t\t\tresponseProjectID = strings.TrimSpace(id)\n\t\t\t\t\t}\n\t\t\t\tcase string:\n\t\t\t\t\tresponseProjectID = strings.TrimSpace(projectValue)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfinalProjectID := projectID\n\t\t\tif responseProjectID != \"\" {\n\t\t\t\tif explicitProject && !strings.EqualFold(responseProjectID, projectID) {\n\t\t\t\t\t// Check if this is a free user (gen-lang-client projects or free/legacy tier)\n\t\t\t\t\tisFreeUser := strings.HasPrefix(projectID, \"gen-lang-client-\") ||\n\t\t\t\t\t\tstrings.EqualFold(tierID, \"FREE\") ||\n\t\t\t\t\t\tstrings.EqualFold(tierID, \"LEGACY\")\n\n\t\t\t\t\tif isFreeUser {\n\t\t\t\t\t\t// Interactive prompt for free users\n\t\t\t\t\t\tfmt.Printf(\"\\nGoogle returned a different project ID:\\n\")\n\t\t\t\t\t\tfmt.Printf(\"  Requested (frontend): %s\\n\", projectID)\n\t\t\t\t\t\tfmt.Printf(\"  Returned (backend):   %s\\n\\n\", responseProjectID)\n\t\t\t\t\t\tfmt.Printf(\"  Backend project IDs have access to preview models (gemini-3-*).\\n\")\n\t\t\t\t\t\tfmt.Printf(\"  This is normal for free tier users.\\n\\n\")\n\t\t\t\t\t\tfmt.Printf(\"Which project ID would you like to use?\\n\")\n\t\t\t\t\t\tfmt.Printf(\"  [1] Backend (recommended): %s\\n\", responseProjectID)\n\t\t\t\t\t\tfmt.Printf(\"  [2] Frontend: %s\\n\\n\", projectID)\n\t\t\t\t\t\tfmt.Printf(\"Enter choice [1]: \")\n\n\t\t\t\t\t\treader := bufio.NewReader(os.Stdin)\n\t\t\t\t\t\tchoice, _ := reader.ReadString('\\n')\n\t\t\t\t\t\tchoice = strings.TrimSpace(choice)\n\n\t\t\t\t\t\tif choice == \"2\" {\n\t\t\t\t\t\t\tlog.Infof(\"Using frontend project ID: %s\", projectID)\n\t\t\t\t\t\t\tfmt.Println(\". Warning: Frontend project IDs may not have access to preview models.\")\n\t\t\t\t\t\t\tfinalProjectID = projectID\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tlog.Infof(\"Using backend project ID: %s (recommended)\", responseProjectID)\n\t\t\t\t\t\t\tfinalProjectID = responseProjectID\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Pro users: keep requested project ID (original behavior)\n\t\t\t\t\t\tlog.Warnf(\"Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.\", responseProjectID, projectID)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfinalProjectID = responseProjectID\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tstorage.ProjectID = strings.TrimSpace(finalProjectID)\n\t\t\tif storage.ProjectID == \"\" {\n\t\t\t\tstorage.ProjectID = strings.TrimSpace(projectID)\n\t\t\t}\n\t\t\tif storage.ProjectID == \"\" {\n\t\t\t\treturn fmt.Errorf(\"onboard user completed without project id\")\n\t\t\t}\n\t\t\tlog.Infof(\"Onboarding complete. Using Project ID: %s\", storage.ProjectID)\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Println(\"Onboarding in progress, waiting 5 seconds...\")\n\t\ttime.Sleep(5 * time.Second)\n\t}\n}\n\nfunc callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {\n\turl := fmt.Sprintf(\"%s/%s:%s\", geminiCLIEndpoint, geminiCLIVersion, endpoint)\n\tif strings.HasPrefix(endpoint, \"operations/\") {\n\t\turl = fmt.Sprintf(\"%s/%s\", geminiCLIEndpoint, endpoint)\n\t}\n\n\tvar reader io.Reader\n\tif body != nil {\n\t\trawBody, errMarshal := json.Marshal(body)\n\t\tif errMarshal != nil {\n\t\t\treturn fmt.Errorf(\"marshal request body: %w\", errMarshal)\n\t\t}\n\t\treader = bytes.NewReader(rawBody)\n\t}\n\n\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, reader)\n\tif errRequest != nil {\n\t\treturn fmt.Errorf(\"create request: %w\", errRequest)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn fmt.Errorf(\"execute request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"api request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tif result == nil {\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\treturn nil\n\t}\n\n\tif errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {\n\t\treturn fmt.Errorf(\"decode response body: %w\", errDecode)\n\t}\n\n\treturn nil\n}\n\nfunc fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {\n\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, \"https://cloudresourcemanager.googleapis.com/v1/projects\", nil)\n\tif errRequest != nil {\n\t\treturn nil, fmt.Errorf(\"could not create project list request: %w\", errRequest)\n\t}\n\n\tresp, errDo := httpClient.Do(req)\n\tif errDo != nil {\n\t\treturn nil, fmt.Errorf(\"failed to execute project list request: %w\", errDo)\n\t}\n\tdefer func() {\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\n\tif resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"project list request failed with status %d: %s\", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))\n\t}\n\n\tvar projects interfaces.GCPProject\n\tif errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal project list: %w\", errDecode)\n\t}\n\n\treturn projects.Projects, nil\n}\n\n// promptForProjectSelection prints available projects and returns the chosen project ID.\nfunc promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetID string, promptFn func(string) (string, error)) string {\n\ttrimmedPreset := strings.TrimSpace(presetID)\n\tif len(projects) == 0 {\n\t\tif trimmedPreset != \"\" {\n\t\t\treturn trimmedPreset\n\t\t}\n\t\tfmt.Println(\"No Google Cloud projects are available for selection.\")\n\t\treturn \"\"\n\t}\n\n\tfmt.Println(\"Available Google Cloud projects:\")\n\tdefaultIndex := 0\n\tfor idx, project := range projects {\n\t\tfmt.Printf(\"[%d] %s (%s)\\n\", idx+1, project.ProjectID, project.Name)\n\t\tif trimmedPreset != \"\" && project.ProjectID == trimmedPreset {\n\t\t\tdefaultIndex = idx\n\t\t}\n\t}\n\tfmt.Println(\"Type 'ALL' to onboard every listed project.\")\n\n\tdefaultID := projects[defaultIndex].ProjectID\n\n\tif trimmedPreset != \"\" {\n\t\tif strings.EqualFold(trimmedPreset, \"ALL\") {\n\t\t\treturn \"ALL\"\n\t\t}\n\t\tfor _, project := range projects {\n\t\t\tif project.ProjectID == trimmedPreset {\n\t\t\t\treturn trimmedPreset\n\t\t\t}\n\t\t}\n\t\tlog.Warnf(\"Provided project ID %s not found in available projects; please choose from the list.\", trimmedPreset)\n\t}\n\n\tfor {\n\t\tpromptMsg := fmt.Sprintf(\"Enter project ID [%s] or ALL: \", defaultID)\n\t\tanswer, errPrompt := promptFn(promptMsg)\n\t\tif errPrompt != nil {\n\t\t\tlog.Errorf(\"Project selection prompt failed: %v\", errPrompt)\n\t\t\treturn defaultID\n\t\t}\n\t\tanswer = strings.TrimSpace(answer)\n\t\tif strings.EqualFold(answer, \"ALL\") {\n\t\t\treturn \"ALL\"\n\t\t}\n\t\tif answer == \"\" {\n\t\t\treturn defaultID\n\t\t}\n\n\t\tfor _, project := range projects {\n\t\t\tif project.ProjectID == answer {\n\t\t\t\treturn project.ProjectID\n\t\t\t}\n\t\t}\n\n\t\tif idx, errAtoi := strconv.Atoi(answer); errAtoi == nil {\n\t\t\tif idx >= 1 && idx <= len(projects) {\n\t\t\t\treturn projects[idx-1].ProjectID\n\t\t\t}\n\t\t}\n\n\t\tfmt.Println(\"Invalid selection, enter a project ID or a number from the list.\")\n\t}\n}\n\nfunc resolveProjectSelections(selection string, projects []interfaces.GCPProjectProjects) ([]string, error) {\n\ttrimmed := strings.TrimSpace(selection)\n\tif trimmed == \"\" {\n\t\treturn nil, nil\n\t}\n\tavailable := make(map[string]struct{}, len(projects))\n\tordered := make([]string, 0, len(projects))\n\tfor _, project := range projects {\n\t\tid := strings.TrimSpace(project.ProjectID)\n\t\tif id == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := available[id]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tavailable[id] = struct{}{}\n\t\tordered = append(ordered, id)\n\t}\n\tif strings.EqualFold(trimmed, \"ALL\") {\n\t\tif len(ordered) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"no projects available for ALL selection\")\n\t\t}\n\t\treturn append([]string(nil), ordered...), nil\n\t}\n\tparts := strings.Split(trimmed, \",\")\n\tselections := make([]string, 0, len(parts))\n\tseen := make(map[string]struct{}, len(parts))\n\tfor _, part := range parts {\n\t\tid := strings.TrimSpace(part)\n\t\tif id == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, dup := seen[id]; dup {\n\t\t\tcontinue\n\t\t}\n\t\tif len(available) > 0 {\n\t\t\tif _, ok := available[id]; !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"project %s not found in available projects\", id)\n\t\t\t}\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tselections = append(selections, id)\n\t}\n\treturn selections, nil\n}\n\nfunc defaultProjectPrompt() func(string) (string, error) {\n\treader := bufio.NewReader(os.Stdin)\n\treturn func(prompt string) (string, error) {\n\t\tfmt.Print(prompt)\n\t\tline, errRead := reader.ReadString('\\n')\n\t\tif errRead != nil {\n\t\t\tif errors.Is(errRead, io.EOF) {\n\t\t\t\treturn strings.TrimSpace(line), nil\n\t\t\t}\n\t\t\treturn \"\", errRead\n\t\t}\n\t\treturn strings.TrimSpace(line), nil\n\t}\n}\n\nfunc showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProjects) {\n\tif email != \"\" {\n\t\tlog.Infof(\"Your account %s needs to specify a project ID.\", email)\n\t} else {\n\t\tlog.Info(\"You need to specify a project ID.\")\n\t}\n\n\tif len(projects) > 0 {\n\t\tfmt.Println(\"========================================================================\")\n\t\tfor _, p := range projects {\n\t\t\tfmt.Printf(\"Project ID: %s\\n\", p.ProjectID)\n\t\t\tfmt.Printf(\"Project Name: %s\\n\", p.Name)\n\t\t\tfmt.Println(\"------------------------------------------------------------------------\")\n\t\t}\n\t} else {\n\t\tfmt.Println(\"No active projects were returned for this account.\")\n\t}\n\n\tfmt.Printf(\"Please run this command to login again with a specific project:\\n\\n%s --login --project_id <project_id>\\n\", os.Args[0])\n}\n\nfunc checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {\n\tserviceUsageURL := \"https://serviceusage.googleapis.com\"\n\trequiredServices := []string{\n\t\t// \"geminicloudassist.googleapis.com\", // Gemini Cloud Assist API\n\t\t\"cloudaicompanion.googleapis.com\", // Gemini for Google Cloud API\n\t}\n\tfor _, service := range requiredServices {\n\t\tcheckUrl := fmt.Sprintf(\"%s/v1/projects/%s/services/%s\", serviceUsageURL, projectID, service)\n\t\treq, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)\n\t\tif errRequest != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to create request: %w\", errRequest)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\t\tresp, errDo := httpClient.Do(req)\n\t\tif errDo != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to execute request: %w\", errDo)\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\t\tif gjson.GetBytes(bodyBytes, \"state\").String() == \"ENABLED\" {\n\t\t\t\t_ = resp.Body.Close()\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t_ = resp.Body.Close()\n\n\t\tenableUrl := fmt.Sprintf(\"%s/v1/projects/%s/services/%s:enable\", serviceUsageURL, projectID, service)\n\t\treq, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableUrl, strings.NewReader(\"{}\"))\n\t\tif errRequest != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to create request: %w\", errRequest)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(\"\"))\n\t\tresp, errDo = httpClient.Do(req)\n\t\tif errDo != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to execute request: %w\", errDo)\n\t\t}\n\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\terrMessage := string(bodyBytes)\n\t\terrMessageResult := gjson.GetBytes(bodyBytes, \"error.message\")\n\t\tif errMessageResult.Exists() {\n\t\t\terrMessage = errMessageResult.String()\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {\n\t\t\t_ = resp.Body.Close()\n\t\t\tcontinue\n\t\t} else if resp.StatusCode == http.StatusBadRequest {\n\t\t\t_ = resp.Body.Close()\n\t\t\tif strings.Contains(strings.ToLower(errMessage), \"already enabled\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t_ = resp.Body.Close()\n\t\treturn false, fmt.Errorf(\"project activation required: %s\", errMessage)\n\t}\n\treturn true, nil\n}\n\nfunc updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStorage) {\n\tif record == nil || storage == nil {\n\t\treturn\n\t}\n\n\tfinalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, true)\n\n\tif record.Metadata == nil {\n\t\trecord.Metadata = make(map[string]any)\n\t}\n\trecord.Metadata[\"email\"] = storage.Email\n\trecord.Metadata[\"project_id\"] = storage.ProjectID\n\trecord.Metadata[\"auto\"] = storage.Auto\n\trecord.Metadata[\"checked\"] = storage.Checked\n\n\trecord.ID = finalName\n\trecord.FileName = finalName\n\trecord.Storage = storage\n}\n"
  },
  {
    "path": "internal/cmd/openai_device_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tcodexLoginModeMetadataKey = \"codex_login_mode\"\n\tcodexLoginModeDevice      = \"device\"\n)\n\n// DoCodexDeviceLogin triggers the Codex device-code flow while keeping the\n// existing codex-login OAuth callback flow intact.\nfunc DoCodexDeviceLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\tmanager := newAuthManager()\n\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata: map[string]string{\n\t\t\tcodexLoginModeMetadataKey: codexLoginModeDevice,\n\t\t},\n\t\tPrompt: promptFn,\n\t}\n\n\t_, savedPath, err := manager.Login(context.Background(), \"codex\", cfg, authOpts)\n\tif err != nil {\n\t\tif authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok {\n\t\t\tlog.Error(codex.GetUserFriendlyMessage(authErr))\n\t\t\tif authErr.Type == codex.ErrPortInUse.Type {\n\t\t\t\tos.Exit(codex.ErrPortInUse.Code)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"Codex device authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\tfmt.Println(\"Codex device authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/openai_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// LoginOptions contains options for the login processes.\n// It provides configuration for authentication flows including browser behavior\n// and interactive prompting capabilities.\ntype LoginOptions struct {\n\t// NoBrowser indicates whether to skip opening the browser automatically.\n\tNoBrowser bool\n\n\t// CallbackPort overrides the local OAuth callback port when set (>0).\n\tCallbackPort int\n\n\t// Prompt allows the caller to provide interactive input when needed.\n\tPrompt func(prompt string) (string, error)\n}\n\n// DoCodexLogin triggers the Codex OAuth flow through the shared authentication manager.\n// It initiates the OAuth authentication process for OpenAI Codex services and saves\n// the authentication tokens to the configured auth directory.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - options: Login options including browser behavior and prompts\nfunc DoCodexLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = defaultProjectPrompt()\n\t}\n\n\tmanager := newAuthManager()\n\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       promptFn,\n\t}\n\n\t_, savedPath, err := manager.Login(context.Background(), \"codex\", cfg, authOpts)\n\tif err != nil {\n\t\tif authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok {\n\t\t\tlog.Error(codex.GetUserFriendlyMessage(authErr))\n\t\t\tif authErr.Type == codex.ErrPortInUse.Type {\n\t\t\t\tos.Exit(codex.ErrPortInUse.Code)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"Codex authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\tfmt.Println(\"Codex authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/qwen_login.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoQwenLogin handles the Qwen device flow using the shared authentication manager.\n// It initiates the device-based authentication process for Qwen services and saves\n// the authentication tokens to the configured auth directory.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - options: Login options including browser behavior and prompts\nfunc DoQwenLogin(cfg *config.Config, options *LoginOptions) {\n\tif options == nil {\n\t\toptions = &LoginOptions{}\n\t}\n\n\tmanager := newAuthManager()\n\n\tpromptFn := options.Prompt\n\tif promptFn == nil {\n\t\tpromptFn = func(prompt string) (string, error) {\n\t\t\tfmt.Println()\n\t\t\tfmt.Println(prompt)\n\t\t\tvar value string\n\t\t\t_, err := fmt.Scanln(&value)\n\t\t\treturn value, err\n\t\t}\n\t}\n\n\tauthOpts := &sdkAuth.LoginOptions{\n\t\tNoBrowser:    options.NoBrowser,\n\t\tCallbackPort: options.CallbackPort,\n\t\tMetadata:     map[string]string{},\n\t\tPrompt:       promptFn,\n\t}\n\n\t_, savedPath, err := manager.Login(context.Background(), \"qwen\", cfg, authOpts)\n\tif err != nil {\n\t\tif emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok {\n\t\t\tlog.Error(emailErr.Error())\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"Qwen authentication failed: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif savedPath != \"\" {\n\t\tfmt.Printf(\"Authentication saved to %s\\n\", savedPath)\n\t}\n\n\tfmt.Println(\"Qwen authentication successful!\")\n}\n"
  },
  {
    "path": "internal/cmd/run.go",
    "content": "// Package cmd provides command-line interface functionality for the CLI Proxy API server.\n// It includes authentication flows for various AI service providers, service startup,\n// and other command-line operations.\npackage cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// StartService builds and runs the proxy service using the exported SDK.\n// It creates a new proxy service instance, sets up signal handling for graceful shutdown,\n// and starts the service with the provided configuration.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - configPath: The path to the configuration file\n//   - localPassword: Optional password accepted for local management requests\nfunc StartService(cfg *config.Config, configPath string, localPassword string) {\n\tbuilder := cliproxy.NewBuilder().\n\t\tWithConfig(cfg).\n\t\tWithConfigPath(configPath).\n\t\tWithLocalManagementPassword(localPassword)\n\n\tctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)\n\tdefer cancel()\n\n\trunCtx := ctxSignal\n\tif localPassword != \"\" {\n\t\tvar keepAliveCancel context.CancelFunc\n\t\trunCtx, keepAliveCancel = context.WithCancel(ctxSignal)\n\t\tbuilder = builder.WithServerOptions(api.WithKeepAliveEndpoint(10*time.Second, func() {\n\t\t\tlog.Warn(\"keep-alive endpoint idle for 10s, shutting down\")\n\t\t\tkeepAliveCancel()\n\t\t}))\n\t}\n\n\tservice, err := builder.Build()\n\tif err != nil {\n\t\tlog.Errorf(\"failed to build proxy service: %v\", err)\n\t\treturn\n\t}\n\n\terr = service.Run(runCtx)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\tlog.Errorf(\"proxy service exited with error: %v\", err)\n\t}\n}\n\n// StartServiceBackground starts the proxy service in a background goroutine\n// and returns a cancel function for shutdown and a done channel.\nfunc StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) {\n\tbuilder := cliproxy.NewBuilder().\n\t\tWithConfig(cfg).\n\t\tWithConfigPath(configPath).\n\t\tWithLocalManagementPassword(localPassword)\n\n\tctx, cancelFn := context.WithCancel(context.Background())\n\tdoneCh := make(chan struct{})\n\n\tservice, err := builder.Build()\n\tif err != nil {\n\t\tlog.Errorf(\"failed to build proxy service: %v\", err)\n\t\tclose(doneCh)\n\t\treturn cancelFn, doneCh\n\t}\n\n\tgo func() {\n\t\tdefer close(doneCh)\n\t\tif err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\tlog.Errorf(\"proxy service exited with error: %v\", err)\n\t\t}\n\t}()\n\n\treturn cancelFn, doneCh\n}\n\n// WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode\n// when no configuration file is available.\nfunc WaitForCloudDeploy() {\n\t// Clarify that we are intentionally idle for configuration and not running the API server.\n\tlog.Info(\"Cloud deploy mode: No config found; standing by for configuration. API server is not started. Press Ctrl+C to exit.\")\n\n\tctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)\n\tdefer cancel()\n\n\t// Block until shutdown signal is received\n\t<-ctxSignal.Done()\n\tlog.Info(\"Cloud deploy mode: Shutdown signal received; exiting\")\n}\n"
  },
  {
    "path": "internal/cmd/vertex_import.go",
    "content": "// Package cmd contains CLI helpers. This file implements importing a Vertex AI\n// service account JSON into the auth store as a dedicated \"vertex\" credential.\npackage cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// DoVertexImport imports a Google Cloud service account key JSON and persists\n// it as a \"vertex\" provider credential. The file content is embedded in the auth\n// file to allow portable deployment across stores.\nfunc DoVertexImport(cfg *config.Config, keyPath string) {\n\tif cfg == nil {\n\t\tcfg = &config.Config{}\n\t}\n\tif resolved, errResolve := util.ResolveAuthDir(cfg.AuthDir); errResolve == nil {\n\t\tcfg.AuthDir = resolved\n\t}\n\trawPath := strings.TrimSpace(keyPath)\n\tif rawPath == \"\" {\n\t\tlog.Errorf(\"vertex-import: missing service account key path\")\n\t\treturn\n\t}\n\tdata, errRead := os.ReadFile(rawPath)\n\tif errRead != nil {\n\t\tlog.Errorf(\"vertex-import: read file failed: %v\", errRead)\n\t\treturn\n\t}\n\tvar sa map[string]any\n\tif errUnmarshal := json.Unmarshal(data, &sa); errUnmarshal != nil {\n\t\tlog.Errorf(\"vertex-import: invalid service account json: %v\", errUnmarshal)\n\t\treturn\n\t}\n\t// Validate and normalize private_key before saving\n\tnormalizedSA, errFix := vertex.NormalizeServiceAccountMap(sa)\n\tif errFix != nil {\n\t\tlog.Errorf(\"vertex-import: %v\", errFix)\n\t\treturn\n\t}\n\tsa = normalizedSA\n\temail, _ := sa[\"client_email\"].(string)\n\tprojectID, _ := sa[\"project_id\"].(string)\n\tif strings.TrimSpace(projectID) == \"\" {\n\t\tlog.Errorf(\"vertex-import: project_id missing in service account json\")\n\t\treturn\n\t}\n\tif strings.TrimSpace(email) == \"\" {\n\t\t// Keep empty email but warn\n\t\tlog.Warn(\"vertex-import: client_email missing in service account json\")\n\t}\n\t// Default location if not provided by user. Can be edited in the saved file later.\n\tlocation := \"us-central1\"\n\n\tfileName := fmt.Sprintf(\"vertex-%s.json\", sanitizeFilePart(projectID))\n\t// Build auth record\n\tstorage := &vertex.VertexCredentialStorage{\n\t\tServiceAccount: sa,\n\t\tProjectID:      projectID,\n\t\tEmail:          email,\n\t\tLocation:       location,\n\t}\n\tmetadata := map[string]any{\n\t\t\"service_account\": sa,\n\t\t\"project_id\":      projectID,\n\t\t\"email\":           email,\n\t\t\"location\":        location,\n\t\t\"type\":            \"vertex\",\n\t\t\"label\":           labelForVertex(projectID, email),\n\t}\n\trecord := &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: \"vertex\",\n\t\tFileName: fileName,\n\t\tStorage:  storage,\n\t\tMetadata: metadata,\n\t}\n\n\tstore := sdkAuth.GetTokenStore()\n\tif setter, ok := store.(interface{ SetBaseDir(string) }); ok {\n\t\tsetter.SetBaseDir(cfg.AuthDir)\n\t}\n\tpath, errSave := store.Save(context.Background(), record)\n\tif errSave != nil {\n\t\tlog.Errorf(\"vertex-import: save credential failed: %v\", errSave)\n\t\treturn\n\t}\n\tfmt.Printf(\"Vertex credentials imported: %s\\n\", path)\n}\n\nfunc sanitizeFilePart(s string) string {\n\tout := strings.TrimSpace(s)\n\treplacers := []string{\"/\", \"_\", \"\\\\\", \"_\", \":\", \"_\", \" \", \"-\"}\n\tfor i := 0; i < len(replacers); i += 2 {\n\t\tout = strings.ReplaceAll(out, replacers[i], replacers[i+1])\n\t}\n\treturn out\n}\n\nfunc labelForVertex(projectID, email string) string {\n\tp := strings.TrimSpace(projectID)\n\te := strings.TrimSpace(email)\n\tif p != \"\" && e != \"\" {\n\t\treturn fmt.Sprintf(\"%s (%s)\", p, e)\n\t}\n\tif p != \"\" {\n\t\treturn p\n\t}\n\tif e != \"\" {\n\t\treturn e\n\t}\n\treturn \"vertex\"\n}\n"
  },
  {
    "path": "internal/config/codex_websocket_header_defaults_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.yaml\")\n\tconfigYAML := []byte(`\ncodex-header-defaults:\n  user-agent: \"  my-codex-client/1.0  \"\n  beta-features: \"  feature-a,feature-b  \"\n`)\n\tif err := os.WriteFile(configPath, configYAML, 0o600); err != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t}\n\n\tcfg, err := LoadConfigOptional(configPath, false)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfigOptional() error = %v\", err)\n\t}\n\n\tif got := cfg.CodexHeaderDefaults.UserAgent; got != \"my-codex-client/1.0\" {\n\t\tt.Fatalf(\"UserAgent = %q, want %q\", got, \"my-codex-client/1.0\")\n\t}\n\tif got := cfg.CodexHeaderDefaults.BetaFeatures; got != \"feature-a,feature-b\" {\n\t\tt.Fatalf(\"BetaFeatures = %q, want %q\", got, \"feature-a,feature-b\")\n\t}\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "// Package config provides configuration management for the CLI Proxy API server.\n// It handles loading and parsing YAML configuration files, and provides structured\n// access to application settings including server port, authentication directory,\n// debug settings, proxy configuration, and API keys.\npackage config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tDefaultPanelGitHubRepository = \"https://github.com/router-for-me/Cli-Proxy-API-Management-Center\"\n\tDefaultPprofAddr             = \"127.0.0.1:8316\"\n)\n\n// Config represents the application's configuration, loaded from a YAML file.\ntype Config struct {\n\tSDKConfig `yaml:\",inline\"`\n\t// Host is the network host/interface on which the API server will bind.\n\t// Default is empty (\"\") to bind all interfaces (IPv4 + IPv6). Use \"127.0.0.1\" or \"localhost\" for local-only access.\n\tHost string `yaml:\"host\" json:\"-\"`\n\t// Port is the network port on which the API server will listen.\n\tPort int `yaml:\"port\" json:\"-\"`\n\n\t// TLS config controls HTTPS server settings.\n\tTLS TLSConfig `yaml:\"tls\" json:\"tls\"`\n\n\t// RemoteManagement nests management-related options under 'remote-management'.\n\tRemoteManagement RemoteManagement `yaml:\"remote-management\" json:\"-\"`\n\n\t// AuthDir is the directory where authentication token files are stored.\n\tAuthDir string `yaml:\"auth-dir\" json:\"-\"`\n\n\t// Debug enables or disables debug-level logging and other debug features.\n\tDebug bool `yaml:\"debug\" json:\"debug\"`\n\n\t// Pprof config controls the optional pprof HTTP debug server.\n\tPprof PprofConfig `yaml:\"pprof\" json:\"pprof\"`\n\n\t// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.\n\tCommercialMode bool `yaml:\"commercial-mode\" json:\"commercial-mode\"`\n\n\t// LoggingToFile controls whether application logs are written to rotating files or stdout.\n\tLoggingToFile bool `yaml:\"logging-to-file\" json:\"logging-to-file\"`\n\n\t// LogsMaxTotalSizeMB limits the total size (in MB) of log files under the logs directory.\n\t// When exceeded, the oldest log files are deleted until within the limit. Set to 0 to disable.\n\tLogsMaxTotalSizeMB int `yaml:\"logs-max-total-size-mb\" json:\"logs-max-total-size-mb\"`\n\n\t// ErrorLogsMaxFiles limits the number of error log files retained when request logging is disabled.\n\t// When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup.\n\tErrorLogsMaxFiles int `yaml:\"error-logs-max-files\" json:\"error-logs-max-files\"`\n\n\t// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.\n\tUsageStatisticsEnabled bool `yaml:\"usage-statistics-enabled\" json:\"usage-statistics-enabled\"`\n\n\t// DisableCooling disables quota cooldown scheduling when true.\n\tDisableCooling bool `yaml:\"disable-cooling\" json:\"disable-cooling\"`\n\n\t// RequestRetry defines the retry times when the request failed.\n\tRequestRetry int `yaml:\"request-retry\" json:\"request-retry\"`\n\t// MaxRetryCredentials defines the maximum number of credentials to try for a failed request.\n\t// Set to 0 or a negative value to keep trying all available credentials (legacy behavior).\n\tMaxRetryCredentials int `yaml:\"max-retry-credentials\" json:\"max-retry-credentials\"`\n\t// MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential.\n\tMaxRetryInterval int `yaml:\"max-retry-interval\" json:\"max-retry-interval\"`\n\n\t// QuotaExceeded defines the behavior when a quota is exceeded.\n\tQuotaExceeded QuotaExceeded `yaml:\"quota-exceeded\" json:\"quota-exceeded\"`\n\n\t// Routing controls credential selection behavior.\n\tRouting RoutingConfig `yaml:\"routing\" json:\"routing\"`\n\n\t// WebsocketAuth enables or disables authentication for the WebSocket API.\n\tWebsocketAuth bool `yaml:\"ws-auth\" json:\"ws-auth\"`\n\n\t// GeminiKey defines Gemini API key configurations with optional routing overrides.\n\tGeminiKey []GeminiKey `yaml:\"gemini-api-key\" json:\"gemini-api-key\"`\n\n\t// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.\n\tCodexKey []CodexKey `yaml:\"codex-api-key\" json:\"codex-api-key\"`\n\n\t// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.\n\t// These are used only when the client does not send its own headers.\n\tCodexHeaderDefaults CodexHeaderDefaults `yaml:\"codex-header-defaults\" json:\"codex-header-defaults\"`\n\n\t// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.\n\tClaudeKey []ClaudeKey `yaml:\"claude-api-key\" json:\"claude-api-key\"`\n\n\t// ClaudeHeaderDefaults configures default header values for Claude API requests.\n\t// These are used as fallbacks when the client does not send its own headers.\n\tClaudeHeaderDefaults ClaudeHeaderDefaults `yaml:\"claude-header-defaults\" json:\"claude-header-defaults\"`\n\n\t// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.\n\tOpenAICompatibility []OpenAICompatibility `yaml:\"openai-compatibility\" json:\"openai-compatibility\"`\n\n\t// VertexCompatAPIKey defines Vertex AI-compatible API key configurations for third-party providers.\n\t// Used for services that use Vertex AI-style paths but with simple API key authentication.\n\tVertexCompatAPIKey []VertexCompatKey `yaml:\"vertex-api-key\" json:\"vertex-api-key\"`\n\n\t// AmpCode contains Amp CLI upstream configuration, management restrictions, and model mappings.\n\tAmpCode AmpCode `yaml:\"ampcode\" json:\"ampcode\"`\n\n\t// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.\n\tOAuthExcludedModels map[string][]string `yaml:\"oauth-excluded-models,omitempty\" json:\"oauth-excluded-models,omitempty\"`\n\n\t// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.\n\t// These aliases affect both model listing and model routing for supported channels:\n\t// gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.\n\t//\n\t// NOTE: This does not apply to existing per-credential model alias features under:\n\t// gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode.\n\tOAuthModelAlias map[string][]OAuthModelAlias `yaml:\"oauth-model-alias,omitempty\" json:\"oauth-model-alias,omitempty\"`\n\n\t// Payload defines default and override rules for provider payload parameters.\n\tPayload PayloadConfig `yaml:\"payload\" json:\"payload\"`\n\n\tlegacyMigrationPending bool `yaml:\"-\" json:\"-\"`\n}\n\n// ClaudeHeaderDefaults configures default header values injected into Claude API requests\n// when the client does not send them. Update these when Claude Code releases a new version.\ntype ClaudeHeaderDefaults struct {\n\tUserAgent      string `yaml:\"user-agent\" json:\"user-agent\"`\n\tPackageVersion string `yaml:\"package-version\" json:\"package-version\"`\n\tRuntimeVersion string `yaml:\"runtime-version\" json:\"runtime-version\"`\n\tTimeout        string `yaml:\"timeout\" json:\"timeout\"`\n}\n\n// CodexHeaderDefaults configures fallback header values injected into Codex\n// model requests for OAuth/file-backed auth when the client omits them.\n// UserAgent applies to HTTP and websocket requests; BetaFeatures only applies to websockets.\ntype CodexHeaderDefaults struct {\n\tUserAgent    string `yaml:\"user-agent\" json:\"user-agent\"`\n\tBetaFeatures string `yaml:\"beta-features\" json:\"beta-features\"`\n}\n\n// TLSConfig holds HTTPS server settings.\ntype TLSConfig struct {\n\t// Enable toggles HTTPS server mode.\n\tEnable bool `yaml:\"enable\" json:\"enable\"`\n\t// Cert is the path to the TLS certificate file.\n\tCert string `yaml:\"cert\" json:\"cert\"`\n\t// Key is the path to the TLS private key file.\n\tKey string `yaml:\"key\" json:\"key\"`\n}\n\n// PprofConfig holds pprof HTTP server settings.\ntype PprofConfig struct {\n\t// Enable toggles the pprof HTTP debug server.\n\tEnable bool `yaml:\"enable\" json:\"enable\"`\n\t// Addr is the host:port address for the pprof HTTP server.\n\tAddr string `yaml:\"addr\" json:\"addr\"`\n}\n\n// RemoteManagement holds management API configuration under 'remote-management'.\ntype RemoteManagement struct {\n\t// AllowRemote toggles remote (non-localhost) access to management API.\n\tAllowRemote bool `yaml:\"allow-remote\"`\n\t// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.\n\tSecretKey string `yaml:\"secret-key\"`\n\t// DisableControlPanel skips serving and syncing the bundled management UI when true.\n\tDisableControlPanel bool `yaml:\"disable-control-panel\"`\n\t// PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset.\n\t// Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint.\n\tPanelGitHubRepository string `yaml:\"panel-github-repository\"`\n}\n\n// QuotaExceeded defines the behavior when API quota limits are exceeded.\n// It provides configuration options for automatic failover mechanisms.\ntype QuotaExceeded struct {\n\t// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.\n\tSwitchProject bool `yaml:\"switch-project\" json:\"switch-project\"`\n\n\t// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.\n\tSwitchPreviewModel bool `yaml:\"switch-preview-model\" json:\"switch-preview-model\"`\n}\n\n// RoutingConfig configures how credentials are selected for requests.\ntype RoutingConfig struct {\n\t// Strategy selects the credential selection strategy.\n\t// Supported values: \"round-robin\" (default), \"fill-first\".\n\tStrategy string `yaml:\"strategy,omitempty\" json:\"strategy,omitempty\"`\n}\n\n// OAuthModelAlias defines a model ID alias for a specific channel.\n// It maps the upstream model name (Name) to the client-visible alias (Alias).\n// When Fork is true, the alias is added as an additional model in listings while\n// keeping the original model ID available.\ntype OAuthModelAlias struct {\n\tName  string `yaml:\"name\" json:\"name\"`\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n\tFork  bool   `yaml:\"fork,omitempty\" json:\"fork,omitempty\"`\n}\n\n// AmpModelMapping defines a model name mapping for Amp CLI requests.\n// When Amp requests a model that isn't available locally, this mapping\n// allows routing to an alternative model that IS available.\ntype AmpModelMapping struct {\n\t// From is the model name that Amp CLI requests (e.g., \"claude-opus-4.5\").\n\tFrom string `yaml:\"from\" json:\"from\"`\n\n\t// To is the target model name to route to (e.g., \"claude-sonnet-4\").\n\t// The target model must have available providers in the registry.\n\tTo string `yaml:\"to\" json:\"to\"`\n\n\t// Regex indicates whether the 'from' field should be interpreted as a regular\n\t// expression for matching model names. When true, this mapping is evaluated\n\t// after exact matches and in the order provided. Defaults to false (exact match).\n\tRegex bool `yaml:\"regex,omitempty\" json:\"regex,omitempty\"`\n}\n\n// AmpCode groups Amp CLI integration settings including upstream routing,\n// optional overrides, management route restrictions, and model fallback mappings.\ntype AmpCode struct {\n\t// UpstreamURL defines the upstream Amp control plane used for non-provider calls.\n\tUpstreamURL string `yaml:\"upstream-url\" json:\"upstream-url\"`\n\n\t// UpstreamAPIKey optionally overrides the Authorization header when proxying Amp upstream calls.\n\tUpstreamAPIKey string `yaml:\"upstream-api-key\" json:\"upstream-api-key\"`\n\n\t// UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys.\n\t// When a client authenticates with a key that matches an entry, that upstream key is used.\n\t// If no match is found, falls back to UpstreamAPIKey (default behavior).\n\tUpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:\"upstream-api-keys,omitempty\" json:\"upstream-api-keys,omitempty\"`\n\n\t// RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)\n\t// to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by\n\t// browser attacks and remote access to management endpoints. Default: false (API key auth is sufficient).\n\tRestrictManagementToLocalhost bool `yaml:\"restrict-management-to-localhost\" json:\"restrict-management-to-localhost\"`\n\n\t// ModelMappings defines model name mappings for Amp CLI requests.\n\t// When Amp requests a model that isn't available locally, these mappings\n\t// allow routing to an alternative model that IS available.\n\tModelMappings []AmpModelMapping `yaml:\"model-mappings\" json:\"model-mappings\"`\n\n\t// ForceModelMappings when true, model mappings take precedence over local API keys.\n\t// When false (default), local API keys are used first if available.\n\tForceModelMappings bool `yaml:\"force-model-mappings\" json:\"force-model-mappings\"`\n}\n\n// AmpUpstreamAPIKeyEntry maps a set of client API keys to a specific upstream API key.\n// When a request is authenticated with one of the APIKeys, the corresponding UpstreamAPIKey\n// is used for the upstream Amp request.\ntype AmpUpstreamAPIKeyEntry struct {\n\t// UpstreamAPIKey is the API key to use when proxying to the Amp upstream.\n\tUpstreamAPIKey string `yaml:\"upstream-api-key\" json:\"upstream-api-key\"`\n\n\t// APIKeys are the client API keys (from top-level api-keys) that map to this upstream key.\n\tAPIKeys []string `yaml:\"api-keys\" json:\"api-keys\"`\n}\n\n// PayloadConfig defines default and override parameter rules applied to provider payloads.\ntype PayloadConfig struct {\n\t// Default defines rules that only set parameters when they are missing in the payload.\n\tDefault []PayloadRule `yaml:\"default\" json:\"default\"`\n\t// DefaultRaw defines rules that set raw JSON values only when they are missing.\n\tDefaultRaw []PayloadRule `yaml:\"default-raw\" json:\"default-raw\"`\n\t// Override defines rules that always set parameters, overwriting any existing values.\n\tOverride []PayloadRule `yaml:\"override\" json:\"override\"`\n\t// OverrideRaw defines rules that always set raw JSON values, overwriting any existing values.\n\tOverrideRaw []PayloadRule `yaml:\"override-raw\" json:\"override-raw\"`\n\t// Filter defines rules that remove parameters from the payload by JSON path.\n\tFilter []PayloadFilterRule `yaml:\"filter\" json:\"filter\"`\n}\n\n// PayloadFilterRule describes a rule to remove specific JSON paths from matching model payloads.\ntype PayloadFilterRule struct {\n\t// Models lists model entries with name pattern and protocol constraint.\n\tModels []PayloadModelRule `yaml:\"models\" json:\"models\"`\n\t// Params lists JSON paths (gjson/sjson syntax) to remove from the payload.\n\tParams []string `yaml:\"params\" json:\"params\"`\n}\n\n// PayloadRule describes a single rule targeting a list of models with parameter updates.\ntype PayloadRule struct {\n\t// Models lists model entries with name pattern and protocol constraint.\n\tModels []PayloadModelRule `yaml:\"models\" json:\"models\"`\n\t// Params maps JSON paths (gjson/sjson syntax) to values written into the payload.\n\t// For *-raw rules, values are treated as raw JSON fragments (strings are used as-is).\n\tParams map[string]any `yaml:\"params\" json:\"params\"`\n}\n\n// PayloadModelRule ties a model name pattern to a specific translator protocol.\ntype PayloadModelRule struct {\n\t// Name is the model name or wildcard pattern (e.g., \"gpt-*\", \"*-5\", \"gemini-*-pro\").\n\tName string `yaml:\"name\" json:\"name\"`\n\t// Protocol restricts the rule to a specific translator format (e.g., \"gemini\", \"responses\").\n\tProtocol string `yaml:\"protocol\" json:\"protocol\"`\n}\n\n// CloakConfig configures request cloaking for non-Claude-Code clients.\n// Cloaking disguises API requests to appear as originating from the official Claude Code CLI.\ntype CloakConfig struct {\n\t// Mode controls cloaking behavior: \"auto\" (default), \"always\", or \"never\".\n\t// - \"auto\": cloak only when client is not Claude Code (based on User-Agent)\n\t// - \"always\": always apply cloaking regardless of client\n\t// - \"never\": never apply cloaking\n\tMode string `yaml:\"mode,omitempty\" json:\"mode,omitempty\"`\n\n\t// StrictMode controls how system prompts are handled when cloaking.\n\t// - false (default): prepend Claude Code prompt to user system messages\n\t// - true: strip all user system messages, keep only Claude Code prompt\n\tStrictMode bool `yaml:\"strict-mode,omitempty\" json:\"strict-mode,omitempty\"`\n\n\t// SensitiveWords is a list of words to obfuscate with zero-width characters.\n\t// This can help bypass certain content filters.\n\tSensitiveWords []string `yaml:\"sensitive-words,omitempty\" json:\"sensitive-words,omitempty\"`\n\n\t// CacheUserID controls whether Claude user_id values are cached per API key.\n\t// When false, a fresh random user_id is generated for every request.\n\tCacheUserID *bool `yaml:\"cache-user-id,omitempty\" json:\"cache-user-id,omitempty\"`\n}\n\n// ClaudeKey represents the configuration for a Claude API key,\n// including the API key itself and an optional base URL for the API endpoint.\ntype ClaudeKey struct {\n\t// APIKey is the authentication key for accessing Claude API services.\n\tAPIKey string `yaml:\"api-key\" json:\"api-key\"`\n\n\t// Priority controls selection preference when multiple credentials match.\n\t// Higher values are preferred; defaults to 0.\n\tPriority int `yaml:\"priority,omitempty\" json:\"priority,omitempty\"`\n\n\t// Prefix optionally namespaces models for this credential (e.g., \"teamA/claude-sonnet-4\").\n\tPrefix string `yaml:\"prefix,omitempty\" json:\"prefix,omitempty\"`\n\n\t// BaseURL is the base URL for the Claude API endpoint.\n\t// If empty, the default Claude API URL will be used.\n\tBaseURL string `yaml:\"base-url\" json:\"base-url\"`\n\n\t// ProxyURL overrides the global proxy setting for this API key if provided.\n\tProxyURL string `yaml:\"proxy-url\" json:\"proxy-url\"`\n\n\t// Models defines upstream model names and aliases for request routing.\n\tModels []ClaudeModel `yaml:\"models\" json:\"models\"`\n\n\t// Headers optionally adds extra HTTP headers for requests sent with this key.\n\tHeaders map[string]string `yaml:\"headers,omitempty\" json:\"headers,omitempty\"`\n\n\t// ExcludedModels lists model IDs that should be excluded for this provider.\n\tExcludedModels []string `yaml:\"excluded-models,omitempty\" json:\"excluded-models,omitempty\"`\n\n\t// Cloak configures request cloaking for non-Claude-Code clients.\n\tCloak *CloakConfig `yaml:\"cloak,omitempty\" json:\"cloak,omitempty\"`\n}\n\nfunc (k ClaudeKey) GetAPIKey() string  { return k.APIKey }\nfunc (k ClaudeKey) GetBaseURL() string { return k.BaseURL }\n\n// ClaudeModel describes a mapping between an alias and the actual upstream model name.\ntype ClaudeModel struct {\n\t// Name is the upstream model identifier used when issuing requests.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Alias is the client-facing model name that maps to Name.\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n}\n\nfunc (m ClaudeModel) GetName() string  { return m.Name }\nfunc (m ClaudeModel) GetAlias() string { return m.Alias }\n\n// CodexKey represents the configuration for a Codex API key,\n// including the API key itself and an optional base URL for the API endpoint.\ntype CodexKey struct {\n\t// APIKey is the authentication key for accessing Codex API services.\n\tAPIKey string `yaml:\"api-key\" json:\"api-key\"`\n\n\t// Priority controls selection preference when multiple credentials match.\n\t// Higher values are preferred; defaults to 0.\n\tPriority int `yaml:\"priority,omitempty\" json:\"priority,omitempty\"`\n\n\t// Prefix optionally namespaces models for this credential (e.g., \"teamA/gpt-5-codex\").\n\tPrefix string `yaml:\"prefix,omitempty\" json:\"prefix,omitempty\"`\n\n\t// BaseURL is the base URL for the Codex API endpoint.\n\t// If empty, the default Codex API URL will be used.\n\tBaseURL string `yaml:\"base-url\" json:\"base-url\"`\n\n\t// Websockets enables the Responses API websocket transport for this credential.\n\tWebsockets bool `yaml:\"websockets,omitempty\" json:\"websockets,omitempty\"`\n\n\t// ProxyURL overrides the global proxy setting for this API key if provided.\n\tProxyURL string `yaml:\"proxy-url\" json:\"proxy-url\"`\n\n\t// Models defines upstream model names and aliases for request routing.\n\tModels []CodexModel `yaml:\"models\" json:\"models\"`\n\n\t// Headers optionally adds extra HTTP headers for requests sent with this key.\n\tHeaders map[string]string `yaml:\"headers,omitempty\" json:\"headers,omitempty\"`\n\n\t// ExcludedModels lists model IDs that should be excluded for this provider.\n\tExcludedModels []string `yaml:\"excluded-models,omitempty\" json:\"excluded-models,omitempty\"`\n}\n\nfunc (k CodexKey) GetAPIKey() string  { return k.APIKey }\nfunc (k CodexKey) GetBaseURL() string { return k.BaseURL }\n\n// CodexModel describes a mapping between an alias and the actual upstream model name.\ntype CodexModel struct {\n\t// Name is the upstream model identifier used when issuing requests.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Alias is the client-facing model name that maps to Name.\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n}\n\nfunc (m CodexModel) GetName() string  { return m.Name }\nfunc (m CodexModel) GetAlias() string { return m.Alias }\n\n// GeminiKey represents the configuration for a Gemini API key,\n// including optional overrides for upstream base URL, proxy routing, and headers.\ntype GeminiKey struct {\n\t// APIKey is the authentication key for accessing Gemini API services.\n\tAPIKey string `yaml:\"api-key\" json:\"api-key\"`\n\n\t// Priority controls selection preference when multiple credentials match.\n\t// Higher values are preferred; defaults to 0.\n\tPriority int `yaml:\"priority,omitempty\" json:\"priority,omitempty\"`\n\n\t// Prefix optionally namespaces models for this credential (e.g., \"teamA/gemini-3-pro-preview\").\n\tPrefix string `yaml:\"prefix,omitempty\" json:\"prefix,omitempty\"`\n\n\t// BaseURL optionally overrides the Gemini API endpoint.\n\tBaseURL string `yaml:\"base-url,omitempty\" json:\"base-url,omitempty\"`\n\n\t// ProxyURL optionally overrides the global proxy for this API key.\n\tProxyURL string `yaml:\"proxy-url,omitempty\" json:\"proxy-url,omitempty\"`\n\n\t// Models defines upstream model names and aliases for request routing.\n\tModels []GeminiModel `yaml:\"models,omitempty\" json:\"models,omitempty\"`\n\n\t// Headers optionally adds extra HTTP headers for requests sent with this key.\n\tHeaders map[string]string `yaml:\"headers,omitempty\" json:\"headers,omitempty\"`\n\n\t// ExcludedModels lists model IDs that should be excluded for this provider.\n\tExcludedModels []string `yaml:\"excluded-models,omitempty\" json:\"excluded-models,omitempty\"`\n}\n\nfunc (k GeminiKey) GetAPIKey() string  { return k.APIKey }\nfunc (k GeminiKey) GetBaseURL() string { return k.BaseURL }\n\n// GeminiModel describes a mapping between an alias and the actual upstream model name.\ntype GeminiModel struct {\n\t// Name is the upstream model identifier used when issuing requests.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Alias is the client-facing model name that maps to Name.\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n}\n\nfunc (m GeminiModel) GetName() string  { return m.Name }\nfunc (m GeminiModel) GetAlias() string { return m.Alias }\n\n// OpenAICompatibility represents the configuration for OpenAI API compatibility\n// with external providers, allowing model aliases to be routed through OpenAI API format.\ntype OpenAICompatibility struct {\n\t// Name is the identifier for this OpenAI compatibility configuration.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Priority controls selection preference when multiple providers or credentials match.\n\t// Higher values are preferred; defaults to 0.\n\tPriority int `yaml:\"priority,omitempty\" json:\"priority,omitempty\"`\n\n\t// Prefix optionally namespaces model aliases for this provider (e.g., \"teamA/kimi-k2\").\n\tPrefix string `yaml:\"prefix,omitempty\" json:\"prefix,omitempty\"`\n\n\t// BaseURL is the base URL for the external OpenAI-compatible API endpoint.\n\tBaseURL string `yaml:\"base-url\" json:\"base-url\"`\n\n\t// APIKeyEntries defines API keys with optional per-key proxy configuration.\n\tAPIKeyEntries []OpenAICompatibilityAPIKey `yaml:\"api-key-entries,omitempty\" json:\"api-key-entries,omitempty\"`\n\n\t// Models defines the model configurations including aliases for routing.\n\tModels []OpenAICompatibilityModel `yaml:\"models\" json:\"models\"`\n\n\t// Headers optionally adds extra HTTP headers for requests sent to this provider.\n\tHeaders map[string]string `yaml:\"headers,omitempty\" json:\"headers,omitempty\"`\n}\n\n// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.\ntype OpenAICompatibilityAPIKey struct {\n\t// APIKey is the authentication key for accessing the external API services.\n\tAPIKey string `yaml:\"api-key\" json:\"api-key\"`\n\n\t// ProxyURL overrides the global proxy setting for this API key if provided.\n\tProxyURL string `yaml:\"proxy-url,omitempty\" json:\"proxy-url,omitempty\"`\n}\n\n// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,\n// including the actual model name and its alias for API routing.\ntype OpenAICompatibilityModel struct {\n\t// Name is the actual model name used by the external provider.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Alias is the model name alias that clients will use to reference this model.\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n}\n\nfunc (m OpenAICompatibilityModel) GetName() string  { return m.Name }\nfunc (m OpenAICompatibilityModel) GetAlias() string { return m.Alias }\n\n// LoadConfig reads a YAML configuration file from the given path,\n// unmarshals it into a Config struct, applies environment variable overrides,\n// and returns it.\n//\n// Parameters:\n//   - configFile: The path to the YAML configuration file\n//\n// Returns:\n//   - *Config: The loaded configuration\n//   - error: An error if the configuration could not be loaded\nfunc LoadConfig(configFile string) (*Config, error) {\n\treturn LoadConfigOptional(configFile, false)\n}\n\n// LoadConfigOptional reads YAML from configFile.\n// If optional is true and the file is missing, it returns an empty Config.\n// If optional is true and the file is empty or invalid, it returns an empty Config.\nfunc LoadConfigOptional(configFile string, optional bool) (*Config, error) {\n\t// Read the entire configuration file into memory.\n\tdata, err := os.ReadFile(configFile)\n\tif err != nil {\n\t\tif optional {\n\t\t\tif os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {\n\t\t\t\t// Missing and optional: return empty config (cloud deploy standby).\n\t\t\t\treturn &Config{}, nil\n\t\t\t}\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to read config file: %w\", err)\n\t}\n\n\t// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.\n\tif optional && len(data) == 0 {\n\t\treturn &Config{}, nil\n\t}\n\n\t// Unmarshal the YAML data into the Config struct.\n\tvar cfg Config\n\t// Set defaults before unmarshal so that absent keys keep defaults.\n\tcfg.Host = \"\" // Default empty: binds to all interfaces (IPv4 + IPv6)\n\tcfg.LoggingToFile = false\n\tcfg.LogsMaxTotalSizeMB = 0\n\tcfg.ErrorLogsMaxFiles = 10\n\tcfg.UsageStatisticsEnabled = false\n\tcfg.DisableCooling = false\n\tcfg.Pprof.Enable = false\n\tcfg.Pprof.Addr = DefaultPprofAddr\n\tcfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient\n\tcfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository\n\tif err = yaml.Unmarshal(data, &cfg); err != nil {\n\t\tif optional {\n\t\t\t// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.\n\t\t\treturn &Config{}, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to parse config file: %w\", err)\n\t}\n\n\t// NOTE: Startup legacy key migration is intentionally disabled.\n\t// Reason: avoid mutating config.yaml during server startup.\n\t// Re-enable the block below if automatic startup migration is needed again.\n\t// var legacy legacyConfigData\n\t// if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {\n\t// \tif cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) {\n\t// \t\tcfg.legacyMigrationPending = true\n\t// \t}\n\t// \tif cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) {\n\t// \t\tcfg.legacyMigrationPending = true\n\t// \t}\n\t// \tif cfg.migrateLegacyAmpConfig(&legacy) {\n\t// \t\tcfg.legacyMigrationPending = true\n\t// \t}\n\t// }\n\n\t// Hash remote management key if plaintext is detected (nested)\n\t// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).\n\tif cfg.RemoteManagement.SecretKey != \"\" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {\n\t\thashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)\n\t\tif errHash != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to hash remote management key: %w\", errHash)\n\t\t}\n\t\tcfg.RemoteManagement.SecretKey = hashed\n\n\t\t// Persist the hashed value back to the config file to avoid re-hashing on next startup.\n\t\t// Preserve YAML comments and ordering; update only the nested key.\n\t\t_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{\"remote-management\", \"secret-key\"}, hashed)\n\t}\n\n\tcfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository)\n\tif cfg.RemoteManagement.PanelGitHubRepository == \"\" {\n\t\tcfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository\n\t}\n\n\tcfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)\n\tif cfg.Pprof.Addr == \"\" {\n\t\tcfg.Pprof.Addr = DefaultPprofAddr\n\t}\n\n\tif cfg.LogsMaxTotalSizeMB < 0 {\n\t\tcfg.LogsMaxTotalSizeMB = 0\n\t}\n\n\tif cfg.ErrorLogsMaxFiles < 0 {\n\t\tcfg.ErrorLogsMaxFiles = 10\n\t}\n\n\tif cfg.MaxRetryCredentials < 0 {\n\t\tcfg.MaxRetryCredentials = 0\n\t}\n\n\t// Sanitize Gemini API key configuration and migrate legacy entries.\n\tcfg.SanitizeGeminiKeys()\n\n\t// Sanitize Vertex-compatible API keys.\n\tcfg.SanitizeVertexCompatKeys()\n\n\t// Sanitize Codex keys: drop entries without base-url\n\tcfg.SanitizeCodexKeys()\n\n\t// Sanitize Codex header defaults.\n\tcfg.SanitizeCodexHeaderDefaults()\n\n\t// Sanitize Claude key headers\n\tcfg.SanitizeClaudeKeys()\n\n\t// Sanitize OpenAI compatibility providers: drop entries without base-url\n\tcfg.SanitizeOpenAICompatibility()\n\n\t// Normalize OAuth provider model exclusion map.\n\tcfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)\n\n\t// Normalize global OAuth model name aliases.\n\tcfg.SanitizeOAuthModelAlias()\n\n\t// Validate raw payload rules and drop invalid entries.\n\tcfg.SanitizePayloadRules()\n\n\t// NOTE: Legacy migration persistence is intentionally disabled together with\n\t// startup legacy migration to keep startup read-only for config.yaml.\n\t// Re-enable the block below if automatic startup migration is needed again.\n\t// if cfg.legacyMigrationPending {\n\t// \tfmt.Println(\"Detected legacy configuration keys, attempting to persist the normalized config...\")\n\t// \tif !optional && configFile != \"\" {\n\t// \t\tif err := SaveConfigPreserveComments(configFile, &cfg); err != nil {\n\t// \t\t\treturn nil, fmt.Errorf(\"failed to persist migrated legacy config: %w\", err)\n\t// \t\t}\n\t// \t\tfmt.Println(\"Legacy configuration normalized and persisted.\")\n\t// \t} else {\n\t// \t\tfmt.Println(\"Legacy configuration normalized in memory; persistence skipped.\")\n\t// \t}\n\t// }\n\n\t// Return the populated configuration struct.\n\treturn &cfg, nil\n}\n\n// SanitizePayloadRules validates raw JSON payload rule params and drops invalid rules.\nfunc (cfg *Config) SanitizePayloadRules() {\n\tif cfg == nil {\n\t\treturn\n\t}\n\tcfg.Payload.DefaultRaw = sanitizePayloadRawRules(cfg.Payload.DefaultRaw, \"default-raw\")\n\tcfg.Payload.OverrideRaw = sanitizePayloadRawRules(cfg.Payload.OverrideRaw, \"override-raw\")\n}\n\nfunc sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule {\n\tif len(rules) == 0 {\n\t\treturn rules\n\t}\n\tout := make([]PayloadRule, 0, len(rules))\n\tfor i := range rules {\n\t\trule := rules[i]\n\t\tif len(rule.Params) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tinvalid := false\n\t\tfor path, value := range rule.Params {\n\t\t\traw, ok := payloadRawString(value)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttrimmed := bytes.TrimSpace(raw)\n\t\t\tif len(trimmed) == 0 || !json.Valid(trimmed) {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"section\":    section,\n\t\t\t\t\t\"rule_index\": i + 1,\n\t\t\t\t\t\"param\":      path,\n\t\t\t\t}).Warn(\"payload rule dropped: invalid raw JSON\")\n\t\t\t\tinvalid = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif invalid {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, rule)\n\t}\n\treturn out\n}\n\nfunc payloadRawString(value any) ([]byte, bool) {\n\tswitch typed := value.(type) {\n\tcase string:\n\t\treturn []byte(typed), true\n\tcase []byte:\n\t\treturn typed, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\n// SanitizeCodexHeaderDefaults trims surrounding whitespace from the\n// configured Codex header fallback values.\nfunc (cfg *Config) SanitizeCodexHeaderDefaults() {\n\tif cfg == nil {\n\t\treturn\n\t}\n\tcfg.CodexHeaderDefaults.UserAgent = strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent)\n\tcfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures)\n}\n\n// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.\n// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,\n// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.\nfunc (cfg *Config) SanitizeOAuthModelAlias() {\n\tif cfg == nil || len(cfg.OAuthModelAlias) == 0 {\n\t\treturn\n\t}\n\tout := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))\n\tfor rawChannel, aliases := range cfg.OAuthModelAlias {\n\t\tchannel := strings.ToLower(strings.TrimSpace(rawChannel))\n\t\tif channel == \"\" || len(aliases) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tseenAlias := make(map[string]struct{}, len(aliases))\n\t\tclean := make([]OAuthModelAlias, 0, len(aliases))\n\t\tfor _, entry := range aliases {\n\t\t\tname := strings.TrimSpace(entry.Name)\n\t\t\talias := strings.TrimSpace(entry.Alias)\n\t\t\tif name == \"\" || alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.EqualFold(name, alias) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\taliasKey := strings.ToLower(alias)\n\t\t\tif _, ok := seenAlias[aliasKey]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenAlias[aliasKey] = struct{}{}\n\t\t\tclean = append(clean, OAuthModelAlias{Name: name, Alias: alias, Fork: entry.Fork})\n\t\t}\n\t\tif len(clean) > 0 {\n\t\t\tout[channel] = clean\n\t\t}\n\t}\n\tcfg.OAuthModelAlias = out\n}\n\n// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are\n// not actionable, specifically those missing a BaseURL. It trims whitespace before\n// evaluation and preserves the relative order of remaining entries.\nfunc (cfg *Config) SanitizeOpenAICompatibility() {\n\tif cfg == nil || len(cfg.OpenAICompatibility) == 0 {\n\t\treturn\n\t}\n\tout := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))\n\tfor i := range cfg.OpenAICompatibility {\n\t\te := cfg.OpenAICompatibility[i]\n\t\te.Name = strings.TrimSpace(e.Name)\n\t\te.Prefix = normalizeModelPrefix(e.Prefix)\n\t\te.BaseURL = strings.TrimSpace(e.BaseURL)\n\t\te.Headers = NormalizeHeaders(e.Headers)\n\t\tif e.BaseURL == \"\" {\n\t\t\t// Skip providers with no base-url; treated as removed\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, e)\n\t}\n\tcfg.OpenAICompatibility = out\n}\n\n// SanitizeCodexKeys removes Codex API key entries missing a BaseURL.\n// It trims whitespace and preserves order for remaining entries.\nfunc (cfg *Config) SanitizeCodexKeys() {\n\tif cfg == nil || len(cfg.CodexKey) == 0 {\n\t\treturn\n\t}\n\tout := make([]CodexKey, 0, len(cfg.CodexKey))\n\tfor i := range cfg.CodexKey {\n\t\te := cfg.CodexKey[i]\n\t\te.Prefix = normalizeModelPrefix(e.Prefix)\n\t\te.BaseURL = strings.TrimSpace(e.BaseURL)\n\t\te.Headers = NormalizeHeaders(e.Headers)\n\t\te.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)\n\t\tif e.BaseURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, e)\n\t}\n\tcfg.CodexKey = out\n}\n\n// SanitizeClaudeKeys normalizes headers for Claude credentials.\nfunc (cfg *Config) SanitizeClaudeKeys() {\n\tif cfg == nil || len(cfg.ClaudeKey) == 0 {\n\t\treturn\n\t}\n\tfor i := range cfg.ClaudeKey {\n\t\tentry := &cfg.ClaudeKey[i]\n\t\tentry.Prefix = normalizeModelPrefix(entry.Prefix)\n\t\tentry.Headers = NormalizeHeaders(entry.Headers)\n\t\tentry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)\n\t}\n}\n\n// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.\nfunc (cfg *Config) SanitizeGeminiKeys() {\n\tif cfg == nil {\n\t\treturn\n\t}\n\n\tseen := make(map[string]struct{}, len(cfg.GeminiKey))\n\tout := cfg.GeminiKey[:0]\n\tfor i := range cfg.GeminiKey {\n\t\tentry := cfg.GeminiKey[i]\n\t\tentry.APIKey = strings.TrimSpace(entry.APIKey)\n\t\tif entry.APIKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentry.Prefix = normalizeModelPrefix(entry.Prefix)\n\t\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\t\tentry.ProxyURL = strings.TrimSpace(entry.ProxyURL)\n\t\tentry.Headers = NormalizeHeaders(entry.Headers)\n\t\tentry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)\n\t\tif _, exists := seen[entry.APIKey]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[entry.APIKey] = struct{}{}\n\t\tout = append(out, entry)\n\t}\n\tcfg.GeminiKey = out\n}\n\nfunc normalizeModelPrefix(prefix string) string {\n\ttrimmed := strings.TrimSpace(prefix)\n\ttrimmed = strings.Trim(trimmed, \"/\")\n\tif trimmed == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.Contains(trimmed, \"/\") {\n\t\treturn \"\"\n\t}\n\treturn trimmed\n}\n\n// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.\nfunc looksLikeBcrypt(s string) bool {\n\treturn len(s) > 4 && (s[:4] == \"$2a$\" || s[:4] == \"$2b$\" || s[:4] == \"$2y$\")\n}\n\n// NormalizeHeaders trims header keys and values and removes empty pairs.\nfunc NormalizeHeaders(headers map[string]string) map[string]string {\n\tif len(headers) == 0 {\n\t\treturn nil\n\t}\n\tclean := make(map[string]string, len(headers))\n\tfor k, v := range headers {\n\t\tkey := strings.TrimSpace(k)\n\t\tval := strings.TrimSpace(v)\n\t\tif key == \"\" || val == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tclean[key] = val\n\t}\n\tif len(clean) == 0 {\n\t\treturn nil\n\t}\n\treturn clean\n}\n\n// NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns.\n// It preserves the order of first occurrences and drops empty entries.\nfunc NormalizeExcludedModels(models []string) []string {\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tseen := make(map[string]struct{}, len(models))\n\tout := make([]string, 0, len(models))\n\tfor _, raw := range models {\n\t\ttrimmed := strings.ToLower(strings.TrimSpace(raw))\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[trimmed]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmed] = struct{}{}\n\t\tout = append(out, trimmed)\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys\n// and applying model exclusion normalization to each entry.\nfunc NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string][]string, len(entries))\n\tfor provider, models := range entries {\n\t\tkey := strings.ToLower(strings.TrimSpace(provider))\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized := NormalizeExcludedModels(models)\n\t\tif len(normalized) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tout[key] = normalized\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// hashSecret hashes the given secret using bcrypt.\nfunc hashSecret(secret string) (string, error) {\n\t// Use default cost for simplicity.\n\thashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(hashedBytes), nil\n}\n\n// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments\n// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.\nfunc SaveConfigPreserveComments(configFile string, cfg *Config) error {\n\tpersistCfg := cfg\n\t// Load original YAML as a node tree to preserve comments and ordering.\n\tdata, err := os.ReadFile(configFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar original yaml.Node\n\tif err = yaml.Unmarshal(data, &original); err != nil {\n\t\treturn err\n\t}\n\tif original.Kind != yaml.DocumentNode || len(original.Content) == 0 {\n\t\treturn fmt.Errorf(\"invalid yaml document structure\")\n\t}\n\tif original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {\n\t\treturn fmt.Errorf(\"expected root mapping node\")\n\t}\n\n\t// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.\n\trendered, err := yaml.Marshal(persistCfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar generated yaml.Node\n\tif err = yaml.Unmarshal(rendered, &generated); err != nil {\n\t\treturn err\n\t}\n\tif generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {\n\t\treturn fmt.Errorf(\"invalid generated yaml structure\")\n\t}\n\tif generated.Content[0].Kind != yaml.MappingNode {\n\t\treturn fmt.Errorf(\"expected generated root mapping node\")\n\t}\n\n\t// Remove deprecated sections before merging back the sanitized config.\n\tremoveLegacyAuthBlock(original.Content[0])\n\tremoveLegacyOpenAICompatAPIKeys(original.Content[0])\n\tremoveLegacyAmpKeys(original.Content[0])\n\tremoveLegacyGenerativeLanguageKeys(original.Content[0])\n\n\tpruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], \"oauth-excluded-models\")\n\tpruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], \"oauth-model-alias\")\n\n\t// Merge generated into original in-place, preserving comments/order of existing nodes.\n\tmergeMappingPreserve(original.Content[0], generated.Content[0])\n\tnormalizeCollectionNodeStyles(original.Content[0])\n\n\t// Write back.\n\tf, err := os.Create(configFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = f.Close() }()\n\tvar buf bytes.Buffer\n\tenc := yaml.NewEncoder(&buf)\n\tenc.SetIndent(2)\n\tif err = enc.Encode(&original); err != nil {\n\t\t_ = enc.Close()\n\t\treturn err\n\t}\n\tif err = enc.Close(); err != nil {\n\t\treturn err\n\t}\n\tdata = NormalizeCommentIndentation(buf.Bytes())\n\t_, err = f.Write(data)\n\treturn err\n}\n\n// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like [\"a\",\"b\"]\n// while preserving comments and positions.\nfunc SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {\n\tdata, err := os.ReadFile(configFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar root yaml.Node\n\tif err = yaml.Unmarshal(data, &root); err != nil {\n\t\treturn err\n\t}\n\tif root.Kind != yaml.DocumentNode || len(root.Content) == 0 {\n\t\treturn fmt.Errorf(\"invalid yaml document structure\")\n\t}\n\tnode := root.Content[0]\n\t// descend mapping nodes following path\n\tfor i, key := range path {\n\t\tif i == len(path)-1 {\n\t\t\t// set final scalar\n\t\t\tv := getOrCreateMapValue(node, key)\n\t\t\tv.Kind = yaml.ScalarNode\n\t\t\tv.Tag = \"!!str\"\n\t\t\tv.Value = value\n\t\t} else {\n\t\t\tnext := getOrCreateMapValue(node, key)\n\t\t\tif next.Kind != yaml.MappingNode {\n\t\t\t\tnext.Kind = yaml.MappingNode\n\t\t\t\tnext.Tag = \"!!map\"\n\t\t\t}\n\t\t\tnode = next\n\t\t}\n\t}\n\tf, err := os.Create(configFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = f.Close() }()\n\tvar buf bytes.Buffer\n\tenc := yaml.NewEncoder(&buf)\n\tenc.SetIndent(2)\n\tif err = enc.Encode(&root); err != nil {\n\t\t_ = enc.Close()\n\t\treturn err\n\t}\n\tif err = enc.Close(); err != nil {\n\t\treturn err\n\t}\n\tdata = NormalizeCommentIndentation(buf.Bytes())\n\t_, err = f.Write(data)\n\treturn err\n}\n\n// NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.\nfunc NormalizeCommentIndentation(data []byte) []byte {\n\tlines := bytes.Split(data, []byte(\"\\n\"))\n\tchanged := false\n\tfor i, line := range lines {\n\t\ttrimmed := bytes.TrimLeft(line, \" \\t\")\n\t\tif len(trimmed) == 0 || trimmed[0] != '#' {\n\t\t\tcontinue\n\t\t}\n\t\tif len(trimmed) == len(line) {\n\t\t\tcontinue\n\t\t}\n\t\tlines[i] = append([]byte(nil), trimmed...)\n\t\tchanged = true\n\t}\n\tif !changed {\n\t\treturn data\n\t}\n\treturn bytes.Join(lines, []byte(\"\\n\"))\n}\n\n// getOrCreateMapValue finds the value node for a given key in a mapping node.\n// If not found, it appends a new key/value pair and returns the new value node.\nfunc getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {\n\tif mapNode.Kind != yaml.MappingNode {\n\t\tmapNode.Kind = yaml.MappingNode\n\t\tmapNode.Tag = \"!!map\"\n\t\tmapNode.Content = nil\n\t}\n\tfor i := 0; i+1 < len(mapNode.Content); i += 2 {\n\t\tk := mapNode.Content[i]\n\t\tif k.Value == key {\n\t\t\treturn mapNode.Content[i+1]\n\t\t}\n\t}\n\t// append new key/value\n\tmapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: key})\n\tval := &yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: \"\"}\n\tmapNode.Content = append(mapNode.Content, val)\n\treturn val\n}\n\n// mergeMappingPreserve merges keys from src into dst mapping node while preserving\n// key order and comments of existing keys in dst. New keys are only added if their\n// value is non-zero and not a known default to avoid polluting the config with defaults.\nfunc mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {\n\tvar currentPath []string\n\tif len(path) > 0 {\n\t\tcurrentPath = path[0]\n\t}\n\n\tif dst == nil || src == nil {\n\t\treturn\n\t}\n\tif dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {\n\t\t// If kinds do not match, prefer replacing dst with src semantics in-place\n\t\t// but keep dst node object to preserve any attached comments at the parent level.\n\t\tcopyNodeShallow(dst, src)\n\t\treturn\n\t}\n\tfor i := 0; i+1 < len(src.Content); i += 2 {\n\t\tsk := src.Content[i]\n\t\tsv := src.Content[i+1]\n\t\tidx := findMapKeyIndex(dst, sk.Value)\n\t\tchildPath := appendPath(currentPath, sk.Value)\n\t\tif idx >= 0 {\n\t\t\t// Merge into existing value node (always update, even to zero values)\n\t\t\tdv := dst.Content[idx+1]\n\t\t\tmergeNodePreserve(dv, sv, childPath)\n\t\t} else {\n\t\t\t// New key: only add if value is non-zero and not a known default\n\t\t\tcandidate := deepCopyNode(sv)\n\t\t\tpruneKnownDefaultsInNewNode(childPath, candidate)\n\t\t\tif isKnownDefaultValue(childPath, candidate) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdst.Content = append(dst.Content, deepCopyNode(sk), candidate)\n\t\t}\n\t}\n}\n\n// mergeNodePreserve merges src into dst for scalars, mappings and sequences while\n// reusing destination nodes to keep comments and anchors. For sequences, it updates\n// in-place by index.\nfunc mergeNodePreserve(dst, src *yaml.Node, path ...[]string) {\n\tvar currentPath []string\n\tif len(path) > 0 {\n\t\tcurrentPath = path[0]\n\t}\n\n\tif dst == nil || src == nil {\n\t\treturn\n\t}\n\tswitch src.Kind {\n\tcase yaml.MappingNode:\n\t\tif dst.Kind != yaml.MappingNode {\n\t\t\tcopyNodeShallow(dst, src)\n\t\t}\n\t\tmergeMappingPreserve(dst, src, currentPath)\n\tcase yaml.SequenceNode:\n\t\t// Preserve explicit null style if dst was null and src is empty sequence\n\t\tif dst.Kind == yaml.ScalarNode && dst.Tag == \"!!null\" && len(src.Content) == 0 {\n\t\t\t// Keep as null to preserve original style\n\t\t\treturn\n\t\t}\n\t\tif dst.Kind != yaml.SequenceNode {\n\t\t\tdst.Kind = yaml.SequenceNode\n\t\t\tdst.Tag = \"!!seq\"\n\t\t\tdst.Content = nil\n\t\t}\n\t\treorderSequenceForMerge(dst, src)\n\t\t// Update elements in place\n\t\tminContent := len(dst.Content)\n\t\tif len(src.Content) < minContent {\n\t\t\tminContent = len(src.Content)\n\t\t}\n\t\tfor i := 0; i < minContent; i++ {\n\t\t\tif dst.Content[i] == nil {\n\t\t\t\tdst.Content[i] = deepCopyNode(src.Content[i])\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmergeNodePreserve(dst.Content[i], src.Content[i], currentPath)\n\t\t\tif dst.Content[i] != nil && src.Content[i] != nil &&\n\t\t\t\tdst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {\n\t\t\t\tpruneMissingMapKeys(dst.Content[i], src.Content[i])\n\t\t\t}\n\t\t}\n\t\t// Append any extra items from src\n\t\tfor i := len(dst.Content); i < len(src.Content); i++ {\n\t\t\tdst.Content = append(dst.Content, deepCopyNode(src.Content[i]))\n\t\t}\n\t\t// Truncate if dst has extra items not in src\n\t\tif len(src.Content) < len(dst.Content) {\n\t\t\tdst.Content = dst.Content[:len(src.Content)]\n\t\t}\n\tcase yaml.ScalarNode, yaml.AliasNode:\n\t\t// For scalars, update Tag and Value but keep Style from dst to preserve quoting\n\t\tdst.Kind = src.Kind\n\t\tdst.Tag = src.Tag\n\t\tdst.Value = src.Value\n\t\t// Keep dst.Style as-is intentionally\n\tcase 0:\n\t\t// Unknown/empty kind; do nothing\n\tdefault:\n\t\t// Fallback: replace shallowly\n\t\tcopyNodeShallow(dst, src)\n\t}\n}\n\n// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).\n// Returns -1 when not found.\nfunc findMapKeyIndex(mapNode *yaml.Node, key string) int {\n\tif mapNode == nil || mapNode.Kind != yaml.MappingNode {\n\t\treturn -1\n\t}\n\tfor i := 0; i+1 < len(mapNode.Content); i += 2 {\n\t\tif mapNode.Content[i] != nil && mapNode.Content[i].Value == key {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\n// appendPath appends a key to the path, returning a new slice to avoid modifying the original.\nfunc appendPath(path []string, key string) []string {\n\tif len(path) == 0 {\n\t\treturn []string{key}\n\t}\n\tnewPath := make([]string, len(path)+1)\n\tcopy(newPath, path)\n\tnewPath[len(path)] = key\n\treturn newPath\n}\n\n// isKnownDefaultValue returns true if the given node at the specified path\n// represents a known default value that should not be written to the config file.\n// This prevents non-zero defaults from polluting the config.\nfunc isKnownDefaultValue(path []string, node *yaml.Node) bool {\n\t// First check if it's a zero value\n\tif isZeroValueNode(node) {\n\t\treturn true\n\t}\n\n\t// Match known non-zero defaults by exact dotted path.\n\tif len(path) == 0 {\n\t\treturn false\n\t}\n\n\tfullPath := strings.Join(path, \".\")\n\n\t// Check string defaults\n\tif node.Kind == yaml.ScalarNode && node.Tag == \"!!str\" {\n\t\tswitch fullPath {\n\t\tcase \"pprof.addr\":\n\t\t\treturn node.Value == DefaultPprofAddr\n\t\tcase \"remote-management.panel-github-repository\":\n\t\t\treturn node.Value == DefaultPanelGitHubRepository\n\t\tcase \"routing.strategy\":\n\t\t\treturn node.Value == \"round-robin\"\n\t\t}\n\t}\n\n\t// Check integer defaults\n\tif node.Kind == yaml.ScalarNode && node.Tag == \"!!int\" {\n\t\tswitch fullPath {\n\t\tcase \"error-logs-max-files\":\n\t\t\treturn node.Value == \"10\"\n\t\t}\n\t}\n\n\treturn false\n}\n\n// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node\n// before it is appended into the destination YAML tree.\nfunc pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) {\n\tif node == nil {\n\t\treturn\n\t}\n\n\tswitch node.Kind {\n\tcase yaml.MappingNode:\n\t\tfiltered := make([]*yaml.Node, 0, len(node.Content))\n\t\tfor i := 0; i+1 < len(node.Content); i += 2 {\n\t\t\tkeyNode := node.Content[i]\n\t\t\tvalueNode := node.Content[i+1]\n\t\t\tif keyNode == nil || valueNode == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tchildPath := appendPath(path, keyNode.Value)\n\t\t\tif isKnownDefaultValue(childPath, valueNode) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpruneKnownDefaultsInNewNode(childPath, valueNode)\n\t\t\tif (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) &&\n\t\t\t\tlen(valueNode.Content) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfiltered = append(filtered, keyNode, valueNode)\n\t\t}\n\t\tnode.Content = filtered\n\tcase yaml.SequenceNode:\n\t\tfor _, child := range node.Content {\n\t\t\tpruneKnownDefaultsInNewNode(path, child)\n\t\t}\n\t}\n}\n\n// isZeroValueNode returns true if the YAML node represents a zero/default value\n// that should not be written as a new key to preserve config cleanliness.\n// For mappings and sequences, recursively checks if all children are zero values.\nfunc isZeroValueNode(node *yaml.Node) bool {\n\tif node == nil {\n\t\treturn true\n\t}\n\tswitch node.Kind {\n\tcase yaml.ScalarNode:\n\t\tswitch node.Tag {\n\t\tcase \"!!bool\":\n\t\t\treturn node.Value == \"false\"\n\t\tcase \"!!int\", \"!!float\":\n\t\t\treturn node.Value == \"0\" || node.Value == \"0.0\"\n\t\tcase \"!!str\":\n\t\t\treturn node.Value == \"\"\n\t\tcase \"!!null\":\n\t\t\treturn true\n\t\t}\n\tcase yaml.SequenceNode:\n\t\tif len(node.Content) == 0 {\n\t\t\treturn true\n\t\t}\n\t\t// Check if all elements are zero values\n\t\tfor _, child := range node.Content {\n\t\t\tif !isZeroValueNode(child) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase yaml.MappingNode:\n\t\tif len(node.Content) == 0 {\n\t\t\treturn true\n\t\t}\n\t\t// Check if all values are zero values (values are at odd indices)\n\t\tfor i := 1; i < len(node.Content); i += 2 {\n\t\t\tif !isZeroValueNode(node.Content[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\treturn false\n}\n\n// deepCopyNode creates a deep copy of a yaml.Node graph.\nfunc deepCopyNode(n *yaml.Node) *yaml.Node {\n\tif n == nil {\n\t\treturn nil\n\t}\n\tcp := *n\n\tif len(n.Content) > 0 {\n\t\tcp.Content = make([]*yaml.Node, len(n.Content))\n\t\tfor i := range n.Content {\n\t\t\tcp.Content[i] = deepCopyNode(n.Content[i])\n\t\t}\n\t}\n\treturn &cp\n}\n\n// copyNodeShallow copies type/tag/value and resets content to match src, but\n// keeps the same destination node pointer to preserve parent relations/comments.\nfunc copyNodeShallow(dst, src *yaml.Node) {\n\tif dst == nil || src == nil {\n\t\treturn\n\t}\n\tdst.Kind = src.Kind\n\tdst.Tag = src.Tag\n\tdst.Value = src.Value\n\t// Replace content with deep copy from src\n\tif len(src.Content) > 0 {\n\t\tdst.Content = make([]*yaml.Node, len(src.Content))\n\t\tfor i := range src.Content {\n\t\t\tdst.Content[i] = deepCopyNode(src.Content[i])\n\t\t}\n\t} else {\n\t\tdst.Content = nil\n\t}\n}\n\nfunc reorderSequenceForMerge(dst, src *yaml.Node) {\n\tif dst == nil || src == nil {\n\t\treturn\n\t}\n\tif len(dst.Content) == 0 {\n\t\treturn\n\t}\n\tif len(src.Content) == 0 {\n\t\treturn\n\t}\n\toriginal := append([]*yaml.Node(nil), dst.Content...)\n\tused := make([]bool, len(original))\n\tordered := make([]*yaml.Node, len(src.Content))\n\tfor i := range src.Content {\n\t\tif idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 {\n\t\t\tordered[i] = original[idx]\n\t\t\tused[idx] = true\n\t\t}\n\t}\n\tdst.Content = ordered\n}\n\nfunc matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int {\n\tif target == nil {\n\t\treturn -1\n\t}\n\tswitch target.Kind {\n\tcase yaml.MappingNode:\n\t\tid := sequenceElementIdentity(target)\n\t\tif id != \"\" {\n\t\t\tfor i := range original {\n\t\t\t\tif used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif sequenceElementIdentity(original[i]) == id {\n\t\t\t\t\treturn i\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase yaml.ScalarNode:\n\t\tval := strings.TrimSpace(target.Value)\n\t\tif val != \"\" {\n\t\t\tfor i := range original {\n\t\t\t\tif used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif strings.TrimSpace(original[i].Value) == val {\n\t\t\t\t\treturn i\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tdefault:\n\t}\n\t// Fallback to structural equality to preserve nodes lacking explicit identifiers.\n\tfor i := range original {\n\t\tif used[i] || original[i] == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif nodesStructurallyEqual(original[i], target) {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\nfunc sequenceElementIdentity(node *yaml.Node) string {\n\tif node == nil || node.Kind != yaml.MappingNode {\n\t\treturn \"\"\n\t}\n\tidentityKeys := []string{\"id\", \"name\", \"alias\", \"api-key\", \"api_key\", \"apikey\", \"key\", \"provider\", \"model\"}\n\tfor _, k := range identityKeys {\n\t\tif v := mappingScalarValue(node, k); v != \"\" {\n\t\t\treturn k + \"=\" + v\n\t\t}\n\t}\n\tfor i := 0; i+1 < len(node.Content); i += 2 {\n\t\tkeyNode := node.Content[i]\n\t\tvalNode := node.Content[i+1]\n\t\tif keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {\n\t\t\tcontinue\n\t\t}\n\t\tval := strings.TrimSpace(valNode.Value)\n\t\tif val != \"\" {\n\t\t\treturn strings.ToLower(strings.TrimSpace(keyNode.Value)) + \"=\" + val\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc mappingScalarValue(node *yaml.Node, key string) string {\n\tif node == nil || node.Kind != yaml.MappingNode {\n\t\treturn \"\"\n\t}\n\tlowerKey := strings.ToLower(key)\n\tfor i := 0; i+1 < len(node.Content); i += 2 {\n\t\tkeyNode := node.Content[i]\n\t\tvalNode := node.Content[i+1]\n\t\tif keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey {\n\t\t\treturn strings.TrimSpace(valNode.Value)\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nodesStructurallyEqual(a, b *yaml.Node) bool {\n\tif a == nil || b == nil {\n\t\treturn a == b\n\t}\n\tif a.Kind != b.Kind {\n\t\treturn false\n\t}\n\tswitch a.Kind {\n\tcase yaml.MappingNode:\n\t\tif len(a.Content) != len(b.Content) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := 0; i+1 < len(a.Content); i += 2 {\n\t\t\tif !nodesStructurallyEqual(a.Content[i], b.Content[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase yaml.SequenceNode:\n\t\tif len(a.Content) != len(b.Content) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := range a.Content {\n\t\t\tif !nodesStructurallyEqual(a.Content[i], b.Content[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase yaml.ScalarNode:\n\t\treturn strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)\n\tcase yaml.AliasNode:\n\t\treturn nodesStructurallyEqual(a.Alias, b.Alias)\n\tdefault:\n\t\treturn strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)\n\t}\n}\n\nfunc removeMapKey(mapNode *yaml.Node, key string) {\n\tif mapNode == nil || mapNode.Kind != yaml.MappingNode || key == \"\" {\n\t\treturn\n\t}\n\tfor i := 0; i+1 < len(mapNode.Content); i += 2 {\n\t\tif mapNode.Content[i] != nil && mapNode.Content[i].Value == key {\n\t\t\tmapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {\n\tif key == \"\" || dstRoot == nil || srcRoot == nil {\n\t\treturn\n\t}\n\tif dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tdstIdx := findMapKeyIndex(dstRoot, key)\n\tif dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) {\n\t\treturn\n\t}\n\tsrcIdx := findMapKeyIndex(srcRoot, key)\n\tif srcIdx < 0 {\n\t\t// Keep an explicit empty mapping for oauth-model-alias when it was previously present.\n\t\t// When users delete the last channel from oauth-model-alias via the management API,\n\t\t// we want that deletion to persist across hot reloads and restarts.\n\t\tif key == \"oauth-model-alias\" {\n\t\t\tdstRoot.Content[dstIdx+1] = &yaml.Node{Kind: yaml.MappingNode, Tag: \"!!map\"}\n\t\t\treturn\n\t\t}\n\t\tremoveMapKey(dstRoot, key)\n\t\treturn\n\t}\n\tif srcIdx+1 >= len(srcRoot.Content) {\n\t\treturn\n\t}\n\tsrcVal := srcRoot.Content[srcIdx+1]\n\tdstVal := dstRoot.Content[dstIdx+1]\n\tif srcVal == nil {\n\t\tdstRoot.Content[dstIdx+1] = nil\n\t\treturn\n\t}\n\tif srcVal.Kind != yaml.MappingNode {\n\t\tdstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)\n\t\treturn\n\t}\n\tif dstVal == nil || dstVal.Kind != yaml.MappingNode {\n\t\tdstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)\n\t\treturn\n\t}\n\tpruneMissingMapKeys(dstVal, srcVal)\n}\n\nfunc pruneMissingMapKeys(dstMap, srcMap *yaml.Node) {\n\tif dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tkeep := make(map[string]struct{}, len(srcMap.Content)/2)\n\tfor i := 0; i+1 < len(srcMap.Content); i += 2 {\n\t\tkeyNode := srcMap.Content[i]\n\t\tif keyNode == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.TrimSpace(keyNode.Value)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkeep[key] = struct{}{}\n\t}\n\tfor i := 0; i+1 < len(dstMap.Content); {\n\t\tkeyNode := dstMap.Content[i]\n\t\tif keyNode == nil {\n\t\t\ti += 2\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.TrimSpace(keyNode.Value)\n\t\tif _, ok := keep[key]; !ok {\n\t\t\tdstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...)\n\t\t\tcontinue\n\t\t}\n\t\ti += 2\n\t}\n}\n\n// normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping\n// lists and maps readable. Empty sequences retain flow style ([]) so empty list markers\n// remain compact.\nfunc normalizeCollectionNodeStyles(node *yaml.Node) {\n\tif node == nil {\n\t\treturn\n\t}\n\tswitch node.Kind {\n\tcase yaml.MappingNode:\n\t\tnode.Style = 0\n\t\tfor i := range node.Content {\n\t\t\tnormalizeCollectionNodeStyles(node.Content[i])\n\t\t}\n\tcase yaml.SequenceNode:\n\t\tif len(node.Content) == 0 {\n\t\t\tnode.Style = yaml.FlowStyle\n\t\t} else {\n\t\t\tnode.Style = 0\n\t\t}\n\t\tfor i := range node.Content {\n\t\t\tnormalizeCollectionNodeStyles(node.Content[i])\n\t\t}\n\tdefault:\n\t\t// Scalars keep their existing style to preserve quoting\n\t}\n}\n\n// Legacy migration helpers (move deprecated config keys into structured fields).\ntype legacyConfigData struct {\n\tLegacyGeminiKeys      []string                    `yaml:\"generative-language-api-key\"`\n\tOpenAICompat          []legacyOpenAICompatibility `yaml:\"openai-compatibility\"`\n\tAmpUpstreamURL        string                      `yaml:\"amp-upstream-url\"`\n\tAmpUpstreamAPIKey     string                      `yaml:\"amp-upstream-api-key\"`\n\tAmpRestrictManagement *bool                       `yaml:\"amp-restrict-management-to-localhost\"`\n\tAmpModelMappings      []AmpModelMapping           `yaml:\"amp-model-mappings\"`\n}\n\ntype legacyOpenAICompatibility struct {\n\tName    string   `yaml:\"name\"`\n\tBaseURL string   `yaml:\"base-url\"`\n\tAPIKeys []string `yaml:\"api-keys\"`\n}\n\nfunc (cfg *Config) migrateLegacyGeminiKeys(legacy []string) bool {\n\tif cfg == nil || len(legacy) == 0 {\n\t\treturn false\n\t}\n\tchanged := false\n\tseen := make(map[string]struct{}, len(cfg.GeminiKey))\n\tfor i := range cfg.GeminiKey {\n\t\tkey := strings.TrimSpace(cfg.GeminiKey[i].APIKey)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = struct{}{}\n\t}\n\tfor _, raw := range legacy {\n\t\tkey := strings.TrimSpace(raw)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[key]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tcfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key})\n\t\tseen[key] = struct{}{}\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) bool {\n\tif cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor _, legacyEntry := range legacy {\n\t\tif len(legacyEntry.APIKeys) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := findOpenAICompatTarget(cfg.OpenAICompatibility, legacyEntry.Name, legacyEntry.BaseURL)\n\t\tif target == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) {\n\t\t\tchanged = true\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) bool {\n\tif entry == nil || len(keys) == 0 {\n\t\treturn false\n\t}\n\tchanged := false\n\texisting := make(map[string]struct{}, len(entry.APIKeyEntries))\n\tfor i := range entry.APIKeyEntries {\n\t\tkey := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\texisting[key] = struct{}{}\n\t}\n\tfor _, raw := range keys {\n\t\tkey := strings.TrimSpace(raw)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := existing[key]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tentry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key})\n\t\texisting[key] = struct{}{}\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc findOpenAICompatTarget(entries []OpenAICompatibility, legacyName, legacyBase string) *OpenAICompatibility {\n\tnameKey := strings.ToLower(strings.TrimSpace(legacyName))\n\tbaseKey := strings.ToLower(strings.TrimSpace(legacyBase))\n\tif nameKey != \"\" && baseKey != \"\" {\n\t\tfor i := range entries {\n\t\t\tif strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey &&\n\t\t\t\tstrings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {\n\t\t\t\treturn &entries[i]\n\t\t\t}\n\t\t}\n\t}\n\tif baseKey != \"\" {\n\t\tfor i := range entries {\n\t\t\tif strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {\n\t\t\t\treturn &entries[i]\n\t\t\t}\n\t\t}\n\t}\n\tif nameKey != \"\" {\n\t\tfor i := range entries {\n\t\t\tif strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey {\n\t\t\t\treturn &entries[i]\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) bool {\n\tif cfg == nil || legacy == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tif cfg.AmpCode.UpstreamURL == \"\" {\n\t\tif val := strings.TrimSpace(legacy.AmpUpstreamURL); val != \"\" {\n\t\t\tcfg.AmpCode.UpstreamURL = val\n\t\t\tchanged = true\n\t\t}\n\t}\n\tif cfg.AmpCode.UpstreamAPIKey == \"\" {\n\t\tif val := strings.TrimSpace(legacy.AmpUpstreamAPIKey); val != \"\" {\n\t\t\tcfg.AmpCode.UpstreamAPIKey = val\n\t\t\tchanged = true\n\t\t}\n\t}\n\tif legacy.AmpRestrictManagement != nil {\n\t\tcfg.AmpCode.RestrictManagementToLocalhost = *legacy.AmpRestrictManagement\n\t\tchanged = true\n\t}\n\tif len(cfg.AmpCode.ModelMappings) == 0 && len(legacy.AmpModelMappings) > 0 {\n\t\tcfg.AmpCode.ModelMappings = append([]AmpModelMapping(nil), legacy.AmpModelMappings...)\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc removeLegacyOpenAICompatAPIKeys(root *yaml.Node) {\n\tif root == nil || root.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tidx := findMapKeyIndex(root, \"openai-compatibility\")\n\tif idx < 0 || idx+1 >= len(root.Content) {\n\t\treturn\n\t}\n\tseq := root.Content[idx+1]\n\tif seq == nil || seq.Kind != yaml.SequenceNode {\n\t\treturn\n\t}\n\tfor i := range seq.Content {\n\t\tif seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode {\n\t\t\tremoveMapKey(seq.Content[i], \"api-keys\")\n\t\t}\n\t}\n}\n\nfunc removeLegacyAmpKeys(root *yaml.Node) {\n\tif root == nil || root.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tremoveMapKey(root, \"amp-upstream-url\")\n\tremoveMapKey(root, \"amp-upstream-api-key\")\n\tremoveMapKey(root, \"amp-restrict-management-to-localhost\")\n\tremoveMapKey(root, \"amp-model-mappings\")\n}\n\nfunc removeLegacyGenerativeLanguageKeys(root *yaml.Node) {\n\tif root == nil || root.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tremoveMapKey(root, \"generative-language-api-key\")\n}\n\nfunc removeLegacyAuthBlock(root *yaml.Node) {\n\tif root == nil || root.Kind != yaml.MappingNode {\n\t\treturn\n\t}\n\tremoveMapKey(root, \"auth\")\n}\n"
  },
  {
    "path": "internal/config/oauth_model_alias_test.go",
    "content": "package config\n\nimport \"testing\"\n\nfunc TestSanitizeOAuthModelAlias_PreservesForkFlag(t *testing.T) {\n\tcfg := &Config{\n\t\tOAuthModelAlias: map[string][]OAuthModelAlias{\n\t\t\t\" CoDeX \": {\n\t\t\t\t{Name: \" gpt-5 \", Alias: \" g5 \", Fork: true},\n\t\t\t\t{Name: \"gpt-6\", Alias: \"g6\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg.SanitizeOAuthModelAlias()\n\n\taliases := cfg.OAuthModelAlias[\"codex\"]\n\tif len(aliases) != 2 {\n\t\tt.Fatalf(\"expected 2 sanitized aliases, got %d\", len(aliases))\n\t}\n\tif aliases[0].Name != \"gpt-5\" || aliases[0].Alias != \"g5\" || !aliases[0].Fork {\n\t\tt.Fatalf(\"expected first alias to be gpt-5->g5 fork=true, got name=%q alias=%q fork=%v\", aliases[0].Name, aliases[0].Alias, aliases[0].Fork)\n\t}\n\tif aliases[1].Name != \"gpt-6\" || aliases[1].Alias != \"g6\" || aliases[1].Fork {\n\t\tt.Fatalf(\"expected second alias to be gpt-6->g6 fork=false, got name=%q alias=%q fork=%v\", aliases[1].Name, aliases[1].Alias, aliases[1].Fork)\n\t}\n}\n\nfunc TestSanitizeOAuthModelAlias_AllowsMultipleAliasesForSameName(t *testing.T) {\n\tcfg := &Config{\n\t\tOAuthModelAlias: map[string][]OAuthModelAlias{\n\t\t\t\"antigravity\": {\n\t\t\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5-20251101\", Fork: true},\n\t\t\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5-20251101-thinking\", Fork: true},\n\t\t\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5\", Fork: true},\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg.SanitizeOAuthModelAlias()\n\n\taliases := cfg.OAuthModelAlias[\"antigravity\"]\n\texpected := []OAuthModelAlias{\n\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5-20251101\", Fork: true},\n\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5-20251101-thinking\", Fork: true},\n\t\t{Name: \"gemini-claude-opus-4-5-thinking\", Alias: \"claude-opus-4-5\", Fork: true},\n\t}\n\tif len(aliases) != len(expected) {\n\t\tt.Fatalf(\"expected %d sanitized aliases, got %d\", len(expected), len(aliases))\n\t}\n\tfor i, exp := range expected {\n\t\tif aliases[i].Name != exp.Name || aliases[i].Alias != exp.Alias || aliases[i].Fork != exp.Fork {\n\t\t\tt.Fatalf(\"expected alias %d to be name=%q alias=%q fork=%v, got name=%q alias=%q fork=%v\", i, exp.Name, exp.Alias, exp.Fork, aliases[i].Name, aliases[i].Alias, aliases[i].Fork)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/config/sdk_config.go",
    "content": "// Package config provides configuration management for the CLI Proxy API server.\n// It handles loading and parsing YAML configuration files, and provides structured\n// access to application settings including server port, authentication directory,\n// debug settings, proxy configuration, and API keys.\npackage config\n\n// SDKConfig represents the application's configuration, loaded from a YAML file.\ntype SDKConfig struct {\n\t// ProxyURL is the URL of an optional proxy server to use for outbound requests.\n\tProxyURL string `yaml:\"proxy-url\" json:\"proxy-url\"`\n\n\t// ForceModelPrefix requires explicit model prefixes (e.g., \"teamA/gemini-3-pro-preview\")\n\t// to target prefixed credentials. When false, unprefixed model requests may use prefixed\n\t// credentials as well.\n\tForceModelPrefix bool `yaml:\"force-model-prefix\" json:\"force-model-prefix\"`\n\n\t// RequestLog enables or disables detailed request logging functionality.\n\tRequestLog bool `yaml:\"request-log\" json:\"request-log\"`\n\n\t// APIKeys is a list of keys for authenticating clients to this proxy server.\n\tAPIKeys []string `yaml:\"api-keys\" json:\"api-keys\"`\n\n\t// PassthroughHeaders controls whether upstream response headers are forwarded to downstream clients.\n\t// Default is false (disabled).\n\tPassthroughHeaders bool `yaml:\"passthrough-headers\" json:\"passthrough-headers\"`\n\n\t// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).\n\tStreaming StreamingConfig `yaml:\"streaming\" json:\"streaming\"`\n\n\t// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.\n\t// <= 0 disables keep-alives. Value is in seconds.\n\tNonStreamKeepAliveInterval int `yaml:\"nonstream-keepalive-interval,omitempty\" json:\"nonstream-keepalive-interval,omitempty\"`\n}\n\n// StreamingConfig holds server streaming behavior configuration.\ntype StreamingConfig struct {\n\t// KeepAliveSeconds controls how often the server emits SSE heartbeats (\": keep-alive\\n\\n\").\n\t// <= 0 disables keep-alives. Default is 0.\n\tKeepAliveSeconds int `yaml:\"keepalive-seconds,omitempty\" json:\"keepalive-seconds,omitempty\"`\n\n\t// BootstrapRetries controls how many times the server may retry a streaming request before any bytes are sent,\n\t// to allow auth rotation / transient recovery.\n\t// <= 0 disables bootstrap retries. Default is 0.\n\tBootstrapRetries int `yaml:\"bootstrap-retries,omitempty\" json:\"bootstrap-retries,omitempty\"`\n}\n"
  },
  {
    "path": "internal/config/vertex_compat.go",
    "content": "package config\n\nimport \"strings\"\n\n// VertexCompatKey represents the configuration for Vertex AI-compatible API keys.\n// This supports third-party services that use Vertex AI-style endpoint paths\n// (/publishers/google/models/{model}:streamGenerateContent) but authenticate\n// with simple API keys instead of Google Cloud service account credentials.\n//\n// Example services: zenmux.ai and similar Vertex-compatible providers.\ntype VertexCompatKey struct {\n\t// APIKey is the authentication key for accessing the Vertex-compatible API.\n\t// Maps to the x-goog-api-key header.\n\tAPIKey string `yaml:\"api-key\" json:\"api-key\"`\n\n\t// Priority controls selection preference when multiple credentials match.\n\t// Higher values are preferred; defaults to 0.\n\tPriority int `yaml:\"priority,omitempty\" json:\"priority,omitempty\"`\n\n\t// Prefix optionally namespaces model aliases for this credential (e.g., \"teamA/vertex-pro\").\n\tPrefix string `yaml:\"prefix,omitempty\" json:\"prefix,omitempty\"`\n\n\t// BaseURL optionally overrides the Vertex-compatible API endpoint.\n\t// The executor will append \"/v1/publishers/google/models/{model}:action\" to this.\n\t// When empty, requests fall back to the default Vertex API base URL.\n\tBaseURL string `yaml:\"base-url,omitempty\" json:\"base-url,omitempty\"`\n\n\t// ProxyURL optionally overrides the global proxy for this API key.\n\tProxyURL string `yaml:\"proxy-url,omitempty\" json:\"proxy-url,omitempty\"`\n\n\t// Headers optionally adds extra HTTP headers for requests sent with this key.\n\t// Commonly used for cookies, user-agent, and other authentication headers.\n\tHeaders map[string]string `yaml:\"headers,omitempty\" json:\"headers,omitempty\"`\n\n\t// Models defines the model configurations including aliases for routing.\n\tModels []VertexCompatModel `yaml:\"models,omitempty\" json:\"models,omitempty\"`\n\n\t// ExcludedModels lists model IDs that should be excluded for this provider.\n\tExcludedModels []string `yaml:\"excluded-models,omitempty\" json:\"excluded-models,omitempty\"`\n}\n\nfunc (k VertexCompatKey) GetAPIKey() string  { return k.APIKey }\nfunc (k VertexCompatKey) GetBaseURL() string { return k.BaseURL }\n\n// VertexCompatModel represents a model configuration for Vertex compatibility,\n// including the actual model name and its alias for API routing.\ntype VertexCompatModel struct {\n\t// Name is the actual model name used by the external provider.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Alias is the model name alias that clients will use to reference this model.\n\tAlias string `yaml:\"alias\" json:\"alias\"`\n}\n\nfunc (m VertexCompatModel) GetName() string  { return m.Name }\nfunc (m VertexCompatModel) GetAlias() string { return m.Alias }\n\n// SanitizeVertexCompatKeys deduplicates and normalizes Vertex-compatible API key credentials.\nfunc (cfg *Config) SanitizeVertexCompatKeys() {\n\tif cfg == nil {\n\t\treturn\n\t}\n\n\tseen := make(map[string]struct{}, len(cfg.VertexCompatAPIKey))\n\tout := cfg.VertexCompatAPIKey[:0]\n\tfor i := range cfg.VertexCompatAPIKey {\n\t\tentry := cfg.VertexCompatAPIKey[i]\n\t\tentry.APIKey = strings.TrimSpace(entry.APIKey)\n\t\tif entry.APIKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentry.Prefix = normalizeModelPrefix(entry.Prefix)\n\t\tentry.BaseURL = strings.TrimSpace(entry.BaseURL)\n\t\tentry.ProxyURL = strings.TrimSpace(entry.ProxyURL)\n\t\tentry.Headers = NormalizeHeaders(entry.Headers)\n\t\tentry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)\n\n\t\t// Sanitize models: remove entries without valid alias\n\t\tsanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))\n\t\tfor _, model := range entry.Models {\n\t\t\tmodel.Alias = strings.TrimSpace(model.Alias)\n\t\t\tmodel.Name = strings.TrimSpace(model.Name)\n\t\t\tif model.Alias != \"\" && model.Name != \"\" {\n\t\t\t\tsanitizedModels = append(sanitizedModels, model)\n\t\t\t}\n\t\t}\n\t\tentry.Models = sanitizedModels\n\n\t\t// Use API key + base URL as uniqueness key\n\t\tuniqueKey := entry.APIKey + \"|\" + entry.BaseURL\n\t\tif _, exists := seen[uniqueKey]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[uniqueKey] = struct{}{}\n\t\tout = append(out, entry)\n\t}\n\tcfg.VertexCompatAPIKey = out\n}\n"
  },
  {
    "path": "internal/constant/constant.go",
    "content": "// Package constant defines provider name constants used throughout the CLI Proxy API.\n// These constants identify different AI service providers and their variants,\n// ensuring consistent naming across the application.\npackage constant\n\nconst (\n\t// Gemini represents the Google Gemini provider identifier.\n\tGemini = \"gemini\"\n\n\t// GeminiCLI represents the Google Gemini CLI provider identifier.\n\tGeminiCLI = \"gemini-cli\"\n\n\t// Codex represents the OpenAI Codex provider identifier.\n\tCodex = \"codex\"\n\n\t// Claude represents the Anthropic Claude provider identifier.\n\tClaude = \"claude\"\n\n\t// OpenAI represents the OpenAI provider identifier.\n\tOpenAI = \"openai\"\n\n\t// OpenaiResponse represents the OpenAI response format identifier.\n\tOpenaiResponse = \"openai-response\"\n\n\t// Antigravity represents the Antigravity response format identifier.\n\tAntigravity = \"antigravity\"\n)\n"
  },
  {
    "path": "internal/interfaces/api_handler.go",
    "content": "// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.\n// These interfaces provide a common contract for different components of the application,\n// such as AI service clients, API handlers, and data models.\npackage interfaces\n\n// APIHandler defines the interface that all API handlers must implement.\n// This interface provides methods for identifying handler types and retrieving\n// supported models for different AI service endpoints.\ntype APIHandler interface {\n\t// HandlerType returns the type identifier for this API handler.\n\t// This is used to determine which request/response translators to use.\n\tHandlerType() string\n\n\t// Models returns a list of supported models for this API handler.\n\t// Each model is represented as a map containing model metadata.\n\tModels() []map[string]any\n}\n"
  },
  {
    "path": "internal/interfaces/client_models.go",
    "content": "// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.\n// These interfaces provide a common contract for different components of the application,\n// such as AI service clients, API handlers, and data models.\npackage interfaces\n\nimport (\n\t\"time\"\n)\n\n// GCPProject represents the response structure for a Google Cloud project list request.\n// This structure is used when fetching available projects for a Google Cloud account.\ntype GCPProject struct {\n\t// Projects is a list of Google Cloud projects accessible by the user.\n\tProjects []GCPProjectProjects `json:\"projects\"`\n}\n\n// GCPProjectLabels defines the labels associated with a GCP project.\n// These labels can contain metadata about the project's purpose or configuration.\ntype GCPProjectLabels struct {\n\t// GenerativeLanguage indicates if the project has generative language APIs enabled.\n\tGenerativeLanguage string `json:\"generative-language\"`\n}\n\n// GCPProjectProjects contains details about a single Google Cloud project.\n// This includes identifying information, metadata, and configuration details.\ntype GCPProjectProjects struct {\n\t// ProjectNumber is the unique numeric identifier for the project.\n\tProjectNumber string `json:\"projectNumber\"`\n\n\t// ProjectID is the unique string identifier for the project.\n\tProjectID string `json:\"projectId\"`\n\n\t// LifecycleState indicates the current state of the project (e.g., \"ACTIVE\").\n\tLifecycleState string `json:\"lifecycleState\"`\n\n\t// Name is the human-readable name of the project.\n\tName string `json:\"name\"`\n\n\t// Labels contains metadata labels associated with the project.\n\tLabels GCPProjectLabels `json:\"labels\"`\n\n\t// CreateTime is the timestamp when the project was created.\n\tCreateTime time.Time `json:\"createTime\"`\n}\n\n// Content represents a single message in a conversation, with a role and parts.\n// This structure models a message exchange between a user and an AI model.\ntype Content struct {\n\t// Role indicates who sent the message (\"user\", \"model\", or \"tool\").\n\tRole string `json:\"role\"`\n\n\t// Parts is a collection of content parts that make up the message.\n\tParts []Part `json:\"parts\"`\n}\n\n// Part represents a distinct piece of content within a message.\n// A part can be text, inline data (like an image), a function call, or a function response.\ntype Part struct {\n\tThought bool `json:\"thought,omitempty\"`\n\n\t// Text contains plain text content.\n\tText string `json:\"text,omitempty\"`\n\n\t// InlineData contains base64-encoded data with its MIME type (e.g., images).\n\tInlineData *InlineData `json:\"inlineData,omitempty\"`\n\n\t// ThoughtSignature is a provider-required signature that accompanies certain parts.\n\tThoughtSignature string `json:\"thoughtSignature,omitempty\"`\n\n\t// FunctionCall represents a tool call requested by the model.\n\tFunctionCall *FunctionCall `json:\"functionCall,omitempty\"`\n\n\t// FunctionResponse represents the result of a tool execution.\n\tFunctionResponse *FunctionResponse `json:\"functionResponse,omitempty\"`\n}\n\n// InlineData represents base64-encoded data with its MIME type.\n// This is typically used for embedding images or other binary data in requests.\ntype InlineData struct {\n\t// MimeType specifies the media type of the embedded data (e.g., \"image/png\").\n\tMimeType string `json:\"mime_type,omitempty\"`\n\n\t// Data contains the base64-encoded binary data.\n\tData string `json:\"data,omitempty\"`\n}\n\n// FunctionCall represents a tool call requested by the model.\n// It includes the function name and its arguments that the model wants to execute.\ntype FunctionCall struct {\n\t// ID is the identifier of the function to be called.\n\tID string `json:\"id,omitempty\"`\n\n\t// Name is the identifier of the function to be called.\n\tName string `json:\"name\"`\n\n\t// Args contains the arguments to pass to the function.\n\tArgs map[string]interface{} `json:\"args\"`\n}\n\n// FunctionResponse represents the result of a tool execution.\n// This is sent back to the model after a tool call has been processed.\ntype FunctionResponse struct {\n\t// ID is the identifier of the function to be called.\n\tID string `json:\"id,omitempty\"`\n\n\t// Name is the identifier of the function that was called.\n\tName string `json:\"name\"`\n\n\t// Response contains the result data from the function execution.\n\tResponse map[string]interface{} `json:\"response\"`\n}\n\n// GenerateContentRequest is the top-level request structure for the streamGenerateContent endpoint.\n// This structure defines all the parameters needed for generating content from an AI model.\ntype GenerateContentRequest struct {\n\t// SystemInstruction provides system-level instructions that guide the model's behavior.\n\tSystemInstruction *Content `json:\"systemInstruction,omitempty\"`\n\n\t// Contents is the conversation history between the user and the model.\n\tContents []Content `json:\"contents\"`\n\n\t// Tools defines the available tools/functions that the model can call.\n\tTools []ToolDeclaration `json:\"tools,omitempty\"`\n\n\t// GenerationConfig contains parameters that control the model's generation behavior.\n\tGenerationConfig `json:\"generationConfig\"`\n}\n\n// GenerationConfig defines parameters that control the model's generation behavior.\n// These parameters affect the creativity, randomness, and reasoning of the model's responses.\ntype GenerationConfig struct {\n\t// ThinkingConfig specifies configuration for the model's \"thinking\" process.\n\tThinkingConfig GenerationConfigThinkingConfig `json:\"thinkingConfig,omitempty\"`\n\n\t// Temperature controls the randomness of the model's responses.\n\t// Values closer to 0 make responses more deterministic, while values closer to 1 increase randomness.\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\n\t// TopP controls nucleus sampling, which affects the diversity of responses.\n\t// It limits the model to consider only the top P% of probability mass.\n\tTopP float64 `json:\"topP,omitempty\"`\n\n\t// TopK limits the model to consider only the top K most likely tokens.\n\t// This can help control the quality and diversity of generated text.\n\tTopK float64 `json:\"topK,omitempty\"`\n}\n\n// GenerationConfigThinkingConfig specifies configuration for the model's \"thinking\" process.\n// This controls whether the model should output its reasoning process along with the final answer.\ntype GenerationConfigThinkingConfig struct {\n\t// IncludeThoughts determines whether the model should output its reasoning process.\n\t// When enabled, the model will include its step-by-step thinking in the response.\n\tIncludeThoughts bool `json:\"include_thoughts,omitempty\"`\n}\n\n// ToolDeclaration defines the structure for declaring tools (like functions)\n// that the model can call during content generation.\ntype ToolDeclaration struct {\n\t// FunctionDeclarations is a list of available functions that the model can call.\n\tFunctionDeclarations []interface{} `json:\"functionDeclarations\"`\n}\n"
  },
  {
    "path": "internal/interfaces/error_message.go",
    "content": "// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.\n// These interfaces provide a common contract for different components of the application,\n// such as AI service clients, API handlers, and data models.\npackage interfaces\n\nimport \"net/http\"\n\n// ErrorMessage encapsulates an error with an associated HTTP status code.\n// This structure is used to provide detailed error information including\n// both the HTTP status and the underlying error.\ntype ErrorMessage struct {\n\t// StatusCode is the HTTP status code returned by the API.\n\tStatusCode int\n\n\t// Error is the underlying error that occurred.\n\tError error\n\n\t// Addon contains additional headers to be added to the response.\n\tAddon http.Header\n}\n"
  },
  {
    "path": "internal/interfaces/types.go",
    "content": "// Package interfaces provides type aliases for backwards compatibility with translator functions.\n// It defines common interface types used throughout the CLI Proxy API for request and response\n// transformation operations, maintaining compatibility with the SDK translator package.\npackage interfaces\n\nimport sdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\n// Backwards compatible aliases for translator function types.\ntype TranslateRequestFunc = sdktranslator.RequestTransform\n\ntype TranslateResponseFunc = sdktranslator.ResponseStreamTransform\n\ntype TranslateResponseNonStreamFunc = sdktranslator.ResponseNonStreamTransform\n\ntype TranslateResponse = sdktranslator.ResponseTransform\n"
  },
  {
    "path": "internal/logging/gin_logger.go",
    "content": "// Package logging provides Gin middleware for HTTP request logging and panic recovery.\n// It integrates Gin web framework with logrus for structured logging of HTTP requests,\n// responses, and error handling with panic recovery capabilities.\npackage logging\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime/debug\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// aiAPIPrefixes defines path prefixes for AI API requests that should have request ID tracking.\nvar aiAPIPrefixes = []string{\n\t\"/v1/chat/completions\",\n\t\"/v1/completions\",\n\t\"/v1/messages\",\n\t\"/v1/responses\",\n\t\"/v1beta/models/\",\n\t\"/api/provider/\",\n}\n\nconst skipGinLogKey = \"__gin_skip_request_logging__\"\n\n// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses\n// using logrus. It captures request details including method, path, status code, latency,\n// client IP, and any error messages. Request ID is only added for AI API requests.\n//\n// Output format (AI API): [2025-12-23 20:14:10] [info ] | a1b2c3d4 | 200 |       23.559s | ...\n// Output format (others): [2025-12-23 20:14:10] [info ] | -------- | 200 |       23.559s | ...\n//\n// Returns:\n//   - gin.HandlerFunc: A middleware handler for request logging\nfunc GinLogrusLogger() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\traw := util.MaskSensitiveQuery(c.Request.URL.RawQuery)\n\n\t\t// Only generate request ID for AI API paths\n\t\tvar requestID string\n\t\tif isAIAPIPath(path) {\n\t\t\trequestID = GenerateRequestID()\n\t\t\tSetGinRequestID(c, requestID)\n\t\t\tctx := WithRequestID(c.Request.Context(), requestID)\n\t\t\tc.Request = c.Request.WithContext(ctx)\n\t\t}\n\n\t\tc.Next()\n\n\t\tif shouldSkipGinRequestLogging(c) {\n\t\t\treturn\n\t\t}\n\n\t\tif raw != \"\" {\n\t\t\tpath = path + \"?\" + raw\n\t\t}\n\n\t\tlatency := time.Since(start)\n\t\tif latency > time.Minute {\n\t\t\tlatency = latency.Truncate(time.Second)\n\t\t} else {\n\t\t\tlatency = latency.Truncate(time.Millisecond)\n\t\t}\n\n\t\tstatusCode := c.Writer.Status()\n\t\tclientIP := c.ClientIP()\n\t\tmethod := c.Request.Method\n\t\terrorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()\n\n\t\tif requestID == \"\" {\n\t\t\trequestID = \"--------\"\n\t\t}\n\t\tlogLine := fmt.Sprintf(\"%3d | %13v | %15s | %-7s \\\"%s\\\"\", statusCode, latency, clientIP, method, path)\n\t\tif errorMessage != \"\" {\n\t\t\tlogLine = logLine + \" | \" + errorMessage\n\t\t}\n\n\t\tentry := log.WithField(\"request_id\", requestID)\n\n\t\tswitch {\n\t\tcase statusCode >= http.StatusInternalServerError:\n\t\t\tentry.Error(logLine)\n\t\tcase statusCode >= http.StatusBadRequest:\n\t\t\tentry.Warn(logLine)\n\t\tdefault:\n\t\t\tentry.Info(logLine)\n\t\t}\n\t}\n}\n\n// isAIAPIPath checks if the given path is an AI API endpoint that should have request ID tracking.\nfunc isAIAPIPath(path string) bool {\n\tfor _, prefix := range aiAPIPrefixes {\n\t\tif strings.HasPrefix(path, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// GinLogrusRecovery returns a Gin middleware handler that recovers from panics and logs\n// them using logrus. When a panic occurs, it captures the panic value, stack trace,\n// and request path, then returns a 500 Internal Server Error response to the client.\n//\n// Returns:\n//   - gin.HandlerFunc: A middleware handler for panic recovery\nfunc GinLogrusRecovery() gin.HandlerFunc {\n\treturn gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {\n\t\tif err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {\n\t\t\t// Let net/http handle ErrAbortHandler so the connection is aborted without noisy stack logs.\n\t\t\tpanic(http.ErrAbortHandler)\n\t\t}\n\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"panic\": recovered,\n\t\t\t\"stack\": string(debug.Stack()),\n\t\t\t\"path\":  c.Request.URL.Path,\n\t\t}).Error(\"recovered from panic\")\n\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t})\n}\n\n// SkipGinRequestLogging marks the provided Gin context so that GinLogrusLogger\n// will skip emitting a log line for the associated request.\nfunc SkipGinRequestLogging(c *gin.Context) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.Set(skipGinLogKey, true)\n}\n\nfunc shouldSkipGinRequestLogging(c *gin.Context) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tval, exists := c.Get(skipGinLogKey)\n\tif !exists {\n\t\treturn false\n\t}\n\tflag, ok := val.(bool)\n\treturn ok && flag\n}\n"
  },
  {
    "path": "internal/logging/gin_logger_test.go",
    "content": "package logging\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tengine := gin.New()\n\tengine.Use(GinLogrusRecovery())\n\tengine.GET(\"/abort\", func(c *gin.Context) {\n\t\tpanic(http.ErrAbortHandler)\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/abort\", nil)\n\trecorder := httptest.NewRecorder()\n\n\tdefer func() {\n\t\trecovered := recover()\n\t\tif recovered == nil {\n\t\t\tt.Fatalf(\"expected panic, got nil\")\n\t\t}\n\t\terr, ok := recovered.(error)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected error panic, got %T\", recovered)\n\t\t}\n\t\tif !errors.Is(err, http.ErrAbortHandler) {\n\t\t\tt.Fatalf(\"expected ErrAbortHandler, got %v\", err)\n\t\t}\n\t\tif err != http.ErrAbortHandler {\n\t\t\tt.Fatalf(\"expected exact ErrAbortHandler sentinel, got %v\", err)\n\t\t}\n\t}()\n\n\tengine.ServeHTTP(recorder, req)\n}\n\nfunc TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tengine := gin.New()\n\tengine.Use(GinLogrusRecovery())\n\tengine.GET(\"/panic\", func(c *gin.Context) {\n\t\tpanic(\"boom\")\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/panic\", nil)\n\trecorder := httptest.NewRecorder()\n\n\tengine.ServeHTTP(recorder, req)\n\tif recorder.Code != http.StatusInternalServerError {\n\t\tt.Fatalf(\"expected 500, got %d\", recorder.Code)\n\t}\n}\n"
  },
  {
    "path": "internal/logging/global_logger.go",
    "content": "package logging\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n)\n\nvar (\n\tsetupOnce      sync.Once\n\twriterMu       sync.Mutex\n\tlogWriter      *lumberjack.Logger\n\tginInfoWriter  *io.PipeWriter\n\tginErrorWriter *io.PipeWriter\n)\n\n// LogFormatter defines a custom log format for logrus.\n// This formatter adds timestamp, level, request ID, and source location to each log entry.\n// Format: [2025-12-23 20:14:04] [debug] [manager.go:524] | a1b2c3d4 | Use API key sk-9...0RHO for model gpt-5.2\ntype LogFormatter struct{}\n\n// logFieldOrder defines the display order for common log fields.\nvar logFieldOrder = []string{\"provider\", \"model\", \"mode\", \"budget\", \"level\", \"original_mode\", \"original_value\", \"min\", \"max\", \"clamped_to\", \"error\"}\n\n// Format renders a single log entry with custom formatting.\nfunc (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {\n\tvar buffer *bytes.Buffer\n\tif entry.Buffer != nil {\n\t\tbuffer = entry.Buffer\n\t} else {\n\t\tbuffer = &bytes.Buffer{}\n\t}\n\n\ttimestamp := entry.Time.Format(\"2006-01-02 15:04:05\")\n\tmessage := strings.TrimRight(entry.Message, \"\\r\\n\")\n\n\treqID := \"--------\"\n\tif id, ok := entry.Data[\"request_id\"].(string); ok && id != \"\" {\n\t\treqID = id\n\t}\n\n\tlevel := entry.Level.String()\n\tif level == \"warning\" {\n\t\tlevel = \"warn\"\n\t}\n\tlevelStr := fmt.Sprintf(\"%-5s\", level)\n\n\t// Build fields string (only print fields in logFieldOrder)\n\tvar fieldsStr string\n\tif len(entry.Data) > 0 {\n\t\tvar fields []string\n\t\tfor _, k := range logFieldOrder {\n\t\t\tif v, ok := entry.Data[k]; ok {\n\t\t\t\tfields = append(fields, fmt.Sprintf(\"%s=%v\", k, v))\n\t\t\t}\n\t\t}\n\t\tif len(fields) > 0 {\n\t\t\tfieldsStr = \" \" + strings.Join(fields, \" \")\n\t\t}\n\t}\n\n\tvar formatted string\n\tif entry.Caller != nil {\n\t\tformatted = fmt.Sprintf(\"[%s] [%s] [%s] [%s:%d] %s%s\\n\", timestamp, reqID, levelStr, filepath.Base(entry.Caller.File), entry.Caller.Line, message, fieldsStr)\n\t} else {\n\t\tformatted = fmt.Sprintf(\"[%s] [%s] [%s] %s%s\\n\", timestamp, reqID, levelStr, message, fieldsStr)\n\t}\n\tbuffer.WriteString(formatted)\n\n\treturn buffer.Bytes(), nil\n}\n\n// SetupBaseLogger configures the shared logrus instance and Gin writers.\n// It is safe to call multiple times; initialization happens only once.\nfunc SetupBaseLogger() {\n\tsetupOnce.Do(func() {\n\t\tlog.SetOutput(os.Stdout)\n\t\tlog.SetReportCaller(true)\n\t\tlog.SetFormatter(&LogFormatter{})\n\n\t\tginInfoWriter = log.StandardLogger().Writer()\n\t\tgin.DefaultWriter = ginInfoWriter\n\t\tginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\t\tgin.DefaultErrorWriter = ginErrorWriter\n\t\tgin.DebugPrintFunc = func(format string, values ...interface{}) {\n\t\t\tformat = strings.TrimRight(format, \"\\r\\n\")\n\t\t\tlog.StandardLogger().Infof(format, values...)\n\t\t}\n\n\t\tlog.RegisterExitHandler(closeLogOutputs)\n\t})\n}\n\n// isDirWritable checks if the specified directory exists and is writable by attempting to create and remove a test file.\nfunc isDirWritable(dir string) bool {\n\tinfo, err := os.Stat(dir)\n\tif err != nil || !info.IsDir() {\n\t\treturn false\n\t}\n\n\ttestFile := filepath.Join(dir, \".perm_test\")\n\tf, err := os.Create(testFile)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tdefer func() {\n\t\t_ = f.Close()\n\t\t_ = os.Remove(testFile)\n\t}()\n\treturn true\n}\n\n// ResolveLogDirectory determines the directory used for application logs.\nfunc ResolveLogDirectory(cfg *config.Config) string {\n\tlogDir := \"logs\"\n\tif base := util.WritablePath(); base != \"\" {\n\t\treturn filepath.Join(base, \"logs\")\n\t}\n\tif cfg == nil {\n\t\treturn logDir\n\t}\n\tif !isDirWritable(logDir) {\n\t\tauthDir, err := util.ResolveAuthDir(cfg.AuthDir)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"Failed to resolve auth-dir %q for log directory: %v\", cfg.AuthDir, err)\n\t\t}\n\t\tif authDir != \"\" {\n\t\t\tlogDir = filepath.Join(authDir, \"logs\")\n\t\t}\n\t}\n\treturn logDir\n}\n\n// ConfigureLogOutput switches the global log destination between rotating files and stdout.\n// When logsMaxTotalSizeMB > 0, a background cleaner removes the oldest log files in the logs directory\n// until the total size is within the limit.\nfunc ConfigureLogOutput(cfg *config.Config) error {\n\tSetupBaseLogger()\n\n\twriterMu.Lock()\n\tdefer writerMu.Unlock()\n\n\tlogDir := ResolveLogDirectory(cfg)\n\n\tprotectedPath := \"\"\n\tif cfg.LoggingToFile {\n\t\tif err := os.MkdirAll(logDir, 0o755); err != nil {\n\t\t\treturn fmt.Errorf(\"logging: failed to create log directory: %w\", err)\n\t\t}\n\t\tif logWriter != nil {\n\t\t\t_ = logWriter.Close()\n\t\t}\n\t\tprotectedPath = filepath.Join(logDir, \"main.log\")\n\t\tlogWriter = &lumberjack.Logger{\n\t\t\tFilename:   protectedPath,\n\t\t\tMaxSize:    10,\n\t\t\tMaxBackups: 0,\n\t\t\tMaxAge:     0,\n\t\t\tCompress:   false,\n\t\t}\n\t\tlog.SetOutput(logWriter)\n\t} else {\n\t\tif logWriter != nil {\n\t\t\t_ = logWriter.Close()\n\t\t\tlogWriter = nil\n\t\t}\n\t\tlog.SetOutput(os.Stdout)\n\t}\n\n\tconfigureLogDirCleanerLocked(logDir, cfg.LogsMaxTotalSizeMB, protectedPath)\n\treturn nil\n}\n\nfunc closeLogOutputs() {\n\twriterMu.Lock()\n\tdefer writerMu.Unlock()\n\n\tstopLogDirCleanerLocked()\n\n\tif logWriter != nil {\n\t\t_ = logWriter.Close()\n\t\tlogWriter = nil\n\t}\n\tif ginInfoWriter != nil {\n\t\t_ = ginInfoWriter.Close()\n\t\tginInfoWriter = nil\n\t}\n\tif ginErrorWriter != nil {\n\t\t_ = ginErrorWriter.Close()\n\t\tginErrorWriter = nil\n\t}\n}\n"
  },
  {
    "path": "internal/logging/log_dir_cleaner.go",
    "content": "package logging\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst logDirCleanerInterval = time.Minute\n\nvar logDirCleanerCancel context.CancelFunc\n\nfunc configureLogDirCleanerLocked(logDir string, maxTotalSizeMB int, protectedPath string) {\n\tstopLogDirCleanerLocked()\n\n\tif maxTotalSizeMB <= 0 {\n\t\treturn\n\t}\n\n\tmaxBytes := int64(maxTotalSizeMB) * 1024 * 1024\n\tif maxBytes <= 0 {\n\t\treturn\n\t}\n\n\tdir := strings.TrimSpace(logDir)\n\tif dir == \"\" {\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tlogDirCleanerCancel = cancel\n\tgo runLogDirCleaner(ctx, filepath.Clean(dir), maxBytes, strings.TrimSpace(protectedPath))\n}\n\nfunc stopLogDirCleanerLocked() {\n\tif logDirCleanerCancel == nil {\n\t\treturn\n\t}\n\tlogDirCleanerCancel()\n\tlogDirCleanerCancel = nil\n}\n\nfunc runLogDirCleaner(ctx context.Context, logDir string, maxBytes int64, protectedPath string) {\n\tticker := time.NewTicker(logDirCleanerInterval)\n\tdefer ticker.Stop()\n\n\tcleanOnce := func() {\n\t\tdeleted, errClean := enforceLogDirSizeLimit(logDir, maxBytes, protectedPath)\n\t\tif errClean != nil {\n\t\t\tlog.WithError(errClean).Warn(\"logging: failed to enforce log directory size limit\")\n\t\t\treturn\n\t\t}\n\t\tif deleted > 0 {\n\t\t\tlog.Debugf(\"logging: removed %d old log file(s) to enforce log directory size limit\", deleted)\n\t\t}\n\t}\n\n\tcleanOnce()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tcleanOnce()\n\t\t}\n\t}\n}\n\nfunc enforceLogDirSizeLimit(logDir string, maxBytes int64, protectedPath string) (int, error) {\n\tif maxBytes <= 0 {\n\t\treturn 0, nil\n\t}\n\n\tdir := strings.TrimSpace(logDir)\n\tif dir == \"\" {\n\t\treturn 0, nil\n\t}\n\tdir = filepath.Clean(dir)\n\n\tentries, errRead := os.ReadDir(dir)\n\tif errRead != nil {\n\t\tif os.IsNotExist(errRead) {\n\t\t\treturn 0, nil\n\t\t}\n\t\treturn 0, errRead\n\t}\n\n\tprotected := strings.TrimSpace(protectedPath)\n\tif protected != \"\" {\n\t\tprotected = filepath.Clean(protected)\n\t}\n\n\ttype logFile struct {\n\t\tpath    string\n\t\tsize    int64\n\t\tmodTime time.Time\n\t}\n\n\tvar (\n\t\tfiles []logFile\n\t\ttotal int64\n\t)\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !isLogFileName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tinfo, errInfo := entry.Info()\n\t\tif errInfo != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tpath := filepath.Join(dir, name)\n\t\tfiles = append(files, logFile{\n\t\t\tpath:    path,\n\t\t\tsize:    info.Size(),\n\t\t\tmodTime: info.ModTime(),\n\t\t})\n\t\ttotal += info.Size()\n\t}\n\n\tif total <= maxBytes {\n\t\treturn 0, nil\n\t}\n\n\tsort.Slice(files, func(i, j int) bool {\n\t\treturn files[i].modTime.Before(files[j].modTime)\n\t})\n\n\tdeleted := 0\n\tfor _, file := range files {\n\t\tif total <= maxBytes {\n\t\t\tbreak\n\t\t}\n\t\tif protected != \"\" && filepath.Clean(file.path) == protected {\n\t\t\tcontinue\n\t\t}\n\t\tif errRemove := os.Remove(file.path); errRemove != nil {\n\t\t\tlog.WithError(errRemove).Warnf(\"logging: failed to remove old log file: %s\", filepath.Base(file.path))\n\t\t\tcontinue\n\t\t}\n\t\ttotal -= file.size\n\t\tdeleted++\n\t}\n\n\treturn deleted, nil\n}\n\nfunc isLogFileName(name string) bool {\n\ttrimmed := strings.TrimSpace(name)\n\tif trimmed == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(trimmed)\n\treturn strings.HasSuffix(lower, \".log\") || strings.HasSuffix(lower, \".log.gz\")\n}\n"
  },
  {
    "path": "internal/logging/log_dir_cleaner_test.go",
    "content": "package logging\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestEnforceLogDirSizeLimitDeletesOldest(t *testing.T) {\n\tdir := t.TempDir()\n\n\twriteLogFile(t, filepath.Join(dir, \"old.log\"), 60, time.Unix(1, 0))\n\twriteLogFile(t, filepath.Join(dir, \"mid.log\"), 60, time.Unix(2, 0))\n\tprotected := filepath.Join(dir, \"main.log\")\n\twriteLogFile(t, protected, 60, time.Unix(3, 0))\n\n\tdeleted, err := enforceLogDirSizeLimit(dir, 120, protected)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif deleted != 1 {\n\t\tt.Fatalf(\"expected 1 deleted file, got %d\", deleted)\n\t}\n\n\tif _, err := os.Stat(filepath.Join(dir, \"old.log\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"expected old.log to be removed, stat error: %v\", err)\n\t}\n\tif _, err := os.Stat(filepath.Join(dir, \"mid.log\")); err != nil {\n\t\tt.Fatalf(\"expected mid.log to remain, stat error: %v\", err)\n\t}\n\tif _, err := os.Stat(protected); err != nil {\n\t\tt.Fatalf(\"expected protected main.log to remain, stat error: %v\", err)\n\t}\n}\n\nfunc TestEnforceLogDirSizeLimitSkipsProtected(t *testing.T) {\n\tdir := t.TempDir()\n\n\tprotected := filepath.Join(dir, \"main.log\")\n\twriteLogFile(t, protected, 200, time.Unix(1, 0))\n\twriteLogFile(t, filepath.Join(dir, \"other.log\"), 50, time.Unix(2, 0))\n\n\tdeleted, err := enforceLogDirSizeLimit(dir, 100, protected)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif deleted != 1 {\n\t\tt.Fatalf(\"expected 1 deleted file, got %d\", deleted)\n\t}\n\n\tif _, err := os.Stat(protected); err != nil {\n\t\tt.Fatalf(\"expected protected main.log to remain, stat error: %v\", err)\n\t}\n\tif _, err := os.Stat(filepath.Join(dir, \"other.log\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"expected other.log to be removed, stat error: %v\", err)\n\t}\n}\n\nfunc writeLogFile(t *testing.T, path string, size int, modTime time.Time) {\n\tt.Helper()\n\n\tdata := make([]byte, size)\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\tt.Fatalf(\"write file: %v\", err)\n\t}\n\tif err := os.Chtimes(path, modTime, modTime); err != nil {\n\t\tt.Fatalf(\"set times: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/logging/request_logger.go",
    "content": "// Package logging provides request logging functionality for the CLI Proxy API server.\n// It handles capturing and storing detailed HTTP request and response data when enabled\n// through configuration, supporting both regular and streaming responses.\npackage logging\n\nimport (\n\t\"bytes\"\n\t\"compress/flate\"\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/andybalholm/brotli\"\n\t\"github.com/klauspost/compress/zstd\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n)\n\nvar requestLogID atomic.Uint64\n\n// RequestLogger defines the interface for logging HTTP requests and responses.\n// It provides methods for logging both regular and streaming HTTP request/response cycles.\ntype RequestLogger interface {\n\t// LogRequest logs a complete non-streaming request/response cycle.\n\t//\n\t// Parameters:\n\t//   - url: The request URL\n\t//   - method: The HTTP method\n\t//   - requestHeaders: The request headers\n\t//   - body: The request body\n\t//   - statusCode: The response status code\n\t//   - responseHeaders: The response headers\n\t//   - response: The raw response data\n\t//   - apiRequest: The API request data\n\t//   - apiResponse: The API response data\n\t//   - requestID: Optional request ID for log file naming\n\t//   - requestTimestamp: When the request was received\n\t//   - apiResponseTimestamp: When the API response was received\n\t//\n\t// Returns:\n\t//   - error: An error if logging fails, nil otherwise\n\tLogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error\n\n\t// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.\n\t//\n\t// Parameters:\n\t//   - url: The request URL\n\t//   - method: The HTTP method\n\t//   - headers: The request headers\n\t//   - body: The request body\n\t//   - requestID: Optional request ID for log file naming\n\t//\n\t// Returns:\n\t//   - StreamingLogWriter: A writer for streaming response chunks\n\t//   - error: An error if logging initialization fails, nil otherwise\n\tLogStreamingRequest(url, method string, headers map[string][]string, body []byte, requestID string) (StreamingLogWriter, error)\n\n\t// IsEnabled returns whether request logging is currently enabled.\n\t//\n\t// Returns:\n\t//   - bool: True if logging is enabled, false otherwise\n\tIsEnabled() bool\n}\n\n// StreamingLogWriter handles real-time logging of streaming response chunks.\n// It provides methods for writing streaming response data asynchronously.\ntype StreamingLogWriter interface {\n\t// WriteChunkAsync writes a response chunk asynchronously (non-blocking).\n\t//\n\t// Parameters:\n\t//   - chunk: The response chunk to write\n\tWriteChunkAsync(chunk []byte)\n\n\t// WriteStatus writes the response status and headers to the log.\n\t//\n\t// Parameters:\n\t//   - status: The response status code\n\t//   - headers: The response headers\n\t//\n\t// Returns:\n\t//   - error: An error if writing fails, nil otherwise\n\tWriteStatus(status int, headers map[string][]string) error\n\n\t// WriteAPIRequest writes the upstream API request details to the log.\n\t// This should be called before WriteStatus to maintain proper log ordering.\n\t//\n\t// Parameters:\n\t//   - apiRequest: The API request data (typically includes URL, headers, body sent upstream)\n\t//\n\t// Returns:\n\t//   - error: An error if writing fails, nil otherwise\n\tWriteAPIRequest(apiRequest []byte) error\n\n\t// WriteAPIResponse writes the upstream API response details to the log.\n\t// This should be called after the streaming response is complete.\n\t//\n\t// Parameters:\n\t//   - apiResponse: The API response data\n\t//\n\t// Returns:\n\t//   - error: An error if writing fails, nil otherwise\n\tWriteAPIResponse(apiResponse []byte) error\n\n\t// SetFirstChunkTimestamp sets the TTFB timestamp captured when first chunk was received.\n\t//\n\t// Parameters:\n\t//   - timestamp: The time when first response chunk was received\n\tSetFirstChunkTimestamp(timestamp time.Time)\n\n\t// Close finalizes the log file and cleans up resources.\n\t//\n\t// Returns:\n\t//   - error: An error if closing fails, nil otherwise\n\tClose() error\n}\n\n// FileRequestLogger implements RequestLogger using file-based storage.\n// It provides file-based logging functionality for HTTP requests and responses.\ntype FileRequestLogger struct {\n\t// enabled indicates whether request logging is currently enabled.\n\tenabled bool\n\n\t// logsDir is the directory where log files are stored.\n\tlogsDir string\n\n\t// errorLogsMaxFiles limits the number of error log files retained.\n\terrorLogsMaxFiles int\n}\n\n// NewFileRequestLogger creates a new file-based request logger.\n//\n// Parameters:\n//   - enabled: Whether request logging should be enabled\n//   - logsDir: The directory where log files should be stored (can be relative)\n//   - configDir: The directory of the configuration file; when logsDir is\n//     relative, it will be resolved relative to this directory\n//   - errorLogsMaxFiles: Maximum number of error log files to retain (0 = no cleanup)\n//\n// Returns:\n//   - *FileRequestLogger: A new file-based request logger instance\nfunc NewFileRequestLogger(enabled bool, logsDir string, configDir string, errorLogsMaxFiles int) *FileRequestLogger {\n\t// Resolve logsDir relative to the configuration file directory when it's not absolute.\n\tif !filepath.IsAbs(logsDir) {\n\t\t// If configDir is provided, resolve logsDir relative to it.\n\t\tif configDir != \"\" {\n\t\t\tlogsDir = filepath.Join(configDir, logsDir)\n\t\t}\n\t}\n\treturn &FileRequestLogger{\n\t\tenabled:           enabled,\n\t\tlogsDir:           logsDir,\n\t\terrorLogsMaxFiles: errorLogsMaxFiles,\n\t}\n}\n\n// IsEnabled returns whether request logging is currently enabled.\n//\n// Returns:\n//   - bool: True if logging is enabled, false otherwise\nfunc (l *FileRequestLogger) IsEnabled() bool {\n\treturn l.enabled\n}\n\n// SetEnabled updates the request logging enabled state.\n// This method allows dynamic enabling/disabling of request logging.\n//\n// Parameters:\n//   - enabled: Whether request logging should be enabled\nfunc (l *FileRequestLogger) SetEnabled(enabled bool) {\n\tl.enabled = enabled\n}\n\n// SetErrorLogsMaxFiles updates the maximum number of error log files to retain.\nfunc (l *FileRequestLogger) SetErrorLogsMaxFiles(maxFiles int) {\n\tl.errorLogsMaxFiles = maxFiles\n}\n\n// LogRequest logs a complete non-streaming request/response cycle to a file.\n//\n// Parameters:\n//   - url: The request URL\n//   - method: The HTTP method\n//   - requestHeaders: The request headers\n//   - body: The request body\n//   - statusCode: The response status code\n//   - responseHeaders: The response headers\n//   - response: The raw response data\n//   - apiRequest: The API request data\n//   - apiResponse: The API response data\n//   - requestID: Optional request ID for log file naming\n//   - requestTimestamp: When the request was received\n//   - apiResponseTimestamp: When the API response was received\n//\n// Returns:\n//   - error: An error if logging fails, nil otherwise\nfunc (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {\n\treturn l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, false, requestID, requestTimestamp, apiResponseTimestamp)\n}\n\n// LogRequestWithOptions logs a request with optional forced logging behavior.\n// The force flag allows writing error logs even when regular request logging is disabled.\nfunc (l *FileRequestLogger) LogRequestWithOptions(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {\n\treturn l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)\n}\n\nfunc (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {\n\tif !l.enabled && !force {\n\t\treturn nil\n\t}\n\n\t// Ensure logs directory exists\n\tif errEnsure := l.ensureLogsDir(); errEnsure != nil {\n\t\treturn fmt.Errorf(\"failed to create logs directory: %w\", errEnsure)\n\t}\n\n\t// Generate filename with request ID\n\tfilename := l.generateFilename(url, requestID)\n\tif force && !l.enabled {\n\t\tfilename = l.generateErrorFilename(url, requestID)\n\t}\n\tfilePath := filepath.Join(l.logsDir, filename)\n\n\trequestBodyPath, errTemp := l.writeRequestBodyTempFile(body)\n\tif errTemp != nil {\n\t\tlog.WithError(errTemp).Warn(\"failed to create request body temp file, falling back to direct write\")\n\t}\n\tif requestBodyPath != \"\" {\n\t\tdefer func() {\n\t\t\tif errRemove := os.Remove(requestBodyPath); errRemove != nil {\n\t\t\t\tlog.WithError(errRemove).Warn(\"failed to remove request body temp file\")\n\t\t\t}\n\t\t}()\n\t}\n\n\tresponseToWrite, decompressErr := l.decompressResponse(responseHeaders, response)\n\tif decompressErr != nil {\n\t\t// If decompression fails, continue with original response and annotate the log output.\n\t\tresponseToWrite = response\n\t}\n\n\tlogFile, errOpen := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)\n\tif errOpen != nil {\n\t\treturn fmt.Errorf(\"failed to create log file: %w\", errOpen)\n\t}\n\n\twriteErr := l.writeNonStreamingLog(\n\t\tlogFile,\n\t\turl,\n\t\tmethod,\n\t\trequestHeaders,\n\t\tbody,\n\t\trequestBodyPath,\n\t\tapiRequest,\n\t\tapiResponse,\n\t\tapiResponseErrors,\n\t\tstatusCode,\n\t\tresponseHeaders,\n\t\tresponseToWrite,\n\t\tdecompressErr,\n\t\trequestTimestamp,\n\t\tapiResponseTimestamp,\n\t)\n\tif errClose := logFile.Close(); errClose != nil {\n\t\tlog.WithError(errClose).Warn(\"failed to close request log file\")\n\t\tif writeErr == nil {\n\t\t\treturn errClose\n\t\t}\n\t}\n\tif writeErr != nil {\n\t\treturn fmt.Errorf(\"failed to write log file: %w\", writeErr)\n\t}\n\n\tif force && !l.enabled {\n\t\tif errCleanup := l.cleanupOldErrorLogs(); errCleanup != nil {\n\t\t\tlog.WithError(errCleanup).Warn(\"failed to clean up old error logs\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// LogStreamingRequest initiates logging for a streaming request.\n//\n// Parameters:\n//   - url: The request URL\n//   - method: The HTTP method\n//   - headers: The request headers\n//   - body: The request body\n//   - requestID: Optional request ID for log file naming\n//\n// Returns:\n//   - StreamingLogWriter: A writer for streaming response chunks\n//   - error: An error if logging initialization fails, nil otherwise\nfunc (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[string][]string, body []byte, requestID string) (StreamingLogWriter, error) {\n\tif !l.enabled {\n\t\treturn &NoOpStreamingLogWriter{}, nil\n\t}\n\n\t// Ensure logs directory exists\n\tif err := l.ensureLogsDir(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create logs directory: %w\", err)\n\t}\n\n\t// Generate filename with request ID\n\tfilename := l.generateFilename(url, requestID)\n\tfilePath := filepath.Join(l.logsDir, filename)\n\n\trequestHeaders := make(map[string][]string, len(headers))\n\tfor key, values := range headers {\n\t\theaderValues := make([]string, len(values))\n\t\tcopy(headerValues, values)\n\t\trequestHeaders[key] = headerValues\n\t}\n\n\trequestBodyPath, errTemp := l.writeRequestBodyTempFile(body)\n\tif errTemp != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request body temp file: %w\", errTemp)\n\t}\n\n\tresponseBodyFile, errCreate := os.CreateTemp(l.logsDir, \"response-body-*.tmp\")\n\tif errCreate != nil {\n\t\t_ = os.Remove(requestBodyPath)\n\t\treturn nil, fmt.Errorf(\"failed to create response body temp file: %w\", errCreate)\n\t}\n\tresponseBodyPath := responseBodyFile.Name()\n\n\t// Create streaming writer\n\twriter := &FileStreamingLogWriter{\n\t\tlogFilePath:      filePath,\n\t\turl:              url,\n\t\tmethod:           method,\n\t\ttimestamp:        time.Now(),\n\t\trequestHeaders:   requestHeaders,\n\t\trequestBodyPath:  requestBodyPath,\n\t\tresponseBodyPath: responseBodyPath,\n\t\tresponseBodyFile: responseBodyFile,\n\t\tchunkChan:        make(chan []byte, 100), // Buffered channel for async writes\n\t\tcloseChan:        make(chan struct{}),\n\t\terrorChan:        make(chan error, 1),\n\t}\n\n\t// Start async writer goroutine\n\tgo writer.asyncWriter()\n\n\treturn writer, nil\n}\n\n// generateErrorFilename creates a filename with an error prefix to differentiate forced error logs.\nfunc (l *FileRequestLogger) generateErrorFilename(url string, requestID ...string) string {\n\treturn fmt.Sprintf(\"error-%s\", l.generateFilename(url, requestID...))\n}\n\n// ensureLogsDir creates the logs directory if it doesn't exist.\n//\n// Returns:\n//   - error: An error if directory creation fails, nil otherwise\nfunc (l *FileRequestLogger) ensureLogsDir() error {\n\tif _, err := os.Stat(l.logsDir); os.IsNotExist(err) {\n\t\treturn os.MkdirAll(l.logsDir, 0755)\n\t}\n\treturn nil\n}\n\n// generateFilename creates a sanitized filename from the URL path and current timestamp.\n// Format: v1-responses-2025-12-23T195811-a1b2c3d4.log\n//\n// Parameters:\n//   - url: The request URL\n//   - requestID: Optional request ID to include in filename\n//\n// Returns:\n//   - string: A sanitized filename for the log file\nfunc (l *FileRequestLogger) generateFilename(url string, requestID ...string) string {\n\t// Extract path from URL\n\tpath := url\n\tif strings.Contains(url, \"?\") {\n\t\tpath = strings.Split(url, \"?\")[0]\n\t}\n\n\t// Remove leading slash\n\tif strings.HasPrefix(path, \"/\") {\n\t\tpath = path[1:]\n\t}\n\n\t// Sanitize path for filename\n\tsanitized := l.sanitizeForFilename(path)\n\n\t// Add timestamp\n\ttimestamp := time.Now().Format(\"2006-01-02T150405\")\n\n\t// Use request ID if provided, otherwise use sequential ID\n\tvar idPart string\n\tif len(requestID) > 0 && requestID[0] != \"\" {\n\t\tidPart = requestID[0]\n\t} else {\n\t\tid := requestLogID.Add(1)\n\t\tidPart = fmt.Sprintf(\"%d\", id)\n\t}\n\n\treturn fmt.Sprintf(\"%s-%s-%s.log\", sanitized, timestamp, idPart)\n}\n\n// sanitizeForFilename replaces characters that are not safe for filenames.\n//\n// Parameters:\n//   - path: The path to sanitize\n//\n// Returns:\n//   - string: A sanitized filename\nfunc (l *FileRequestLogger) sanitizeForFilename(path string) string {\n\t// Replace slashes with hyphens\n\tsanitized := strings.ReplaceAll(path, \"/\", \"-\")\n\n\t// Replace colons with hyphens\n\tsanitized = strings.ReplaceAll(sanitized, \":\", \"-\")\n\n\t// Replace other problematic characters with hyphens\n\treg := regexp.MustCompile(`[<>:\"|?*\\s]`)\n\tsanitized = reg.ReplaceAllString(sanitized, \"-\")\n\n\t// Remove multiple consecutive hyphens\n\treg = regexp.MustCompile(`-+`)\n\tsanitized = reg.ReplaceAllString(sanitized, \"-\")\n\n\t// Remove leading/trailing hyphens\n\tsanitized = strings.Trim(sanitized, \"-\")\n\n\t// Handle empty result\n\tif sanitized == \"\" {\n\t\tsanitized = \"root\"\n\t}\n\n\treturn sanitized\n}\n\n// cleanupOldErrorLogs keeps only the newest errorLogsMaxFiles forced error log files.\nfunc (l *FileRequestLogger) cleanupOldErrorLogs() error {\n\tif l.errorLogsMaxFiles <= 0 {\n\t\treturn nil\n\t}\n\n\tentries, errRead := os.ReadDir(l.logsDir)\n\tif errRead != nil {\n\t\treturn errRead\n\t}\n\n\ttype logFile struct {\n\t\tname    string\n\t\tmodTime time.Time\n\t}\n\n\tvar files []logFile\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !strings.HasPrefix(name, \"error-\") || !strings.HasSuffix(name, \".log\") {\n\t\t\tcontinue\n\t\t}\n\t\tinfo, errInfo := entry.Info()\n\t\tif errInfo != nil {\n\t\t\tlog.WithError(errInfo).Warn(\"failed to read error log info\")\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, logFile{name: name, modTime: info.ModTime()})\n\t}\n\n\tif len(files) <= l.errorLogsMaxFiles {\n\t\treturn nil\n\t}\n\n\tsort.Slice(files, func(i, j int) bool {\n\t\treturn files[i].modTime.After(files[j].modTime)\n\t})\n\n\tfor _, file := range files[l.errorLogsMaxFiles:] {\n\t\tif errRemove := os.Remove(filepath.Join(l.logsDir, file.name)); errRemove != nil {\n\t\t\tlog.WithError(errRemove).Warnf(\"failed to remove old error log: %s\", file.name)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (l *FileRequestLogger) writeRequestBodyTempFile(body []byte) (string, error) {\n\ttmpFile, errCreate := os.CreateTemp(l.logsDir, \"request-body-*.tmp\")\n\tif errCreate != nil {\n\t\treturn \"\", errCreate\n\t}\n\ttmpPath := tmpFile.Name()\n\n\tif _, errCopy := io.Copy(tmpFile, bytes.NewReader(body)); errCopy != nil {\n\t\t_ = tmpFile.Close()\n\t\t_ = os.Remove(tmpPath)\n\t\treturn \"\", errCopy\n\t}\n\tif errClose := tmpFile.Close(); errClose != nil {\n\t\t_ = os.Remove(tmpPath)\n\t\treturn \"\", errClose\n\t}\n\treturn tmpPath, nil\n}\n\nfunc (l *FileRequestLogger) writeNonStreamingLog(\n\tw io.Writer,\n\turl, method string,\n\trequestHeaders map[string][]string,\n\trequestBody []byte,\n\trequestBodyPath string,\n\tapiRequest []byte,\n\tapiResponse []byte,\n\tapiResponseErrors []*interfaces.ErrorMessage,\n\tstatusCode int,\n\tresponseHeaders map[string][]string,\n\tresponse []byte,\n\tdecompressErr error,\n\trequestTimestamp time.Time,\n\tapiResponseTimestamp time.Time,\n) error {\n\tif requestTimestamp.IsZero() {\n\t\trequestTimestamp = time.Now()\n\t}\n\tif errWrite := writeRequestInfoWithBody(w, url, method, requestHeaders, requestBody, requestBodyPath, requestTimestamp); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif errWrite := writeAPISection(w, \"=== API REQUEST ===\\n\", \"=== API REQUEST\", apiRequest, time.Time{}); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif errWrite := writeAPIErrorResponses(w, apiResponseErrors); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif errWrite := writeAPISection(w, \"=== API RESPONSE ===\\n\", \"=== API RESPONSE\", apiResponse, apiResponseTimestamp); errWrite != nil {\n\t\treturn errWrite\n\t}\n\treturn writeResponseSection(w, statusCode, true, responseHeaders, bytes.NewReader(response), decompressErr, true)\n}\n\nfunc writeRequestInfoWithBody(\n\tw io.Writer,\n\turl, method string,\n\theaders map[string][]string,\n\tbody []byte,\n\tbodyPath string,\n\ttimestamp time.Time,\n) error {\n\tif _, errWrite := io.WriteString(w, \"=== REQUEST INFO ===\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"Version: %s\\n\", buildinfo.Version)); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"URL: %s\\n\", url)); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"Method: %s\\n\", method)); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"Timestamp: %s\\n\", timestamp.Format(time.RFC3339Nano))); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tif _, errWrite := io.WriteString(w, \"=== HEADERS ===\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tfor key, values := range headers {\n\t\tfor _, value := range values {\n\t\t\tmasked := util.MaskSensitiveHeaderValue(key, value)\n\t\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"%s: %s\\n\", key, masked)); errWrite != nil {\n\t\t\t\treturn errWrite\n\t\t\t}\n\t\t}\n\t}\n\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tif _, errWrite := io.WriteString(w, \"=== REQUEST BODY ===\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tif bodyPath != \"\" {\n\t\tbodyFile, errOpen := os.Open(bodyPath)\n\t\tif errOpen != nil {\n\t\t\treturn errOpen\n\t\t}\n\t\tif _, errCopy := io.Copy(w, bodyFile); errCopy != nil {\n\t\t\t_ = bodyFile.Close()\n\t\t\treturn errCopy\n\t\t}\n\t\tif errClose := bodyFile.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close request body temp file\")\n\t\t}\n\t} else if _, errWrite := w.Write(body); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tif _, errWrite := io.WriteString(w, \"\\n\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\treturn nil\n}\n\nfunc writeAPISection(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte, timestamp time.Time) error {\n\tif len(payload) == 0 {\n\t\treturn nil\n\t}\n\n\tif bytes.HasPrefix(payload, []byte(sectionPrefix)) {\n\t\tif _, errWrite := w.Write(payload); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t\tif !bytes.HasSuffix(payload, []byte(\"\\n\")) {\n\t\t\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\t\t\treturn errWrite\n\t\t\t}\n\t\t}\n\t} else {\n\t\tif _, errWrite := io.WriteString(w, sectionHeader); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t\tif !timestamp.IsZero() {\n\t\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"Timestamp: %s\\n\", timestamp.Format(time.RFC3339Nano))); errWrite != nil {\n\t\t\t\treturn errWrite\n\t\t\t}\n\t\t}\n\t\tif _, errWrite := w.Write(payload); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t}\n\n\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\treturn nil\n}\n\nfunc writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMessage) error {\n\tfor i := 0; i < len(apiResponseErrors); i++ {\n\t\tif apiResponseErrors[i] == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif _, errWrite := io.WriteString(w, \"=== API ERROR RESPONSE ===\\n\"); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"HTTP Status: %d\\n\", apiResponseErrors[i].StatusCode)); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t\tif apiResponseErrors[i].Error != nil {\n\t\t\tif _, errWrite := io.WriteString(w, apiResponseErrors[i].Error.Error()); errWrite != nil {\n\t\t\t\treturn errWrite\n\t\t\t}\n\t\t}\n\t\tif _, errWrite := io.WriteString(w, \"\\n\\n\"); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc writeResponseSection(w io.Writer, statusCode int, statusWritten bool, responseHeaders map[string][]string, responseReader io.Reader, decompressErr error, trailingNewline bool) error {\n\tif _, errWrite := io.WriteString(w, \"=== RESPONSE ===\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif statusWritten {\n\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"Status: %d\\n\", statusCode)); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t}\n\n\tif responseHeaders != nil {\n\t\tfor key, values := range responseHeaders {\n\t\t\tfor _, value := range values {\n\t\t\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"%s: %s\\n\", key, value)); errWrite != nil {\n\t\t\t\t\treturn errWrite\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tif responseReader != nil {\n\t\tif _, errCopy := io.Copy(w, responseReader); errCopy != nil {\n\t\t\treturn errCopy\n\t\t}\n\t}\n\tif decompressErr != nil {\n\t\tif _, errWrite := io.WriteString(w, fmt.Sprintf(\"\\n[DECOMPRESSION ERROR: %v]\", decompressErr)); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t}\n\n\tif trailingNewline {\n\t\tif _, errWrite := io.WriteString(w, \"\\n\"); errWrite != nil {\n\t\t\treturn errWrite\n\t\t}\n\t}\n\treturn nil\n}\n\n// formatLogContent creates the complete log content for non-streaming requests.\n//\n// Parameters:\n//   - url: The request URL\n//   - method: The HTTP method\n//   - headers: The request headers\n//   - body: The request body\n//   - apiRequest: The API request data\n//   - apiResponse: The API response data\n//   - response: The raw response data\n//   - status: The response status code\n//   - responseHeaders: The response headers\n//\n// Returns:\n//   - string: The formatted log content\nfunc (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, apiRequest, apiResponse, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {\n\tvar content strings.Builder\n\n\t// Request info\n\tcontent.WriteString(l.formatRequestInfo(url, method, headers, body))\n\n\tif len(apiRequest) > 0 {\n\t\tif bytes.HasPrefix(apiRequest, []byte(\"=== API REQUEST\")) {\n\t\t\tcontent.Write(apiRequest)\n\t\t\tif !bytes.HasSuffix(apiRequest, []byte(\"\\n\")) {\n\t\t\t\tcontent.WriteString(\"\\n\")\n\t\t\t}\n\t\t} else {\n\t\t\tcontent.WriteString(\"=== API REQUEST ===\\n\")\n\t\t\tcontent.Write(apiRequest)\n\t\t\tcontent.WriteString(\"\\n\")\n\t\t}\n\t\tcontent.WriteString(\"\\n\")\n\t}\n\n\tfor i := 0; i < len(apiResponseErrors); i++ {\n\t\tcontent.WriteString(\"=== API ERROR RESPONSE ===\\n\")\n\t\tcontent.WriteString(fmt.Sprintf(\"HTTP Status: %d\\n\", apiResponseErrors[i].StatusCode))\n\t\tcontent.WriteString(apiResponseErrors[i].Error.Error())\n\t\tcontent.WriteString(\"\\n\\n\")\n\t}\n\n\tif len(apiResponse) > 0 {\n\t\tif bytes.HasPrefix(apiResponse, []byte(\"=== API RESPONSE\")) {\n\t\t\tcontent.Write(apiResponse)\n\t\t\tif !bytes.HasSuffix(apiResponse, []byte(\"\\n\")) {\n\t\t\t\tcontent.WriteString(\"\\n\")\n\t\t\t}\n\t\t} else {\n\t\t\tcontent.WriteString(\"=== API RESPONSE ===\\n\")\n\t\t\tcontent.Write(apiResponse)\n\t\t\tcontent.WriteString(\"\\n\")\n\t\t}\n\t\tcontent.WriteString(\"\\n\")\n\t}\n\n\t// Response section\n\tcontent.WriteString(\"=== RESPONSE ===\\n\")\n\tcontent.WriteString(fmt.Sprintf(\"Status: %d\\n\", status))\n\n\tif responseHeaders != nil {\n\t\tfor key, values := range responseHeaders {\n\t\t\tfor _, value := range values {\n\t\t\t\tcontent.WriteString(fmt.Sprintf(\"%s: %s\\n\", key, value))\n\t\t\t}\n\t\t}\n\t}\n\n\tcontent.WriteString(\"\\n\")\n\tcontent.Write(response)\n\tcontent.WriteString(\"\\n\")\n\n\treturn content.String()\n}\n\n// decompressResponse decompresses response data based on Content-Encoding header.\n//\n// Parameters:\n//   - responseHeaders: The response headers\n//   - response: The response data to decompress\n//\n// Returns:\n//   - []byte: The decompressed response data\n//   - error: An error if decompression fails, nil otherwise\nfunc (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]string, response []byte) ([]byte, error) {\n\tif responseHeaders == nil || len(response) == 0 {\n\t\treturn response, nil\n\t}\n\n\t// Check Content-Encoding header\n\tvar contentEncoding string\n\tfor key, values := range responseHeaders {\n\t\tif strings.ToLower(key) == \"content-encoding\" && len(values) > 0 {\n\t\t\tcontentEncoding = strings.ToLower(values[0])\n\t\t\tbreak\n\t\t}\n\t}\n\n\tswitch contentEncoding {\n\tcase \"gzip\":\n\t\treturn l.decompressGzip(response)\n\tcase \"deflate\":\n\t\treturn l.decompressDeflate(response)\n\tcase \"br\":\n\t\treturn l.decompressBrotli(response)\n\tcase \"zstd\":\n\t\treturn l.decompressZstd(response)\n\tdefault:\n\t\t// No compression or unsupported compression\n\t\treturn response, nil\n\t}\n}\n\n// decompressGzip decompresses gzip-encoded data.\n//\n// Parameters:\n//   - data: The gzip-encoded data to decompress\n//\n// Returns:\n//   - []byte: The decompressed data\n//   - error: An error if decompression fails, nil otherwise\nfunc (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {\n\treader, err := gzip.NewReader(bytes.NewReader(data))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create gzip reader: %w\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := reader.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close gzip reader in request logger\")\n\t\t}\n\t}()\n\n\tdecompressed, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decompress gzip data: %w\", err)\n\t}\n\n\treturn decompressed, nil\n}\n\n// decompressDeflate decompresses deflate-encoded data.\n//\n// Parameters:\n//   - data: The deflate-encoded data to decompress\n//\n// Returns:\n//   - []byte: The decompressed data\n//   - error: An error if decompression fails, nil otherwise\nfunc (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {\n\treader := flate.NewReader(bytes.NewReader(data))\n\tdefer func() {\n\t\tif errClose := reader.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close deflate reader in request logger\")\n\t\t}\n\t}()\n\n\tdecompressed, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decompress deflate data: %w\", err)\n\t}\n\n\treturn decompressed, nil\n}\n\n// decompressBrotli decompresses brotli-encoded data.\n//\n// Parameters:\n//   - data: The brotli-encoded data to decompress\n//\n// Returns:\n//   - []byte: The decompressed data\n//   - error: An error if decompression fails, nil otherwise\nfunc (l *FileRequestLogger) decompressBrotli(data []byte) ([]byte, error) {\n\treader := brotli.NewReader(bytes.NewReader(data))\n\n\tdecompressed, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decompress brotli data: %w\", err)\n\t}\n\n\treturn decompressed, nil\n}\n\n// decompressZstd decompresses zstd-encoded data.\n//\n// Parameters:\n//   - data: The zstd-encoded data to decompress\n//\n// Returns:\n//   - []byte: The decompressed data\n//   - error: An error if decompression fails, nil otherwise\nfunc (l *FileRequestLogger) decompressZstd(data []byte) ([]byte, error) {\n\tdecoder, err := zstd.NewReader(bytes.NewReader(data))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create zstd reader: %w\", err)\n\t}\n\tdefer decoder.Close()\n\n\tdecompressed, err := io.ReadAll(decoder)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decompress zstd data: %w\", err)\n\t}\n\n\treturn decompressed, nil\n}\n\n// formatRequestInfo creates the request information section of the log.\n//\n// Parameters:\n//   - url: The request URL\n//   - method: The HTTP method\n//   - headers: The request headers\n//   - body: The request body\n//\n// Returns:\n//   - string: The formatted request information\nfunc (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[string][]string, body []byte) string {\n\tvar content strings.Builder\n\n\tcontent.WriteString(\"=== REQUEST INFO ===\\n\")\n\tcontent.WriteString(fmt.Sprintf(\"Version: %s\\n\", buildinfo.Version))\n\tcontent.WriteString(fmt.Sprintf(\"URL: %s\\n\", url))\n\tcontent.WriteString(fmt.Sprintf(\"Method: %s\\n\", method))\n\tcontent.WriteString(fmt.Sprintf(\"Timestamp: %s\\n\", time.Now().Format(time.RFC3339Nano)))\n\tcontent.WriteString(\"\\n\")\n\n\tcontent.WriteString(\"=== HEADERS ===\\n\")\n\tfor key, values := range headers {\n\t\tfor _, value := range values {\n\t\t\tmasked := util.MaskSensitiveHeaderValue(key, value)\n\t\t\tcontent.WriteString(fmt.Sprintf(\"%s: %s\\n\", key, masked))\n\t\t}\n\t}\n\tcontent.WriteString(\"\\n\")\n\n\tcontent.WriteString(\"=== REQUEST BODY ===\\n\")\n\tcontent.Write(body)\n\tcontent.WriteString(\"\\n\\n\")\n\n\treturn content.String()\n}\n\n// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.\n// It spools streaming response chunks to a temporary file to avoid retaining large responses in memory.\n// The final log file is assembled when Close is called.\ntype FileStreamingLogWriter struct {\n\t// logFilePath is the final log file path.\n\tlogFilePath string\n\n\t// url is the request URL (masked upstream in middleware).\n\turl string\n\n\t// method is the HTTP method.\n\tmethod string\n\n\t// timestamp is captured when the streaming log is initialized.\n\ttimestamp time.Time\n\n\t// requestHeaders stores the request headers.\n\trequestHeaders map[string][]string\n\n\t// requestBodyPath is a temporary file path holding the request body.\n\trequestBodyPath string\n\n\t// responseBodyPath is a temporary file path holding the streaming response body.\n\tresponseBodyPath string\n\n\t// responseBodyFile is the temp file where chunks are appended by the async writer.\n\tresponseBodyFile *os.File\n\n\t// chunkChan is a channel for receiving response chunks to spool.\n\tchunkChan chan []byte\n\n\t// closeChan is a channel for signaling when the writer is closed.\n\tcloseChan chan struct{}\n\n\t// errorChan is a channel for reporting errors during writing.\n\terrorChan chan error\n\n\t// responseStatus stores the HTTP status code.\n\tresponseStatus int\n\n\t// statusWritten indicates whether a non-zero status was recorded.\n\tstatusWritten bool\n\n\t// responseHeaders stores the response headers.\n\tresponseHeaders map[string][]string\n\n\t// apiRequest stores the upstream API request data.\n\tapiRequest []byte\n\n\t// apiResponse stores the upstream API response data.\n\tapiResponse []byte\n\n\t// apiResponseTimestamp captures when the API response was received.\n\tapiResponseTimestamp time.Time\n}\n\n// WriteChunkAsync writes a response chunk asynchronously (non-blocking).\n//\n// Parameters:\n//   - chunk: The response chunk to write\nfunc (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {\n\tif w.chunkChan == nil {\n\t\treturn\n\t}\n\n\t// Make a copy of the chunk to avoid data races\n\tchunkCopy := make([]byte, len(chunk))\n\tcopy(chunkCopy, chunk)\n\n\t// Non-blocking send\n\tselect {\n\tcase w.chunkChan <- chunkCopy:\n\tdefault:\n\t\t// Channel is full, skip this chunk to avoid blocking\n\t}\n}\n\n// WriteStatus buffers the response status and headers for later writing.\n//\n// Parameters:\n//   - status: The response status code\n//   - headers: The response headers\n//\n// Returns:\n//   - error: Always returns nil (buffering cannot fail)\nfunc (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {\n\tif status == 0 {\n\t\treturn nil\n\t}\n\n\tw.responseStatus = status\n\tif headers != nil {\n\t\tw.responseHeaders = make(map[string][]string, len(headers))\n\t\tfor key, values := range headers {\n\t\t\theaderValues := make([]string, len(values))\n\t\t\tcopy(headerValues, values)\n\t\t\tw.responseHeaders[key] = headerValues\n\t\t}\n\t}\n\tw.statusWritten = true\n\treturn nil\n}\n\n// WriteAPIRequest buffers the upstream API request details for later writing.\n//\n// Parameters:\n//   - apiRequest: The API request data (typically includes URL, headers, body sent upstream)\n//\n// Returns:\n//   - error: Always returns nil (buffering cannot fail)\nfunc (w *FileStreamingLogWriter) WriteAPIRequest(apiRequest []byte) error {\n\tif len(apiRequest) == 0 {\n\t\treturn nil\n\t}\n\tw.apiRequest = bytes.Clone(apiRequest)\n\treturn nil\n}\n\n// WriteAPIResponse buffers the upstream API response details for later writing.\n//\n// Parameters:\n//   - apiResponse: The API response data\n//\n// Returns:\n//   - error: Always returns nil (buffering cannot fail)\nfunc (w *FileStreamingLogWriter) WriteAPIResponse(apiResponse []byte) error {\n\tif len(apiResponse) == 0 {\n\t\treturn nil\n\t}\n\tw.apiResponse = bytes.Clone(apiResponse)\n\treturn nil\n}\n\nfunc (w *FileStreamingLogWriter) SetFirstChunkTimestamp(timestamp time.Time) {\n\tif !timestamp.IsZero() {\n\t\tw.apiResponseTimestamp = timestamp\n\t}\n}\n\n// Close finalizes the log file and cleans up resources.\n// It writes all buffered data to the file in the correct order:\n// API REQUEST -> API RESPONSE -> RESPONSE (status, headers, body chunks)\n//\n// Returns:\n//   - error: An error if closing fails, nil otherwise\nfunc (w *FileStreamingLogWriter) Close() error {\n\tif w.chunkChan != nil {\n\t\tclose(w.chunkChan)\n\t}\n\n\t// Wait for async writer to finish spooling chunks\n\tif w.closeChan != nil {\n\t\t<-w.closeChan\n\t\tw.chunkChan = nil\n\t}\n\n\tselect {\n\tcase errWrite := <-w.errorChan:\n\t\tw.cleanupTempFiles()\n\t\treturn errWrite\n\tdefault:\n\t}\n\n\tif w.logFilePath == \"\" {\n\t\tw.cleanupTempFiles()\n\t\treturn nil\n\t}\n\n\tlogFile, errOpen := os.OpenFile(w.logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)\n\tif errOpen != nil {\n\t\tw.cleanupTempFiles()\n\t\treturn fmt.Errorf(\"failed to create log file: %w\", errOpen)\n\t}\n\n\twriteErr := w.writeFinalLog(logFile)\n\tif errClose := logFile.Close(); errClose != nil {\n\t\tlog.WithError(errClose).Warn(\"failed to close request log file\")\n\t\tif writeErr == nil {\n\t\t\twriteErr = errClose\n\t\t}\n\t}\n\n\tw.cleanupTempFiles()\n\treturn writeErr\n}\n\n// asyncWriter runs in a goroutine to buffer chunks from the channel.\n// It continuously reads chunks from the channel and appends them to a temp file for later assembly.\nfunc (w *FileStreamingLogWriter) asyncWriter() {\n\tdefer close(w.closeChan)\n\n\tfor chunk := range w.chunkChan {\n\t\tif w.responseBodyFile == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif _, errWrite := w.responseBodyFile.Write(chunk); errWrite != nil {\n\t\t\tselect {\n\t\t\tcase w.errorChan <- errWrite:\n\t\t\tdefault:\n\t\t\t}\n\t\t\tif errClose := w.responseBodyFile.Close(); errClose != nil {\n\t\t\t\tselect {\n\t\t\t\tcase w.errorChan <- errClose:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t\tw.responseBodyFile = nil\n\t\t}\n\t}\n\n\tif w.responseBodyFile == nil {\n\t\treturn\n\t}\n\tif errClose := w.responseBodyFile.Close(); errClose != nil {\n\t\tselect {\n\t\tcase w.errorChan <- errClose:\n\t\tdefault:\n\t\t}\n\t}\n\tw.responseBodyFile = nil\n}\n\nfunc (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {\n\tif errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif errWrite := writeAPISection(logFile, \"=== API REQUEST ===\\n\", \"=== API REQUEST\", w.apiRequest, time.Time{}); errWrite != nil {\n\t\treturn errWrite\n\t}\n\tif errWrite := writeAPISection(logFile, \"=== API RESPONSE ===\\n\", \"=== API RESPONSE\", w.apiResponse, w.apiResponseTimestamp); errWrite != nil {\n\t\treturn errWrite\n\t}\n\n\tresponseBodyFile, errOpen := os.Open(w.responseBodyPath)\n\tif errOpen != nil {\n\t\treturn errOpen\n\t}\n\tdefer func() {\n\t\tif errClose := responseBodyFile.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close response body temp file\")\n\t\t}\n\t}()\n\n\treturn writeResponseSection(logFile, w.responseStatus, w.statusWritten, w.responseHeaders, responseBodyFile, nil, false)\n}\n\nfunc (w *FileStreamingLogWriter) cleanupTempFiles() {\n\tif w.requestBodyPath != \"\" {\n\t\tif errRemove := os.Remove(w.requestBodyPath); errRemove != nil {\n\t\t\tlog.WithError(errRemove).Warn(\"failed to remove request body temp file\")\n\t\t}\n\t\tw.requestBodyPath = \"\"\n\t}\n\n\tif w.responseBodyPath != \"\" {\n\t\tif errRemove := os.Remove(w.responseBodyPath); errRemove != nil {\n\t\t\tlog.WithError(errRemove).Warn(\"failed to remove response body temp file\")\n\t\t}\n\t\tw.responseBodyPath = \"\"\n\t}\n}\n\n// NoOpStreamingLogWriter is a no-operation implementation for when logging is disabled.\n// It implements the StreamingLogWriter interface but performs no actual logging operations.\ntype NoOpStreamingLogWriter struct{}\n\n// WriteChunkAsync is a no-op implementation that does nothing.\n//\n// Parameters:\n//   - chunk: The response chunk (ignored)\nfunc (w *NoOpStreamingLogWriter) WriteChunkAsync(_ []byte) {}\n\n// WriteStatus is a no-op implementation that does nothing and always returns nil.\n//\n// Parameters:\n//   - status: The response status code (ignored)\n//   - headers: The response headers (ignored)\n//\n// Returns:\n//   - error: Always returns nil\nfunc (w *NoOpStreamingLogWriter) WriteStatus(_ int, _ map[string][]string) error {\n\treturn nil\n}\n\n// WriteAPIRequest is a no-op implementation that does nothing and always returns nil.\n//\n// Parameters:\n//   - apiRequest: The API request data (ignored)\n//\n// Returns:\n//   - error: Always returns nil\nfunc (w *NoOpStreamingLogWriter) WriteAPIRequest(_ []byte) error {\n\treturn nil\n}\n\n// WriteAPIResponse is a no-op implementation that does nothing and always returns nil.\n//\n// Parameters:\n//   - apiResponse: The API response data (ignored)\n//\n// Returns:\n//   - error: Always returns nil\nfunc (w *NoOpStreamingLogWriter) WriteAPIResponse(_ []byte) error {\n\treturn nil\n}\n\nfunc (w *NoOpStreamingLogWriter) SetFirstChunkTimestamp(_ time.Time) {}\n\n// Close is a no-op implementation that does nothing and always returns nil.\n//\n// Returns:\n//   - error: Always returns nil\nfunc (w *NoOpStreamingLogWriter) Close() error { return nil }\n"
  },
  {
    "path": "internal/logging/requestid.go",
    "content": "package logging\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// requestIDKey is the context key for storing/retrieving request IDs.\ntype requestIDKey struct{}\n\n// ginRequestIDKey is the Gin context key for request IDs.\nconst ginRequestIDKey = \"__request_id__\"\n\n// GenerateRequestID creates a new 8-character hex request ID.\nfunc GenerateRequestID() string {\n\tb := make([]byte, 4)\n\tif _, err := rand.Read(b); err != nil {\n\t\treturn \"00000000\"\n\t}\n\treturn hex.EncodeToString(b)\n}\n\n// WithRequestID returns a new context with the request ID attached.\nfunc WithRequestID(ctx context.Context, requestID string) context.Context {\n\treturn context.WithValue(ctx, requestIDKey{}, requestID)\n}\n\n// GetRequestID retrieves the request ID from the context.\n// Returns empty string if not found.\nfunc GetRequestID(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn \"\"\n\t}\n\tif id, ok := ctx.Value(requestIDKey{}).(string); ok {\n\t\treturn id\n\t}\n\treturn \"\"\n}\n\n// SetGinRequestID stores the request ID in the Gin context.\nfunc SetGinRequestID(c *gin.Context, requestID string) {\n\tif c != nil {\n\t\tc.Set(ginRequestIDKey, requestID)\n\t}\n}\n\n// GetGinRequestID retrieves the request ID from the Gin context.\nfunc GetGinRequestID(c *gin.Context) string {\n\tif c == nil {\n\t\treturn \"\"\n\t}\n\tif id, exists := c.Get(ginRequestIDKey); exists {\n\t\tif s, ok := id.(string); ok {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/managementasset/updater.go",
    "content": "package managementasset\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/sync/singleflight\"\n)\n\nconst (\n\tdefaultManagementReleaseURL  = \"https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest\"\n\tdefaultManagementFallbackURL = \"https://cpamc.router-for.me/\"\n\tmanagementAssetName          = \"management.html\"\n\thttpUserAgent                = \"CLIProxyAPI-management-updater\"\n\tmanagementSyncMinInterval    = 30 * time.Second\n\tupdateCheckInterval          = 3 * time.Hour\n)\n\n// ManagementFileName exposes the control panel asset filename.\nconst ManagementFileName = managementAssetName\n\nvar (\n\tlastUpdateCheckMu   sync.Mutex\n\tlastUpdateCheckTime time.Time\n\tcurrentConfigPtr    atomic.Pointer[config.Config]\n\tschedulerOnce       sync.Once\n\tschedulerConfigPath atomic.Value\n\tsfGroup             singleflight.Group\n)\n\n// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.\nfunc SetCurrentConfig(cfg *config.Config) {\n\tif cfg == nil {\n\t\tcurrentConfigPtr.Store(nil)\n\t\treturn\n\t}\n\tcurrentConfigPtr.Store(cfg)\n}\n\n// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.\n// It respects the disable-control-panel flag on every iteration and supports hot-reloaded configurations.\nfunc StartAutoUpdater(ctx context.Context, configFilePath string) {\n\tconfigFilePath = strings.TrimSpace(configFilePath)\n\tif configFilePath == \"\" {\n\t\tlog.Debug(\"management asset auto-updater skipped: empty config path\")\n\t\treturn\n\t}\n\n\tschedulerConfigPath.Store(configFilePath)\n\n\tschedulerOnce.Do(func() {\n\t\tgo runAutoUpdater(ctx)\n\t})\n}\n\nfunc runAutoUpdater(ctx context.Context) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\n\tticker := time.NewTicker(updateCheckInterval)\n\tdefer ticker.Stop()\n\n\trunOnce := func() {\n\t\tcfg := currentConfigPtr.Load()\n\t\tif cfg == nil {\n\t\t\tlog.Debug(\"management asset auto-updater skipped: config not yet available\")\n\t\t\treturn\n\t\t}\n\t\tif cfg.RemoteManagement.DisableControlPanel {\n\t\t\tlog.Debug(\"management asset auto-updater skipped: control panel disabled\")\n\t\t\treturn\n\t\t}\n\n\t\tconfigPath, _ := schedulerConfigPath.Load().(string)\n\t\tstaticDir := StaticDir(configPath)\n\t\tEnsureLatestManagementHTML(ctx, staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)\n\t}\n\n\trunOnce()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\trunOnce()\n\t\t}\n\t}\n}\n\nfunc newHTTPClient(proxyURL string) *http.Client {\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\n\tsdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}\n\tutil.SetProxy(sdkCfg, client)\n\n\treturn client\n}\n\ntype releaseAsset struct {\n\tName               string `json:\"name\"`\n\tBrowserDownloadURL string `json:\"browser_download_url\"`\n\tDigest             string `json:\"digest\"`\n}\n\ntype releaseResponse struct {\n\tAssets []releaseAsset `json:\"assets\"`\n}\n\n// StaticDir resolves the directory that stores the management control panel asset.\nfunc StaticDir(configFilePath string) string {\n\tif override := strings.TrimSpace(os.Getenv(\"MANAGEMENT_STATIC_PATH\")); override != \"\" {\n\t\tcleaned := filepath.Clean(override)\n\t\tif strings.EqualFold(filepath.Base(cleaned), managementAssetName) {\n\t\t\treturn filepath.Dir(cleaned)\n\t\t}\n\t\treturn cleaned\n\t}\n\n\tif writable := util.WritablePath(); writable != \"\" {\n\t\treturn filepath.Join(writable, \"static\")\n\t}\n\n\tconfigFilePath = strings.TrimSpace(configFilePath)\n\tif configFilePath == \"\" {\n\t\treturn \"\"\n\t}\n\n\tbase := filepath.Dir(configFilePath)\n\tfileInfo, err := os.Stat(configFilePath)\n\tif err == nil {\n\t\tif fileInfo.IsDir() {\n\t\t\tbase = configFilePath\n\t\t}\n\t}\n\n\treturn filepath.Join(base, \"static\")\n}\n\n// FilePath resolves the absolute path to the management control panel asset.\nfunc FilePath(configFilePath string) string {\n\tif override := strings.TrimSpace(os.Getenv(\"MANAGEMENT_STATIC_PATH\")); override != \"\" {\n\t\tcleaned := filepath.Clean(override)\n\t\tif strings.EqualFold(filepath.Base(cleaned), managementAssetName) {\n\t\t\treturn cleaned\n\t\t}\n\t\treturn filepath.Join(cleaned, ManagementFileName)\n\t}\n\n\tdir := StaticDir(configFilePath)\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, ManagementFileName)\n}\n\n// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.\n// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt.\nfunc EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\n\tstaticDir = strings.TrimSpace(staticDir)\n\tif staticDir == \"\" {\n\t\tlog.Debug(\"management asset sync skipped: empty static directory\")\n\t\treturn false\n\t}\n\tlocalPath := filepath.Join(staticDir, managementAssetName)\n\n\t_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {\n\t\tlastUpdateCheckMu.Lock()\n\t\tnow := time.Now()\n\t\ttimeSinceLastAttempt := now.Sub(lastUpdateCheckTime)\n\t\tif !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {\n\t\t\tlastUpdateCheckMu.Unlock()\n\t\t\tlog.Debugf(\n\t\t\t\t\"management asset sync skipped by throttle: last attempt %v ago (interval %v)\",\n\t\t\t\ttimeSinceLastAttempt.Round(time.Second),\n\t\t\t\tmanagementSyncMinInterval,\n\t\t\t)\n\t\t\treturn nil, nil\n\t\t}\n\t\tlastUpdateCheckTime = now\n\t\tlastUpdateCheckMu.Unlock()\n\n\t\tlocalFileMissing := false\n\t\tif _, errStat := os.Stat(localPath); errStat != nil {\n\t\t\tif errors.Is(errStat, os.ErrNotExist) {\n\t\t\t\tlocalFileMissing = true\n\t\t\t} else {\n\t\t\t\tlog.WithError(errStat).Debug(\"failed to stat local management asset\")\n\t\t\t}\n\t\t}\n\n\t\tif errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {\n\t\t\tlog.WithError(errMkdirAll).Warn(\"failed to prepare static directory for management asset\")\n\t\t\treturn nil, nil\n\t\t}\n\n\t\treleaseURL := resolveReleaseURL(panelRepository)\n\t\tclient := newHTTPClient(proxyURL)\n\n\t\tlocalHash, err := fileSHA256(localPath)\n\t\tif err != nil {\n\t\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\t\tlog.WithError(err).Debug(\"failed to read local management asset hash\")\n\t\t\t}\n\t\t\tlocalHash = \"\"\n\t\t}\n\n\t\tasset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)\n\t\tif err != nil {\n\t\t\tif localFileMissing {\n\t\t\t\tlog.WithError(err).Warn(\"failed to fetch latest management release information, trying fallback page\")\n\t\t\t\tif ensureFallbackManagementHTML(ctx, client, localPath) {\n\t\t\t\t\treturn nil, nil\n\t\t\t\t}\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tlog.WithError(err).Warn(\"failed to fetch latest management release information\")\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tif remoteHash != \"\" && localHash != \"\" && strings.EqualFold(remoteHash, localHash) {\n\t\t\tlog.Debug(\"management asset is already up to date\")\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tdata, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)\n\t\tif err != nil {\n\t\t\tif localFileMissing {\n\t\t\t\tlog.WithError(err).Warn(\"failed to download management asset, trying fallback page\")\n\t\t\t\tif ensureFallbackManagementHTML(ctx, client, localPath) {\n\t\t\t\t\treturn nil, nil\n\t\t\t\t}\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tlog.WithError(err).Warn(\"failed to download management asset\")\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tif remoteHash != \"\" && !strings.EqualFold(remoteHash, downloadedHash) {\n\t\t\tlog.Warnf(\"remote digest mismatch for management asset: expected %s got %s\", remoteHash, downloadedHash)\n\t\t}\n\n\t\tif err = atomicWriteFile(localPath, data); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to update management asset on disk\")\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tlog.Infof(\"management asset updated successfully (hash=%s)\", downloadedHash)\n\t\treturn nil, nil\n\t})\n\n\t_, err := os.Stat(localPath)\n\treturn err == nil\n}\n\nfunc ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {\n\tdata, downloadedHash, err := downloadAsset(ctx, client, defaultManagementFallbackURL)\n\tif err != nil {\n\t\tlog.WithError(err).Warn(\"failed to download fallback management control panel page\")\n\t\treturn false\n\t}\n\n\tif err = atomicWriteFile(localPath, data); err != nil {\n\t\tlog.WithError(err).Warn(\"failed to persist fallback management control panel page\")\n\t\treturn false\n\t}\n\n\tlog.Infof(\"management asset updated from fallback page successfully (hash=%s)\", downloadedHash)\n\treturn true\n}\n\nfunc resolveReleaseURL(repo string) string {\n\trepo = strings.TrimSpace(repo)\n\tif repo == \"\" {\n\t\treturn defaultManagementReleaseURL\n\t}\n\n\tparsed, err := url.Parse(repo)\n\tif err != nil || parsed.Host == \"\" {\n\t\treturn defaultManagementReleaseURL\n\t}\n\n\thost := strings.ToLower(parsed.Host)\n\tparsed.Path = strings.TrimSuffix(parsed.Path, \"/\")\n\n\tif host == \"api.github.com\" {\n\t\tif !strings.HasSuffix(strings.ToLower(parsed.Path), \"/releases/latest\") {\n\t\t\tparsed.Path = parsed.Path + \"/releases/latest\"\n\t\t}\n\t\treturn parsed.String()\n\t}\n\n\tif host == \"github.com\" {\n\t\tparts := strings.Split(strings.Trim(parsed.Path, \"/\"), \"/\")\n\t\tif len(parts) >= 2 && parts[0] != \"\" && parts[1] != \"\" {\n\t\t\trepoName := strings.TrimSuffix(parts[1], \".git\")\n\t\t\treturn fmt.Sprintf(\"https://api.github.com/repos/%s/%s/releases/latest\", parts[0], repoName)\n\t\t}\n\t}\n\n\treturn defaultManagementReleaseURL\n}\n\nfunc fetchLatestAsset(ctx context.Context, client *http.Client, releaseURL string) (*releaseAsset, string, error) {\n\tif strings.TrimSpace(releaseURL) == \"\" {\n\t\treleaseURL = defaultManagementReleaseURL\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"create release request: %w\", err)\n\t}\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Set(\"User-Agent\", httpUserAgent)\n\tgitURL := strings.ToLower(strings.TrimSpace(os.Getenv(\"GITSTORE_GIT_URL\")))\n\tif tok := strings.TrimSpace(os.Getenv(\"GITSTORE_GIT_TOKEN\")); tok != \"\" && strings.Contains(gitURL, \"github.com\") {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+tok)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"execute release request: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))\n\t\treturn nil, \"\", fmt.Errorf(\"unexpected release status %d: %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tvar release releaseResponse\n\tif err = json.NewDecoder(resp.Body).Decode(&release); err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"decode release response: %w\", err)\n\t}\n\n\tfor i := range release.Assets {\n\t\tasset := &release.Assets[i]\n\t\tif strings.EqualFold(asset.Name, managementAssetName) {\n\t\t\tremoteHash := parseDigest(asset.Digest)\n\t\t\treturn asset, remoteHash, nil\n\t\t}\n\t}\n\n\treturn nil, \"\", fmt.Errorf(\"management asset %s not found in latest release\", managementAssetName)\n}\n\nfunc downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {\n\tif strings.TrimSpace(downloadURL) == \"\" {\n\t\treturn nil, \"\", fmt.Errorf(\"empty download url\")\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"create download request: %w\", err)\n\t}\n\treq.Header.Set(\"User-Agent\", httpUserAgent)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"execute download request: %w\", err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))\n\t\treturn nil, \"\", fmt.Errorf(\"unexpected download status %d: %s\", resp.StatusCode, strings.TrimSpace(string(body)))\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"read download body: %w\", err)\n\t}\n\n\tsum := sha256.Sum256(data)\n\treturn data, hex.EncodeToString(sum[:]), nil\n}\n\nfunc fileSHA256(path string) (string, error) {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer func() {\n\t\t_ = file.Close()\n\t}()\n\n\th := sha256.New()\n\tif _, err = io.Copy(h, file); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn hex.EncodeToString(h.Sum(nil)), nil\n}\n\nfunc atomicWriteFile(path string, data []byte) error {\n\ttmpFile, err := os.CreateTemp(filepath.Dir(path), \"management-*.html\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpName := tmpFile.Name()\n\tdefer func() {\n\t\t_ = tmpFile.Close()\n\t\t_ = os.Remove(tmpName)\n\t}()\n\n\tif _, err = tmpFile.Write(data); err != nil {\n\t\treturn err\n\t}\n\n\tif err = tmpFile.Chmod(0o644); err != nil {\n\t\treturn err\n\t}\n\n\tif err = tmpFile.Close(); err != nil {\n\t\treturn err\n\t}\n\n\tif err = os.Rename(tmpName, path); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc parseDigest(digest string) string {\n\tdigest = strings.TrimSpace(digest)\n\tif digest == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif idx := strings.Index(digest, \":\"); idx >= 0 {\n\t\tdigest = digest[idx+1:]\n\t}\n\n\treturn strings.ToLower(strings.TrimSpace(digest))\n}\n"
  },
  {
    "path": "internal/misc/claude_code_instructions.go",
    "content": "// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.\n// This package contains general-purpose helpers and embedded resources that do not fit into\n// more specific domain packages. It includes embedded instructional text for Claude Code-related operations.\npackage misc\n\nimport _ \"embed\"\n\n// ClaudeCodeInstructions holds the content of the claude_code_instructions.txt file,\n// which is embedded into the application binary at compile time. This variable\n// contains specific instructions for Claude Code model interactions and code generation guidance.\n//\n//go:embed claude_code_instructions.txt\nvar ClaudeCodeInstructions string\n"
  },
  {
    "path": "internal/misc/claude_code_instructions.txt",
    "content": "[{\"type\":\"text\",\"text\":\"You are a Claude agent, built on Anthropic's Claude Agent SDK.\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}]"
  },
  {
    "path": "internal/misc/copy-example-config.go",
    "content": "package misc\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nfunc CopyConfigTemplate(src, dst string) error {\n\tin, err := os.Open(src)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif errClose := in.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close source config file\")\n\t\t}\n\t}()\n\n\tif err = os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {\n\t\treturn err\n\t}\n\n\tout, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif errClose := out.Close(); errClose != nil {\n\t\t\tlog.WithError(errClose).Warn(\"failed to close destination config file\")\n\t\t}\n\t}()\n\n\tif _, err = io.Copy(out, in); err != nil {\n\t\treturn err\n\t}\n\treturn out.Sync()\n}\n"
  },
  {
    "path": "internal/misc/credentials.go",
    "content": "package misc\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// Separator used to visually group related log lines.\nvar credentialSeparator = strings.Repeat(\"-\", 67)\n\n// LogSavingCredentials emits a consistent log message when persisting auth material.\nfunc LogSavingCredentials(path string) {\n\tif path == \"\" {\n\t\treturn\n\t}\n\t// Use filepath.Clean so logs remain stable even if callers pass redundant separators.\n\tfmt.Printf(\"Saving credentials to %s\\n\", filepath.Clean(path))\n}\n\n// LogCredentialSeparator adds a visual separator to group auth/key processing logs.\nfunc LogCredentialSeparator() {\n\tlog.Debug(credentialSeparator)\n}\n\n// MergeMetadata serializes the source struct into a map and merges the provided metadata into it.\nfunc MergeMetadata(source any, metadata map[string]any) (map[string]any, error) {\n\tvar data map[string]any\n\n\t// Fast path: if source is already a map, just copy it to avoid mutation of original\n\tif srcMap, ok := source.(map[string]any); ok {\n\t\tdata = make(map[string]any, len(srcMap)+len(metadata))\n\t\tfor k, v := range srcMap {\n\t\t\tdata[k] = v\n\t\t}\n\t} else {\n\t\t// Slow path: marshal to JSON and back to map to respect JSON tags\n\t\ttemp, err := json.Marshal(source)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to marshal source: %w\", err)\n\t\t}\n\t\tif err := json.Unmarshal(temp, &data); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to unmarshal to map: %w\", err)\n\t\t}\n\t}\n\n\t// Merge extra metadata\n\tif metadata != nil {\n\t\tif data == nil {\n\t\t\tdata = make(map[string]any)\n\t\t}\n\t\tfor k, v := range metadata {\n\t\t\tdata[k] = v\n\t\t}\n\t}\n\n\treturn data, nil\n}\n"
  },
  {
    "path": "internal/misc/header_utils.go",
    "content": "// Package misc provides miscellaneous utility functions for the CLI Proxy API server.\n// It includes helper functions for HTTP header manipulation and other common operations\n// that don't fit into more specific packages.\npackage misc\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"strings\"\n)\n\nconst (\n\t// GeminiCLIVersion is the version string reported in the User-Agent for upstream requests.\n\tGeminiCLIVersion = \"0.31.0\"\n\n\t// GeminiCLIApiClientHeader is the value for the X-Goog-Api-Client header sent to the Gemini CLI upstream.\n\tGeminiCLIApiClientHeader = \"google-genai-sdk/1.41.0 gl-node/v22.19.0\"\n)\n\n// geminiCLIOS maps Go runtime OS names to the Node.js-style platform strings used by Gemini CLI.\nfunc geminiCLIOS() string {\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\treturn \"win32\"\n\tdefault:\n\t\treturn runtime.GOOS\n\t}\n}\n\n// geminiCLIArch maps Go runtime architecture names to the Node.js-style arch strings used by Gemini CLI.\nfunc geminiCLIArch() string {\n\tswitch runtime.GOARCH {\n\tcase \"amd64\":\n\t\treturn \"x64\"\n\tcase \"386\":\n\t\treturn \"x86\"\n\tdefault:\n\t\treturn runtime.GOARCH\n\t}\n}\n\n// GeminiCLIUserAgent returns a User-Agent string that matches the Gemini CLI format.\n// The model parameter is included in the UA; pass \"\" or \"unknown\" when the model is not applicable.\nfunc GeminiCLIUserAgent(model string) string {\n\tif model == \"\" {\n\t\tmodel = \"unknown\"\n\t}\n\treturn fmt.Sprintf(\"GeminiCLI/%s/%s (%s; %s)\", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch())\n}\n\n// ScrubProxyAndFingerprintHeaders removes all headers that could reveal\n// proxy infrastructure, client identity, or browser fingerprints from an\n// outgoing request. This ensures requests to upstream services look like they\n// originate directly from a native client rather than a third-party client\n// behind a reverse proxy.\nfunc ScrubProxyAndFingerprintHeaders(req *http.Request) {\n\tif req == nil {\n\t\treturn\n\t}\n\n\t// --- Proxy tracing headers ---\n\treq.Header.Del(\"X-Forwarded-For\")\n\treq.Header.Del(\"X-Forwarded-Host\")\n\treq.Header.Del(\"X-Forwarded-Proto\")\n\treq.Header.Del(\"X-Forwarded-Port\")\n\treq.Header.Del(\"X-Real-IP\")\n\treq.Header.Del(\"Forwarded\")\n\treq.Header.Del(\"Via\")\n\n\t// --- Client identity headers ---\n\treq.Header.Del(\"X-Title\")\n\treq.Header.Del(\"X-Stainless-Lang\")\n\treq.Header.Del(\"X-Stainless-Package-Version\")\n\treq.Header.Del(\"X-Stainless-Os\")\n\treq.Header.Del(\"X-Stainless-Arch\")\n\treq.Header.Del(\"X-Stainless-Runtime\")\n\treq.Header.Del(\"X-Stainless-Runtime-Version\")\n\treq.Header.Del(\"Http-Referer\")\n\treq.Header.Del(\"Referer\")\n\n\t// --- Browser / Chromium fingerprint headers ---\n\t// These are sent by Electron-based clients (e.g. CherryStudio) using the\n\t// Fetch API, but NOT by Node.js https module (which Antigravity uses).\n\treq.Header.Del(\"Sec-Ch-Ua\")\n\treq.Header.Del(\"Sec-Ch-Ua-Mobile\")\n\treq.Header.Del(\"Sec-Ch-Ua-Platform\")\n\treq.Header.Del(\"Sec-Fetch-Mode\")\n\treq.Header.Del(\"Sec-Fetch-Site\")\n\treq.Header.Del(\"Sec-Fetch-Dest\")\n\treq.Header.Del(\"Priority\")\n\n\t// --- Encoding negotiation ---\n\t// Antigravity (Node.js) sends \"gzip, deflate, br\" by default;\n\t// Electron-based clients may add \"zstd\" which is a fingerprint mismatch.\n\treq.Header.Del(\"Accept-Encoding\")\n}\n\n// EnsureHeader ensures that a header exists in the target header map by checking\n// multiple sources in order of priority: source headers, existing target headers,\n// and finally the default value. It only sets the header if it's not already present\n// and the value is not empty after trimming whitespace.\n//\n// Parameters:\n//   - target: The target header map to modify\n//   - source: The source header map to check first (can be nil)\n//   - key: The header key to ensure\n//   - defaultValue: The default value to use if no other source provides a value\nfunc EnsureHeader(target http.Header, source http.Header, key, defaultValue string) {\n\tif target == nil {\n\t\treturn\n\t}\n\tif source != nil {\n\t\tif val := strings.TrimSpace(source.Get(key)); val != \"\" {\n\t\t\ttarget.Set(key, val)\n\t\t\treturn\n\t\t}\n\t}\n\tif strings.TrimSpace(target.Get(key)) != \"\" {\n\t\treturn\n\t}\n\tif val := strings.TrimSpace(defaultValue); val != \"\" {\n\t\ttarget.Set(key, val)\n\t}\n}\n"
  },
  {
    "path": "internal/misc/mime-type.go",
    "content": "// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.\n// This package contains general-purpose helpers and embedded resources that do not fit into\n// more specific domain packages. It includes a comprehensive MIME type mapping for file operations.\npackage misc\n\n// MimeTypes is a comprehensive map of file extensions to their corresponding MIME types.\n// This map is used to determine the Content-Type header for file uploads and other\n// operations where the MIME type needs to be identified from a file extension.\n// The list is extensive to cover a wide range of common and uncommon file formats.\nvar MimeTypes = map[string]string{\n\t\"ez\":          \"application/andrew-inset\",\n\t\"aw\":          \"application/applixware\",\n\t\"atom\":        \"application/atom+xml\",\n\t\"atomcat\":     \"application/atomcat+xml\",\n\t\"atomsvc\":     \"application/atomsvc+xml\",\n\t\"ccxml\":       \"application/ccxml+xml\",\n\t\"cdmia\":       \"application/cdmi-capability\",\n\t\"cdmic\":       \"application/cdmi-container\",\n\t\"cdmid\":       \"application/cdmi-domain\",\n\t\"cdmio\":       \"application/cdmi-object\",\n\t\"cdmiq\":       \"application/cdmi-queue\",\n\t\"cu\":          \"application/cu-seeme\",\n\t\"davmount\":    \"application/davmount+xml\",\n\t\"dbk\":         \"application/docbook+xml\",\n\t\"dssc\":        \"application/dssc+der\",\n\t\"xdssc\":       \"application/dssc+xml\",\n\t\"ecma\":        \"application/ecmascript\",\n\t\"emma\":        \"application/emma+xml\",\n\t\"epub\":        \"application/epub+zip\",\n\t\"exi\":         \"application/exi\",\n\t\"pfr\":         \"application/font-tdpfr\",\n\t\"gml\":         \"application/gml+xml\",\n\t\"gpx\":         \"application/gpx+xml\",\n\t\"gxf\":         \"application/gxf\",\n\t\"stk\":         \"application/hyperstudio\",\n\t\"ink\":         \"application/inkml+xml\",\n\t\"ipfix\":       \"application/ipfix\",\n\t\"jar\":         \"application/java-archive\",\n\t\"ser\":         \"application/java-serialized-object\",\n\t\"class\":       \"application/java-vm\",\n\t\"js\":          \"application/javascript\",\n\t\"json\":        \"application/json\",\n\t\"jsonml\":      \"application/jsonml+json\",\n\t\"lostxml\":     \"application/lost+xml\",\n\t\"hqx\":         \"application/mac-binhex40\",\n\t\"cpt\":         \"application/mac-compactpro\",\n\t\"mads\":        \"application/mads+xml\",\n\t\"mrc\":         \"application/marc\",\n\t\"mrcx\":        \"application/marcxml+xml\",\n\t\"ma\":          \"application/mathematica\",\n\t\"mathml\":      \"application/mathml+xml\",\n\t\"mbox\":        \"application/mbox\",\n\t\"mscml\":       \"application/mediaservercontrol+xml\",\n\t\"metalink\":    \"application/metalink+xml\",\n\t\"meta4\":       \"application/metalink4+xml\",\n\t\"mets\":        \"application/mets+xml\",\n\t\"mods\":        \"application/mods+xml\",\n\t\"m21\":         \"application/mp21\",\n\t\"mp4s\":        \"application/mp4\",\n\t\"doc\":         \"application/msword\",\n\t\"mxf\":         \"application/mxf\",\n\t\"bin\":         \"application/octet-stream\",\n\t\"oda\":         \"application/oda\",\n\t\"opf\":         \"application/oebps-package+xml\",\n\t\"ogx\":         \"application/ogg\",\n\t\"omdoc\":       \"application/omdoc+xml\",\n\t\"onepkg\":      \"application/onenote\",\n\t\"oxps\":        \"application/oxps\",\n\t\"xer\":         \"application/patch-ops-error+xml\",\n\t\"pdf\":         \"application/pdf\",\n\t\"pgp\":         \"application/pgp-encrypted\",\n\t\"asc\":         \"application/pgp-signature\",\n\t\"prf\":         \"application/pics-rules\",\n\t\"p10\":         \"application/pkcs10\",\n\t\"p7c\":         \"application/pkcs7-mime\",\n\t\"p7s\":         \"application/pkcs7-signature\",\n\t\"p8\":          \"application/pkcs8\",\n\t\"ac\":          \"application/pkix-attr-cert\",\n\t\"cer\":         \"application/pkix-cert\",\n\t\"crl\":         \"application/pkix-crl\",\n\t\"pkipath\":     \"application/pkix-pkipath\",\n\t\"pki\":         \"application/pkixcmp\",\n\t\"pls\":         \"application/pls+xml\",\n\t\"ai\":          \"application/postscript\",\n\t\"cww\":         \"application/prs.cww\",\n\t\"pskcxml\":     \"application/pskc+xml\",\n\t\"rdf\":         \"application/rdf+xml\",\n\t\"rif\":         \"application/reginfo+xml\",\n\t\"rnc\":         \"application/relax-ng-compact-syntax\",\n\t\"rld\":         \"application/resource-lists-diff+xml\",\n\t\"rl\":          \"application/resource-lists+xml\",\n\t\"rs\":          \"application/rls-services+xml\",\n\t\"gbr\":         \"application/rpki-ghostbusters\",\n\t\"mft\":         \"application/rpki-manifest\",\n\t\"roa\":         \"application/rpki-roa\",\n\t\"rsd\":         \"application/rsd+xml\",\n\t\"rss\":         \"application/rss+xml\",\n\t\"rtf\":         \"application/rtf\",\n\t\"sbml\":        \"application/sbml+xml\",\n\t\"scq\":         \"application/scvp-cv-request\",\n\t\"scs\":         \"application/scvp-cv-response\",\n\t\"spq\":         \"application/scvp-vp-request\",\n\t\"spp\":         \"application/scvp-vp-response\",\n\t\"sdp\":         \"application/sdp\",\n\t\"setpay\":      \"application/set-payment-initiation\",\n\t\"setreg\":      \"application/set-registration-initiation\",\n\t\"shf\":         \"application/shf+xml\",\n\t\"smi\":         \"application/smil+xml\",\n\t\"rq\":          \"application/sparql-query\",\n\t\"srx\":         \"application/sparql-results+xml\",\n\t\"gram\":        \"application/srgs\",\n\t\"grxml\":       \"application/srgs+xml\",\n\t\"sru\":         \"application/sru+xml\",\n\t\"ssdl\":        \"application/ssdl+xml\",\n\t\"ssml\":        \"application/ssml+xml\",\n\t\"tei\":         \"application/tei+xml\",\n\t\"tfi\":         \"application/thraud+xml\",\n\t\"tsd\":         \"application/timestamped-data\",\n\t\"plb\":         \"application/vnd.3gpp.pic-bw-large\",\n\t\"psb\":         \"application/vnd.3gpp.pic-bw-small\",\n\t\"pvb\":         \"application/vnd.3gpp.pic-bw-var\",\n\t\"tcap\":        \"application/vnd.3gpp2.tcap\",\n\t\"pwn\":         \"application/vnd.3m.post-it-notes\",\n\t\"aso\":         \"application/vnd.accpac.simply.aso\",\n\t\"imp\":         \"application/vnd.accpac.simply.imp\",\n\t\"acu\":         \"application/vnd.acucobol\",\n\t\"acutc\":       \"application/vnd.acucorp\",\n\t\"air\":         \"application/vnd.adobe.air-application-installer-package+zip\",\n\t\"fcdt\":        \"application/vnd.adobe.formscentral.fcdt\",\n\t\"fxp\":         \"application/vnd.adobe.fxp\",\n\t\"xdp\":         \"application/vnd.adobe.xdp+xml\",\n\t\"xfdf\":        \"application/vnd.adobe.xfdf\",\n\t\"ahead\":       \"application/vnd.ahead.space\",\n\t\"azf\":         \"application/vnd.airzip.filesecure.azf\",\n\t\"azs\":         \"application/vnd.airzip.filesecure.azs\",\n\t\"azw\":         \"application/vnd.amazon.ebook\",\n\t\"acc\":         \"application/vnd.americandynamics.acc\",\n\t\"ami\":         \"application/vnd.amiga.ami\",\n\t\"apk\":         \"application/vnd.android.package-archive\",\n\t\"cii\":         \"application/vnd.anser-web-certificate-issue-initiation\",\n\t\"fti\":         \"application/vnd.anser-web-funds-transfer-initiation\",\n\t\"atx\":         \"application/vnd.antix.game-component\",\n\t\"mpkg\":        \"application/vnd.apple.installer+xml\",\n\t\"m3u8\":        \"application/vnd.apple.mpegurl\",\n\t\"swi\":         \"application/vnd.aristanetworks.swi\",\n\t\"iota\":        \"application/vnd.astraea-software.iota\",\n\t\"aep\":         \"application/vnd.audiograph\",\n\t\"mpm\":         \"application/vnd.blueice.multipass\",\n\t\"bmi\":         \"application/vnd.bmi\",\n\t\"rep\":         \"application/vnd.businessobjects\",\n\t\"cdxml\":       \"application/vnd.chemdraw+xml\",\n\t\"mmd\":         \"application/vnd.chipnuts.karaoke-mmd\",\n\t\"cdy\":         \"application/vnd.cinderella\",\n\t\"cla\":         \"application/vnd.claymore\",\n\t\"rp9\":         \"application/vnd.cloanto.rp9\",\n\t\"c4d\":         \"application/vnd.clonk.c4group\",\n\t\"c11amc\":      \"application/vnd.cluetrust.cartomobile-config\",\n\t\"c11amz\":      \"application/vnd.cluetrust.cartomobile-config-pkg\",\n\t\"csp\":         \"application/vnd.commonspace\",\n\t\"cdbcmsg\":     \"application/vnd.contact.cmsg\",\n\t\"cmc\":         \"application/vnd.cosmocaller\",\n\t\"clkx\":        \"application/vnd.crick.clicker\",\n\t\"clkk\":        \"application/vnd.crick.clicker.keyboard\",\n\t\"clkp\":        \"application/vnd.crick.clicker.palette\",\n\t\"clkt\":        \"application/vnd.crick.clicker.template\",\n\t\"clkw\":        \"application/vnd.crick.clicker.wordbank\",\n\t\"wbs\":         \"application/vnd.criticaltools.wbs+xml\",\n\t\"pml\":         \"application/vnd.ctc-posml\",\n\t\"ppd\":         \"application/vnd.cups-ppd\",\n\t\"car\":         \"application/vnd.curl.car\",\n\t\"pcurl\":       \"application/vnd.curl.pcurl\",\n\t\"dart\":        \"application/vnd.dart\",\n\t\"rdz\":         \"application/vnd.data-vision.rdz\",\n\t\"uvd\":         \"application/vnd.dece.data\",\n\t\"fe_launch\":   \"application/vnd.denovo.fcselayout-link\",\n\t\"dna\":         \"application/vnd.dna\",\n\t\"mlp\":         \"application/vnd.dolby.mlp\",\n\t\"dpg\":         \"application/vnd.dpgraph\",\n\t\"dfac\":        \"application/vnd.dreamfactory\",\n\t\"kpxx\":        \"application/vnd.ds-keypoint\",\n\t\"ait\":         \"application/vnd.dvb.ait\",\n\t\"svc\":         \"application/vnd.dvb.service\",\n\t\"geo\":         \"application/vnd.dynageo\",\n\t\"mag\":         \"application/vnd.ecowin.chart\",\n\t\"nml\":         \"application/vnd.enliven\",\n\t\"esf\":         \"application/vnd.epson.esf\",\n\t\"msf\":         \"application/vnd.epson.msf\",\n\t\"qam\":         \"application/vnd.epson.quickanime\",\n\t\"slt\":         \"application/vnd.epson.salt\",\n\t\"ssf\":         \"application/vnd.epson.ssf\",\n\t\"es3\":         \"application/vnd.eszigno3+xml\",\n\t\"ez2\":         \"application/vnd.ezpix-album\",\n\t\"ez3\":         \"application/vnd.ezpix-package\",\n\t\"fdf\":         \"application/vnd.fdf\",\n\t\"mseed\":       \"application/vnd.fdsn.mseed\",\n\t\"dataless\":    \"application/vnd.fdsn.seed\",\n\t\"gph\":         \"application/vnd.flographit\",\n\t\"ftc\":         \"application/vnd.fluxtime.clip\",\n\t\"book\":        \"application/vnd.framemaker\",\n\t\"fnc\":         \"application/vnd.frogans.fnc\",\n\t\"ltf\":         \"application/vnd.frogans.ltf\",\n\t\"fsc\":         \"application/vnd.fsc.weblaunch\",\n\t\"oas\":         \"application/vnd.fujitsu.oasys\",\n\t\"oa2\":         \"application/vnd.fujitsu.oasys2\",\n\t\"oa3\":         \"application/vnd.fujitsu.oasys3\",\n\t\"fg5\":         \"application/vnd.fujitsu.oasysgp\",\n\t\"bh2\":         \"application/vnd.fujitsu.oasysprs\",\n\t\"ddd\":         \"application/vnd.fujixerox.ddd\",\n\t\"xdw\":         \"application/vnd.fujixerox.docuworks\",\n\t\"xbd\":         \"application/vnd.fujixerox.docuworks.binder\",\n\t\"fzs\":         \"application/vnd.fuzzysheet\",\n\t\"txd\":         \"application/vnd.genomatix.tuxedo\",\n\t\"ggb\":         \"application/vnd.geogebra.file\",\n\t\"ggt\":         \"application/vnd.geogebra.tool\",\n\t\"gex\":         \"application/vnd.geometry-explorer\",\n\t\"gxt\":         \"application/vnd.geonext\",\n\t\"g2w\":         \"application/vnd.geoplan\",\n\t\"g3w\":         \"application/vnd.geospace\",\n\t\"gmx\":         \"application/vnd.gmx\",\n\t\"kml\":         \"application/vnd.google-earth.kml+xml\",\n\t\"kmz\":         \"application/vnd.google-earth.kmz\",\n\t\"gqf\":         \"application/vnd.grafeq\",\n\t\"gac\":         \"application/vnd.groove-account\",\n\t\"ghf\":         \"application/vnd.groove-help\",\n\t\"gim\":         \"application/vnd.groove-identity-message\",\n\t\"grv\":         \"application/vnd.groove-injector\",\n\t\"gtm\":         \"application/vnd.groove-tool-message\",\n\t\"tpl\":         \"application/vnd.groove-tool-template\",\n\t\"vcg\":         \"application/vnd.groove-vcard\",\n\t\"hal\":         \"application/vnd.hal+xml\",\n\t\"zmm\":         \"application/vnd.handheld-entertainment+xml\",\n\t\"hbci\":        \"application/vnd.hbci\",\n\t\"les\":         \"application/vnd.hhe.lesson-player\",\n\t\"hpgl\":        \"application/vnd.hp-hpgl\",\n\t\"hpid\":        \"application/vnd.hp-hpid\",\n\t\"hps\":         \"application/vnd.hp-hps\",\n\t\"jlt\":         \"application/vnd.hp-jlyt\",\n\t\"pcl\":         \"application/vnd.hp-pcl\",\n\t\"pclxl\":       \"application/vnd.hp-pclxl\",\n\t\"sfd-hdstx\":   \"application/vnd.hydrostatix.sof-data\",\n\t\"mpy\":         \"application/vnd.ibm.minipay\",\n\t\"afp\":         \"application/vnd.ibm.modcap\",\n\t\"irm\":         \"application/vnd.ibm.rights-management\",\n\t\"sc\":          \"application/vnd.ibm.secure-container\",\n\t\"icc\":         \"application/vnd.iccprofile\",\n\t\"igl\":         \"application/vnd.igloader\",\n\t\"ivp\":         \"application/vnd.immervision-ivp\",\n\t\"ivu\":         \"application/vnd.immervision-ivu\",\n\t\"igm\":         \"application/vnd.insors.igm\",\n\t\"xpw\":         \"application/vnd.intercon.formnet\",\n\t\"i2g\":         \"application/vnd.intergeo\",\n\t\"qbo\":         \"application/vnd.intu.qbo\",\n\t\"qfx\":         \"application/vnd.intu.qfx\",\n\t\"rcprofile\":   \"application/vnd.ipunplugged.rcprofile\",\n\t\"irp\":         \"application/vnd.irepository.package+xml\",\n\t\"xpr\":         \"application/vnd.is-xpr\",\n\t\"fcs\":         \"application/vnd.isac.fcs\",\n\t\"jam\":         \"application/vnd.jam\",\n\t\"rms\":         \"application/vnd.jcp.javame.midlet-rms\",\n\t\"jisp\":        \"application/vnd.jisp\",\n\t\"joda\":        \"application/vnd.joost.joda-archive\",\n\t\"ktr\":         \"application/vnd.kahootz\",\n\t\"karbon\":      \"application/vnd.kde.karbon\",\n\t\"chrt\":        \"application/vnd.kde.kchart\",\n\t\"kfo\":         \"application/vnd.kde.kformula\",\n\t\"flw\":         \"application/vnd.kde.kivio\",\n\t\"kon\":         \"application/vnd.kde.kontour\",\n\t\"kpr\":         \"application/vnd.kde.kpresenter\",\n\t\"ksp\":         \"application/vnd.kde.kspread\",\n\t\"kwd\":         \"application/vnd.kde.kword\",\n\t\"htke\":        \"application/vnd.kenameaapp\",\n\t\"kia\":         \"application/vnd.kidspiration\",\n\t\"kne\":         \"application/vnd.kinar\",\n\t\"skd\":         \"application/vnd.koan\",\n\t\"sse\":         \"application/vnd.kodak-descriptor\",\n\t\"lasxml\":      \"application/vnd.las.las+xml\",\n\t\"lbd\":         \"application/vnd.llamagraphics.life-balance.desktop\",\n\t\"lbe\":         \"application/vnd.llamagraphics.life-balance.exchange+xml\",\n\t\"123\":         \"application/vnd.lotus-1-2-3\",\n\t\"apr\":         \"application/vnd.lotus-approach\",\n\t\"pre\":         \"application/vnd.lotus-freelance\",\n\t\"nsf\":         \"application/vnd.lotus-notes\",\n\t\"org\":         \"application/vnd.lotus-organizer\",\n\t\"scm\":         \"application/vnd.lotus-screencam\",\n\t\"lwp\":         \"application/vnd.lotus-wordpro\",\n\t\"portpkg\":     \"application/vnd.macports.portpkg\",\n\t\"mcd\":         \"application/vnd.mcd\",\n\t\"mc1\":         \"application/vnd.medcalcdata\",\n\t\"cdkey\":       \"application/vnd.mediastation.cdkey\",\n\t\"mwf\":         \"application/vnd.mfer\",\n\t\"mfm\":         \"application/vnd.mfmp\",\n\t\"flo\":         \"application/vnd.micrografx.flo\",\n\t\"igx\":         \"application/vnd.micrografx.igx\",\n\t\"mif\":         \"application/vnd.mif\",\n\t\"daf\":         \"application/vnd.mobius.daf\",\n\t\"dis\":         \"application/vnd.mobius.dis\",\n\t\"mbk\":         \"application/vnd.mobius.mbk\",\n\t\"mqy\":         \"application/vnd.mobius.mqy\",\n\t\"msl\":         \"application/vnd.mobius.msl\",\n\t\"plc\":         \"application/vnd.mobius.plc\",\n\t\"txf\":         \"application/vnd.mobius.txf\",\n\t\"mpn\":         \"application/vnd.mophun.application\",\n\t\"mpc\":         \"application/vnd.mophun.certificate\",\n\t\"xul\":         \"application/vnd.mozilla.xul+xml\",\n\t\"cil\":         \"application/vnd.ms-artgalry\",\n\t\"cab\":         \"application/vnd.ms-cab-compressed\",\n\t\"xls\":         \"application/vnd.ms-excel\",\n\t\"xlam\":        \"application/vnd.ms-excel.addin.macroenabled.12\",\n\t\"xlsb\":        \"application/vnd.ms-excel.sheet.binary.macroenabled.12\",\n\t\"xlsm\":        \"application/vnd.ms-excel.sheet.macroenabled.12\",\n\t\"xltm\":        \"application/vnd.ms-excel.template.macroenabled.12\",\n\t\"eot\":         \"application/vnd.ms-fontobject\",\n\t\"chm\":         \"application/vnd.ms-htmlhelp\",\n\t\"ims\":         \"application/vnd.ms-ims\",\n\t\"lrm\":         \"application/vnd.ms-lrm\",\n\t\"thmx\":        \"application/vnd.ms-officetheme\",\n\t\"cat\":         \"application/vnd.ms-pki.seccat\",\n\t\"stl\":         \"application/vnd.ms-pki.stl\",\n\t\"ppt\":         \"application/vnd.ms-powerpoint\",\n\t\"ppam\":        \"application/vnd.ms-powerpoint.addin.macroenabled.12\",\n\t\"pptm\":        \"application/vnd.ms-powerpoint.presentation.macroenabled.12\",\n\t\"sldm\":        \"application/vnd.ms-powerpoint.slide.macroenabled.12\",\n\t\"ppsm\":        \"application/vnd.ms-powerpoint.slideshow.macroenabled.12\",\n\t\"potm\":        \"application/vnd.ms-powerpoint.template.macroenabled.12\",\n\t\"mpp\":         \"application/vnd.ms-project\",\n\t\"docm\":        \"application/vnd.ms-word.document.macroenabled.12\",\n\t\"dotm\":        \"application/vnd.ms-word.template.macroenabled.12\",\n\t\"wps\":         \"application/vnd.ms-works\",\n\t\"wpl\":         \"application/vnd.ms-wpl\",\n\t\"xps\":         \"application/vnd.ms-xpsdocument\",\n\t\"mseq\":        \"application/vnd.mseq\",\n\t\"mus\":         \"application/vnd.musician\",\n\t\"msty\":        \"application/vnd.muvee.style\",\n\t\"taglet\":      \"application/vnd.mynfc\",\n\t\"nlu\":         \"application/vnd.neurolanguage.nlu\",\n\t\"nitf\":        \"application/vnd.nitf\",\n\t\"nnd\":         \"application/vnd.noblenet-directory\",\n\t\"nns\":         \"application/vnd.noblenet-sealer\",\n\t\"nnw\":         \"application/vnd.noblenet-web\",\n\t\"ngdat\":       \"application/vnd.nokia.n-gage.data\",\n\t\"n-gage\":      \"application/vnd.nokia.n-gage.symbian.install\",\n\t\"rpst\":        \"application/vnd.nokia.radio-preset\",\n\t\"rpss\":        \"application/vnd.nokia.radio-presets\",\n\t\"edm\":         \"application/vnd.novadigm.edm\",\n\t\"edx\":         \"application/vnd.novadigm.edx\",\n\t\"ext\":         \"application/vnd.novadigm.ext\",\n\t\"odc\":         \"application/vnd.oasis.opendocument.chart\",\n\t\"otc\":         \"application/vnd.oasis.opendocument.chart-template\",\n\t\"odb\":         \"application/vnd.oasis.opendocument.database\",\n\t\"odf\":         \"application/vnd.oasis.opendocument.formula\",\n\t\"odft\":        \"application/vnd.oasis.opendocument.formula-template\",\n\t\"odg\":         \"application/vnd.oasis.opendocument.graphics\",\n\t\"otg\":         \"application/vnd.oasis.opendocument.graphics-template\",\n\t\"odi\":         \"application/vnd.oasis.opendocument.image\",\n\t\"oti\":         \"application/vnd.oasis.opendocument.image-template\",\n\t\"odp\":         \"application/vnd.oasis.opendocument.presentation\",\n\t\"otp\":         \"application/vnd.oasis.opendocument.presentation-template\",\n\t\"ods\":         \"application/vnd.oasis.opendocument.spreadsheet\",\n\t\"ots\":         \"application/vnd.oasis.opendocument.spreadsheet-template\",\n\t\"odt\":         \"application/vnd.oasis.opendocument.text\",\n\t\"odm\":         \"application/vnd.oasis.opendocument.text-master\",\n\t\"ott\":         \"application/vnd.oasis.opendocument.text-template\",\n\t\"oth\":         \"application/vnd.oasis.opendocument.text-web\",\n\t\"xo\":          \"application/vnd.olpc-sugar\",\n\t\"dd2\":         \"application/vnd.oma.dd2+xml\",\n\t\"oxt\":         \"application/vnd.openofficeorg.extension\",\n\t\"pptx\":        \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\t\"sldx\":        \"application/vnd.openxmlformats-officedocument.presentationml.slide\",\n\t\"ppsx\":        \"application/vnd.openxmlformats-officedocument.presentationml.slideshow\",\n\t\"potx\":        \"application/vnd.openxmlformats-officedocument.presentationml.template\",\n\t\"xlsx\":        \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\t\"xltx\":        \"application/vnd.openxmlformats-officedocument.spreadsheetml.template\",\n\t\"docx\":        \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\t\"dotx\":        \"application/vnd.openxmlformats-officedocument.wordprocessingml.template\",\n\t\"mgp\":         \"application/vnd.osgeo.mapguide.package\",\n\t\"dp\":          \"application/vnd.osgi.dp\",\n\t\"esa\":         \"application/vnd.osgi.subsystem\",\n\t\"oprc\":        \"application/vnd.palm\",\n\t\"paw\":         \"application/vnd.pawaafile\",\n\t\"str\":         \"application/vnd.pg.format\",\n\t\"ei6\":         \"application/vnd.pg.osasli\",\n\t\"efif\":        \"application/vnd.picsel\",\n\t\"wg\":          \"application/vnd.pmi.widget\",\n\t\"plf\":         \"application/vnd.pocketlearn\",\n\t\"pbd\":         \"application/vnd.powerbuilder6\",\n\t\"box\":         \"application/vnd.previewsystems.box\",\n\t\"mgz\":         \"application/vnd.proteus.magazine\",\n\t\"qps\":         \"application/vnd.publishare-delta-tree\",\n\t\"ptid\":        \"application/vnd.pvi.ptid1\",\n\t\"qwd\":         \"application/vnd.quark.quarkxpress\",\n\t\"bed\":         \"application/vnd.realvnc.bed\",\n\t\"mxl\":         \"application/vnd.recordare.musicxml\",\n\t\"musicxml\":    \"application/vnd.recordare.musicxml+xml\",\n\t\"cryptonote\":  \"application/vnd.rig.cryptonote\",\n\t\"cod\":         \"application/vnd.rim.cod\",\n\t\"rm\":          \"application/vnd.rn-realmedia\",\n\t\"rmvb\":        \"application/vnd.rn-realmedia-vbr\",\n\t\"link66\":      \"application/vnd.route66.link66+xml\",\n\t\"st\":          \"application/vnd.sailingtracker.track\",\n\t\"see\":         \"application/vnd.seemail\",\n\t\"sema\":        \"application/vnd.sema\",\n\t\"semd\":        \"application/vnd.semd\",\n\t\"semf\":        \"application/vnd.semf\",\n\t\"ifm\":         \"application/vnd.shana.informed.formdata\",\n\t\"itp\":         \"application/vnd.shana.informed.formtemplate\",\n\t\"iif\":         \"application/vnd.shana.informed.interchange\",\n\t\"ipk\":         \"application/vnd.shana.informed.package\",\n\t\"twd\":         \"application/vnd.simtech-mindmapper\",\n\t\"mmf\":         \"application/vnd.smaf\",\n\t\"teacher\":     \"application/vnd.smart.teacher\",\n\t\"sdkd\":        \"application/vnd.solent.sdkm+xml\",\n\t\"dxp\":         \"application/vnd.spotfire.dxp\",\n\t\"sfs\":         \"application/vnd.spotfire.sfs\",\n\t\"sdc\":         \"application/vnd.stardivision.calc\",\n\t\"sda\":         \"application/vnd.stardivision.draw\",\n\t\"sdd\":         \"application/vnd.stardivision.impress\",\n\t\"smf\":         \"application/vnd.stardivision.math\",\n\t\"sdw\":         \"application/vnd.stardivision.writer\",\n\t\"sgl\":         \"application/vnd.stardivision.writer-global\",\n\t\"smzip\":       \"application/vnd.stepmania.package\",\n\t\"sm\":          \"application/vnd.stepmania.stepchart\",\n\t\"sxc\":         \"application/vnd.sun.xml.calc\",\n\t\"stc\":         \"application/vnd.sun.xml.calc.template\",\n\t\"sxd\":         \"application/vnd.sun.xml.draw\",\n\t\"std\":         \"application/vnd.sun.xml.draw.template\",\n\t\"sxi\":         \"application/vnd.sun.xml.impress\",\n\t\"sti\":         \"application/vnd.sun.xml.impress.template\",\n\t\"sxm\":         \"application/vnd.sun.xml.math\",\n\t\"sxw\":         \"application/vnd.sun.xml.writer\",\n\t\"sxg\":         \"application/vnd.sun.xml.writer.global\",\n\t\"stw\":         \"application/vnd.sun.xml.writer.template\",\n\t\"sus\":         \"application/vnd.sus-calendar\",\n\t\"svd\":         \"application/vnd.svd\",\n\t\"sis\":         \"application/vnd.symbian.install\",\n\t\"bdm\":         \"application/vnd.syncml.dm+wbxml\",\n\t\"xdm\":         \"application/vnd.syncml.dm+xml\",\n\t\"xsm\":         \"application/vnd.syncml+xml\",\n\t\"tao\":         \"application/vnd.tao.intent-module-archive\",\n\t\"cap\":         \"application/vnd.tcpdump.pcap\",\n\t\"tmo\":         \"application/vnd.tmobile-livetv\",\n\t\"tpt\":         \"application/vnd.trid.tpt\",\n\t\"mxs\":         \"application/vnd.triscape.mxs\",\n\t\"tra\":         \"application/vnd.trueapp\",\n\t\"ufd\":         \"application/vnd.ufdl\",\n\t\"utz\":         \"application/vnd.uiq.theme\",\n\t\"umj\":         \"application/vnd.umajin\",\n\t\"unityweb\":    \"application/vnd.unity\",\n\t\"uoml\":        \"application/vnd.uoml+xml\",\n\t\"vcx\":         \"application/vnd.vcx\",\n\t\"vss\":         \"application/vnd.visio\",\n\t\"vis\":         \"application/vnd.visionary\",\n\t\"vsf\":         \"application/vnd.vsf\",\n\t\"wbxml\":       \"application/vnd.wap.wbxml\",\n\t\"wmlc\":        \"application/vnd.wap.wmlc\",\n\t\"wmlsc\":       \"application/vnd.wap.wmlscriptc\",\n\t\"wtb\":         \"application/vnd.webturbo\",\n\t\"nbp\":         \"application/vnd.wolfram.player\",\n\t\"wpd\":         \"application/vnd.wordperfect\",\n\t\"wqd\":         \"application/vnd.wqd\",\n\t\"stf\":         \"application/vnd.wt.stf\",\n\t\"xar\":         \"application/vnd.xara\",\n\t\"xfdl\":        \"application/vnd.xfdl\",\n\t\"hvd\":         \"application/vnd.yamaha.hv-dic\",\n\t\"hvs\":         \"application/vnd.yamaha.hv-script\",\n\t\"hvp\":         \"application/vnd.yamaha.hv-voice\",\n\t\"osf\":         \"application/vnd.yamaha.openscoreformat\",\n\t\"osfpvg\":      \"application/vnd.yamaha.openscoreformat.osfpvg+xml\",\n\t\"saf\":         \"application/vnd.yamaha.smaf-audio\",\n\t\"spf\":         \"application/vnd.yamaha.smaf-phrase\",\n\t\"cmp\":         \"application/vnd.yellowriver-custom-menu\",\n\t\"zir\":         \"application/vnd.zul\",\n\t\"zaz\":         \"application/vnd.zzazz.deck+xml\",\n\t\"vxml\":        \"application/voicexml+xml\",\n\t\"wgt\":         \"application/widget\",\n\t\"hlp\":         \"application/winhlp\",\n\t\"wsdl\":        \"application/wsdl+xml\",\n\t\"wspolicy\":    \"application/wspolicy+xml\",\n\t\"7z\":          \"application/x-7z-compressed\",\n\t\"abw\":         \"application/x-abiword\",\n\t\"ace\":         \"application/x-ace-compressed\",\n\t\"dmg\":         \"application/x-apple-diskimage\",\n\t\"aab\":         \"application/x-authorware-bin\",\n\t\"aam\":         \"application/x-authorware-map\",\n\t\"aas\":         \"application/x-authorware-seg\",\n\t\"bcpio\":       \"application/x-bcpio\",\n\t\"torrent\":     \"application/x-bittorrent\",\n\t\"blb\":         \"application/x-blorb\",\n\t\"bz\":          \"application/x-bzip\",\n\t\"bz2\":         \"application/x-bzip2\",\n\t\"cbr\":         \"application/x-cbr\",\n\t\"vcd\":         \"application/x-cdlink\",\n\t\"cfs\":         \"application/x-cfs-compressed\",\n\t\"chat\":        \"application/x-chat\",\n\t\"pgn\":         \"application/x-chess-pgn\",\n\t\"nsc\":         \"application/x-conference\",\n\t\"cpio\":        \"application/x-cpio\",\n\t\"csh\":         \"application/x-csh\",\n\t\"deb\":         \"application/x-debian-package\",\n\t\"dgc\":         \"application/x-dgc-compressed\",\n\t\"cct\":         \"application/x-director\",\n\t\"wad\":         \"application/x-doom\",\n\t\"ncx\":         \"application/x-dtbncx+xml\",\n\t\"dtb\":         \"application/x-dtbook+xml\",\n\t\"res\":         \"application/x-dtbresource+xml\",\n\t\"dvi\":         \"application/x-dvi\",\n\t\"evy\":         \"application/x-envoy\",\n\t\"eva\":         \"application/x-eva\",\n\t\"bdf\":         \"application/x-font-bdf\",\n\t\"gsf\":         \"application/x-font-ghostscript\",\n\t\"psf\":         \"application/x-font-linux-psf\",\n\t\"pcf\":         \"application/x-font-pcf\",\n\t\"snf\":         \"application/x-font-snf\",\n\t\"afm\":         \"application/x-font-type1\",\n\t\"arc\":         \"application/x-freearc\",\n\t\"spl\":         \"application/x-futuresplash\",\n\t\"gca\":         \"application/x-gca-compressed\",\n\t\"ulx\":         \"application/x-glulx\",\n\t\"gnumeric\":    \"application/x-gnumeric\",\n\t\"gramps\":      \"application/x-gramps-xml\",\n\t\"gtar\":        \"application/x-gtar\",\n\t\"hdf\":         \"application/x-hdf\",\n\t\"install\":     \"application/x-install-instructions\",\n\t\"iso\":         \"application/x-iso9660-image\",\n\t\"jnlp\":        \"application/x-java-jnlp-file\",\n\t\"latex\":       \"application/x-latex\",\n\t\"lzh\":         \"application/x-lzh-compressed\",\n\t\"mie\":         \"application/x-mie\",\n\t\"mobi\":        \"application/x-mobipocket-ebook\",\n\t\"application\": \"application/x-ms-application\",\n\t\"lnk\":         \"application/x-ms-shortcut\",\n\t\"wmd\":         \"application/x-ms-wmd\",\n\t\"wmz\":         \"application/x-ms-wmz\",\n\t\"xbap\":        \"application/x-ms-xbap\",\n\t\"mdb\":         \"application/x-msaccess\",\n\t\"obd\":         \"application/x-msbinder\",\n\t\"crd\":         \"application/x-mscardfile\",\n\t\"clp\":         \"application/x-msclip\",\n\t\"mny\":         \"application/x-msmoney\",\n\t\"pub\":         \"application/x-mspublisher\",\n\t\"scd\":         \"application/x-msschedule\",\n\t\"trm\":         \"application/x-msterminal\",\n\t\"wri\":         \"application/x-mswrite\",\n\t\"nzb\":         \"application/x-nzb\",\n\t\"p12\":         \"application/x-pkcs12\",\n\t\"p7b\":         \"application/x-pkcs7-certificates\",\n\t\"p7r\":         \"application/x-pkcs7-certreqresp\",\n\t\"rar\":         \"application/x-rar-compressed\",\n\t\"ris\":         \"application/x-research-info-systems\",\n\t\"sh\":          \"application/x-sh\",\n\t\"shar\":        \"application/x-shar\",\n\t\"swf\":         \"application/x-shockwave-flash\",\n\t\"xap\":         \"application/x-silverlight-app\",\n\t\"sql\":         \"application/x-sql\",\n\t\"sit\":         \"application/x-stuffit\",\n\t\"sitx\":        \"application/x-stuffitx\",\n\t\"srt\":         \"application/x-subrip\",\n\t\"sv4cpio\":     \"application/x-sv4cpio\",\n\t\"sv4crc\":      \"application/x-sv4crc\",\n\t\"t3\":          \"application/x-t3vm-image\",\n\t\"gam\":         \"application/x-tads\",\n\t\"tar\":         \"application/x-tar\",\n\t\"tcl\":         \"application/x-tcl\",\n\t\"tex\":         \"application/x-tex\",\n\t\"tfm\":         \"application/x-tex-tfm\",\n\t\"texi\":        \"application/x-texinfo\",\n\t\"obj\":         \"application/x-tgif\",\n\t\"ustar\":       \"application/x-ustar\",\n\t\"src\":         \"application/x-wais-source\",\n\t\"crt\":         \"application/x-x509-ca-cert\",\n\t\"fig\":         \"application/x-xfig\",\n\t\"xlf\":         \"application/x-xliff+xml\",\n\t\"xpi\":         \"application/x-xpinstall\",\n\t\"xz\":          \"application/x-xz\",\n\t\"xaml\":        \"application/xaml+xml\",\n\t\"xdf\":         \"application/xcap-diff+xml\",\n\t\"xenc\":        \"application/xenc+xml\",\n\t\"xhtml\":       \"application/xhtml+xml\",\n\t\"xml\":         \"application/xml\",\n\t\"dtd\":         \"application/xml-dtd\",\n\t\"xop\":         \"application/xop+xml\",\n\t\"xpl\":         \"application/xproc+xml\",\n\t\"xslt\":        \"application/xslt+xml\",\n\t\"xspf\":        \"application/xspf+xml\",\n\t\"mxml\":        \"application/xv+xml\",\n\t\"yang\":        \"application/yang\",\n\t\"yin\":         \"application/yin+xml\",\n\t\"zip\":         \"application/zip\",\n\t\"adp\":         \"audio/adpcm\",\n\t\"au\":          \"audio/basic\",\n\t\"mid\":         \"audio/midi\",\n\t\"m4a\":         \"audio/mp4\",\n\t\"mp3\":         \"audio/mpeg\",\n\t\"ogg\":         \"audio/ogg\",\n\t\"s3m\":         \"audio/s3m\",\n\t\"sil\":         \"audio/silk\",\n\t\"uva\":         \"audio/vnd.dece.audio\",\n\t\"eol\":         \"audio/vnd.digital-winds\",\n\t\"dra\":         \"audio/vnd.dra\",\n\t\"dts\":         \"audio/vnd.dts\",\n\t\"dtshd\":       \"audio/vnd.dts.hd\",\n\t\"lvp\":         \"audio/vnd.lucent.voice\",\n\t\"pya\":         \"audio/vnd.ms-playready.media.pya\",\n\t\"ecelp4800\":   \"audio/vnd.nuera.ecelp4800\",\n\t\"ecelp7470\":   \"audio/vnd.nuera.ecelp7470\",\n\t\"ecelp9600\":   \"audio/vnd.nuera.ecelp9600\",\n\t\"rip\":         \"audio/vnd.rip\",\n\t\"weba\":        \"audio/webm\",\n\t\"aac\":         \"audio/x-aac\",\n\t\"aiff\":        \"audio/x-aiff\",\n\t\"caf\":         \"audio/x-caf\",\n\t\"flac\":        \"audio/x-flac\",\n\t\"mka\":         \"audio/x-matroska\",\n\t\"m3u\":         \"audio/x-mpegurl\",\n\t\"wax\":         \"audio/x-ms-wax\",\n\t\"wma\":         \"audio/x-ms-wma\",\n\t\"rmp\":         \"audio/x-pn-realaudio-plugin\",\n\t\"wav\":         \"audio/x-wav\",\n\t\"xm\":          \"audio/xm\",\n\t\"cdx\":         \"chemical/x-cdx\",\n\t\"cif\":         \"chemical/x-cif\",\n\t\"cmdf\":        \"chemical/x-cmdf\",\n\t\"cml\":         \"chemical/x-cml\",\n\t\"csml\":        \"chemical/x-csml\",\n\t\"xyz\":         \"chemical/x-xyz\",\n\t\"ttc\":         \"font/collection\",\n\t\"otf\":         \"font/otf\",\n\t\"ttf\":         \"font/ttf\",\n\t\"woff\":        \"font/woff\",\n\t\"woff2\":       \"font/woff2\",\n\t\"bmp\":         \"image/bmp\",\n\t\"cgm\":         \"image/cgm\",\n\t\"g3\":          \"image/g3fax\",\n\t\"gif\":         \"image/gif\",\n\t\"ief\":         \"image/ief\",\n\t\"jpg\":         \"image/jpeg\",\n\t\"ktx\":         \"image/ktx\",\n\t\"png\":         \"image/png\",\n\t\"btif\":        \"image/prs.btif\",\n\t\"sgi\":         \"image/sgi\",\n\t\"svg\":         \"image/svg+xml\",\n\t\"tiff\":        \"image/tiff\",\n\t\"psd\":         \"image/vnd.adobe.photoshop\",\n\t\"dwg\":         \"image/vnd.dwg\",\n\t\"dxf\":         \"image/vnd.dxf\",\n\t\"fbs\":         \"image/vnd.fastbidsheet\",\n\t\"fpx\":         \"image/vnd.fpx\",\n\t\"fst\":         \"image/vnd.fst\",\n\t\"mmr\":         \"image/vnd.fujixerox.edmics-mmr\",\n\t\"rlc\":         \"image/vnd.fujixerox.edmics-rlc\",\n\t\"mdi\":         \"image/vnd.ms-modi\",\n\t\"wdp\":         \"image/vnd.ms-photo\",\n\t\"npx\":         \"image/vnd.net-fpx\",\n\t\"wbmp\":        \"image/vnd.wap.wbmp\",\n\t\"xif\":         \"image/vnd.xiff\",\n\t\"webp\":        \"image/webp\",\n\t\"3ds\":         \"image/x-3ds\",\n\t\"ras\":         \"image/x-cmu-raster\",\n\t\"cmx\":         \"image/x-cmx\",\n\t\"ico\":         \"image/x-icon\",\n\t\"sid\":         \"image/x-mrsid-image\",\n\t\"pcx\":         \"image/x-pcx\",\n\t\"pnm\":         \"image/x-portable-anymap\",\n\t\"pbm\":         \"image/x-portable-bitmap\",\n\t\"pgm\":         \"image/x-portable-graymap\",\n\t\"ppm\":         \"image/x-portable-pixmap\",\n\t\"rgb\":         \"image/x-rgb\",\n\t\"tga\":         \"image/x-tga\",\n\t\"xbm\":         \"image/x-xbitmap\",\n\t\"xpm\":         \"image/x-xpixmap\",\n\t\"xwd\":         \"image/x-xwindowdump\",\n\t\"dae\":         \"model/vnd.collada+xml\",\n\t\"dwf\":         \"model/vnd.dwf\",\n\t\"gdl\":         \"model/vnd.gdl\",\n\t\"gtw\":         \"model/vnd.gtw\",\n\t\"mts\":         \"model/vnd.mts\",\n\t\"vtu\":         \"model/vnd.vtu\",\n\t\"appcache\":    \"text/cache-manifest\",\n\t\"ics\":         \"text/calendar\",\n\t\"css\":         \"text/css\",\n\t\"csv\":         \"text/csv\",\n\t\"html\":        \"text/html\",\n\t\"n3\":          \"text/n3\",\n\t\"txt\":         \"text/plain\",\n\t\"dsc\":         \"text/prs.lines.tag\",\n\t\"rtx\":         \"text/richtext\",\n\t\"tsv\":         \"text/tab-separated-values\",\n\t\"ttl\":         \"text/turtle\",\n\t\"vcard\":       \"text/vcard\",\n\t\"curl\":        \"text/vnd.curl\",\n\t\"dcurl\":       \"text/vnd.curl.dcurl\",\n\t\"mcurl\":       \"text/vnd.curl.mcurl\",\n\t\"scurl\":       \"text/vnd.curl.scurl\",\n\t\"sub\":         \"text/vnd.dvb.subtitle\",\n\t\"fly\":         \"text/vnd.fly\",\n\t\"flx\":         \"text/vnd.fmi.flexstor\",\n\t\"gv\":          \"text/vnd.graphviz\",\n\t\"3dml\":        \"text/vnd.in3d.3dml\",\n\t\"spot\":        \"text/vnd.in3d.spot\",\n\t\"jad\":         \"text/vnd.sun.j2me.app-descriptor\",\n\t\"wml\":         \"text/vnd.wap.wml\",\n\t\"wmls\":        \"text/vnd.wap.wmlscript\",\n\t\"asm\":         \"text/x-asm\",\n\t\"c\":           \"text/x-c\",\n\t\"java\":        \"text/x-java-source\",\n\t\"nfo\":         \"text/x-nfo\",\n\t\"opml\":        \"text/x-opml\",\n\t\"pas\":         \"text/x-pascal\",\n\t\"etx\":         \"text/x-setext\",\n\t\"sfv\":         \"text/x-sfv\",\n\t\"uu\":          \"text/x-uuencode\",\n\t\"vcs\":         \"text/x-vcalendar\",\n\t\"vcf\":         \"text/x-vcard\",\n\t\"3gp\":         \"video/3gpp\",\n\t\"3g2\":         \"video/3gpp2\",\n\t\"h261\":        \"video/h261\",\n\t\"h263\":        \"video/h263\",\n\t\"h264\":        \"video/h264\",\n\t\"jpgv\":        \"video/jpeg\",\n\t\"mp4\":         \"video/mp4\",\n\t\"mpeg\":        \"video/mpeg\",\n\t\"ogv\":         \"video/ogg\",\n\t\"dvb\":         \"video/vnd.dvb.file\",\n\t\"fvt\":         \"video/vnd.fvt\",\n\t\"pyv\":         \"video/vnd.ms-playready.media.pyv\",\n\t\"viv\":         \"video/vnd.vivo\",\n\t\"webm\":        \"video/webm\",\n\t\"f4v\":         \"video/x-f4v\",\n\t\"fli\":         \"video/x-fli\",\n\t\"flv\":         \"video/x-flv\",\n\t\"m4v\":         \"video/x-m4v\",\n\t\"mkv\":         \"video/x-matroska\",\n\t\"mng\":         \"video/x-mng\",\n\t\"asf\":         \"video/x-ms-asf\",\n\t\"vob\":         \"video/x-ms-vob\",\n\t\"wm\":          \"video/x-ms-wm\",\n\t\"wmv\":         \"video/x-ms-wmv\",\n\t\"wmx\":         \"video/x-ms-wmx\",\n\t\"wvx\":         \"video/x-ms-wvx\",\n\t\"avi\":         \"video/x-msvideo\",\n\t\"movie\":       \"video/x-sgi-movie\",\n\t\"smv\":         \"video/x-smv\",\n\t\"ice\":         \"x-conference/x-cooltalk\",\n}\n"
  },
  {
    "path": "internal/misc/oauth.go",
    "content": "package misc\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// GenerateRandomState generates a cryptographically secure random state parameter\n// for OAuth2 flows to prevent CSRF attacks.\n//\n// Returns:\n//   - string: A hexadecimal encoded random state string\n//   - error: An error if the random generation fails, nil otherwise\nfunc GenerateRandomState() (string, error) {\n\tbytes := make([]byte, 16)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate random bytes: %w\", err)\n\t}\n\treturn hex.EncodeToString(bytes), nil\n}\n\n// OAuthCallback captures the parsed OAuth callback parameters.\ntype OAuthCallback struct {\n\tCode             string\n\tState            string\n\tError            string\n\tErrorDescription string\n}\n\n// ParseOAuthCallback extracts OAuth parameters from a callback URL.\n// It returns nil when the input is empty.\nfunc ParseOAuthCallback(input string) (*OAuthCallback, error) {\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tcandidate := trimmed\n\tif !strings.Contains(candidate, \"://\") {\n\t\tif strings.HasPrefix(candidate, \"?\") {\n\t\t\tcandidate = \"http://localhost\" + candidate\n\t\t} else if strings.ContainsAny(candidate, \"/?#\") || strings.Contains(candidate, \":\") {\n\t\t\tcandidate = \"http://\" + candidate\n\t\t} else if strings.Contains(candidate, \"=\") {\n\t\t\tcandidate = \"http://localhost/?\" + candidate\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"invalid callback URL\")\n\t\t}\n\t}\n\n\tparsedURL, err := url.Parse(candidate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery := parsedURL.Query()\n\tcode := strings.TrimSpace(query.Get(\"code\"))\n\tstate := strings.TrimSpace(query.Get(\"state\"))\n\terrCode := strings.TrimSpace(query.Get(\"error\"))\n\terrDesc := strings.TrimSpace(query.Get(\"error_description\"))\n\n\tif parsedURL.Fragment != \"\" {\n\t\tif fragQuery, errFrag := url.ParseQuery(parsedURL.Fragment); errFrag == nil {\n\t\t\tif code == \"\" {\n\t\t\t\tcode = strings.TrimSpace(fragQuery.Get(\"code\"))\n\t\t\t}\n\t\t\tif state == \"\" {\n\t\t\t\tstate = strings.TrimSpace(fragQuery.Get(\"state\"))\n\t\t\t}\n\t\t\tif errCode == \"\" {\n\t\t\t\terrCode = strings.TrimSpace(fragQuery.Get(\"error\"))\n\t\t\t}\n\t\t\tif errDesc == \"\" {\n\t\t\t\terrDesc = strings.TrimSpace(fragQuery.Get(\"error_description\"))\n\t\t\t}\n\t\t}\n\t}\n\n\tif code != \"\" && state == \"\" && strings.Contains(code, \"#\") {\n\t\tparts := strings.SplitN(code, \"#\", 2)\n\t\tcode = parts[0]\n\t\tstate = parts[1]\n\t}\n\n\tif errCode == \"\" && errDesc != \"\" {\n\t\terrCode = errDesc\n\t\terrDesc = \"\"\n\t}\n\n\tif code == \"\" && errCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"callback URL missing code\")\n\t}\n\n\treturn &OAuthCallback{\n\t\tCode:             code,\n\t\tState:            state,\n\t\tError:            errCode,\n\t\tErrorDescription: errDesc,\n\t}, nil\n}\n"
  },
  {
    "path": "internal/registry/model_definitions.go",
    "content": "// Package registry provides model definitions and lookup helpers for various AI providers.\n// Static model metadata is loaded from the embedded models.json file and can be refreshed from network.\npackage registry\n\nimport (\n\t\"strings\"\n)\n\n// staticModelsJSON mirrors the top-level structure of models.json.\ntype staticModelsJSON struct {\n\tClaude      []*ModelInfo `json:\"claude\"`\n\tGemini      []*ModelInfo `json:\"gemini\"`\n\tVertex      []*ModelInfo `json:\"vertex\"`\n\tGeminiCLI   []*ModelInfo `json:\"gemini-cli\"`\n\tAIStudio    []*ModelInfo `json:\"aistudio\"`\n\tCodexFree   []*ModelInfo `json:\"codex-free\"`\n\tCodexTeam   []*ModelInfo `json:\"codex-team\"`\n\tCodexPlus   []*ModelInfo `json:\"codex-plus\"`\n\tCodexPro    []*ModelInfo `json:\"codex-pro\"`\n\tQwen        []*ModelInfo `json:\"qwen\"`\n\tIFlow       []*ModelInfo `json:\"iflow\"`\n\tKimi        []*ModelInfo `json:\"kimi\"`\n\tAntigravity []*ModelInfo `json:\"antigravity\"`\n}\n\n// GetClaudeModels returns the standard Claude model definitions.\nfunc GetClaudeModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Claude)\n}\n\n// GetGeminiModels returns the standard Gemini model definitions.\nfunc GetGeminiModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Gemini)\n}\n\n// GetGeminiVertexModels returns Gemini model definitions for Vertex AI.\nfunc GetGeminiVertexModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Vertex)\n}\n\n// GetGeminiCLIModels returns Gemini model definitions for the Gemini CLI.\nfunc GetGeminiCLIModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().GeminiCLI)\n}\n\n// GetAIStudioModels returns model definitions for AI Studio.\nfunc GetAIStudioModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().AIStudio)\n}\n\n// GetCodexFreeModels returns model definitions for the Codex free plan tier.\nfunc GetCodexFreeModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().CodexFree)\n}\n\n// GetCodexTeamModels returns model definitions for the Codex team plan tier.\nfunc GetCodexTeamModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().CodexTeam)\n}\n\n// GetCodexPlusModels returns model definitions for the Codex plus plan tier.\nfunc GetCodexPlusModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().CodexPlus)\n}\n\n// GetCodexProModels returns model definitions for the Codex pro plan tier.\nfunc GetCodexProModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().CodexPro)\n}\n\n// GetQwenModels returns the standard Qwen model definitions.\nfunc GetQwenModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Qwen)\n}\n\n// GetIFlowModels returns the standard iFlow model definitions.\nfunc GetIFlowModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().IFlow)\n}\n\n// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions.\nfunc GetKimiModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Kimi)\n}\n\n// GetAntigravityModels returns the standard Antigravity model definitions.\nfunc GetAntigravityModels() []*ModelInfo {\n\treturn cloneModelInfos(getModels().Antigravity)\n}\n\n// cloneModelInfos returns a shallow copy of the slice with each element deep-cloned.\nfunc cloneModelInfos(models []*ModelInfo) []*ModelInfo {\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]*ModelInfo, len(models))\n\tfor i, m := range models {\n\t\tout[i] = cloneModelInfo(m)\n\t}\n\treturn out\n}\n\n// GetStaticModelDefinitionsByChannel returns static model definitions for a given channel/provider.\n// It returns nil when the channel is unknown.\n//\n// Supported channels:\n//   - claude\n//   - gemini\n//   - vertex\n//   - gemini-cli\n//   - aistudio\n//   - codex\n//   - qwen\n//   - iflow\n//   - kimi\n//   - antigravity\nfunc GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {\n\tkey := strings.ToLower(strings.TrimSpace(channel))\n\tswitch key {\n\tcase \"claude\":\n\t\treturn GetClaudeModels()\n\tcase \"gemini\":\n\t\treturn GetGeminiModels()\n\tcase \"vertex\":\n\t\treturn GetGeminiVertexModels()\n\tcase \"gemini-cli\":\n\t\treturn GetGeminiCLIModels()\n\tcase \"aistudio\":\n\t\treturn GetAIStudioModels()\n\tcase \"codex\":\n\t\treturn GetCodexProModels()\n\tcase \"qwen\":\n\t\treturn GetQwenModels()\n\tcase \"iflow\":\n\t\treturn GetIFlowModels()\n\tcase \"kimi\":\n\t\treturn GetKimiModels()\n\tcase \"antigravity\":\n\t\treturn GetAntigravityModels()\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// LookupStaticModelInfo searches all static model definitions for a model by ID.\n// Returns nil if no matching model is found.\nfunc LookupStaticModelInfo(modelID string) *ModelInfo {\n\tif modelID == \"\" {\n\t\treturn nil\n\t}\n\n\tdata := getModels()\n\tallModels := [][]*ModelInfo{\n\t\tdata.Claude,\n\t\tdata.Gemini,\n\t\tdata.Vertex,\n\t\tdata.GeminiCLI,\n\t\tdata.AIStudio,\n\t\tdata.CodexPro,\n\t\tdata.Qwen,\n\t\tdata.IFlow,\n\t\tdata.Kimi,\n\t\tdata.Antigravity,\n\t}\n\tfor _, models := range allModels {\n\t\tfor _, m := range models {\n\t\t\tif m != nil && m.ID == modelID {\n\t\t\t\treturn cloneModelInfo(m)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/registry/model_registry.go",
    "content": "// Package registry provides centralized model management for all AI service providers.\n// It implements a dynamic model registry with reference counting to track active clients\n// and automatically hide models when no clients are available or when quota is exceeded.\npackage registry\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tmisc \"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ModelInfo represents information about an available model\ntype ModelInfo struct {\n\t// ID is the unique identifier for the model\n\tID string `json:\"id\"`\n\t// Object type for the model (typically \"model\")\n\tObject string `json:\"object\"`\n\t// Created timestamp when the model was created\n\tCreated int64 `json:\"created\"`\n\t// OwnedBy indicates the organization that owns the model\n\tOwnedBy string `json:\"owned_by\"`\n\t// Type indicates the model type (e.g., \"claude\", \"gemini\", \"openai\")\n\tType string `json:\"type\"`\n\t// DisplayName is the human-readable name for the model\n\tDisplayName string `json:\"display_name,omitempty\"`\n\t// Name is used for Gemini-style model names\n\tName string `json:\"name,omitempty\"`\n\t// Version is the model version\n\tVersion string `json:\"version,omitempty\"`\n\t// Description provides detailed information about the model\n\tDescription string `json:\"description,omitempty\"`\n\t// InputTokenLimit is the maximum input token limit\n\tInputTokenLimit int `json:\"inputTokenLimit,omitempty\"`\n\t// OutputTokenLimit is the maximum output token limit\n\tOutputTokenLimit int `json:\"outputTokenLimit,omitempty\"`\n\t// SupportedGenerationMethods lists supported generation methods\n\tSupportedGenerationMethods []string `json:\"supportedGenerationMethods,omitempty\"`\n\t// ContextLength is the context window size\n\tContextLength int `json:\"context_length,omitempty\"`\n\t// MaxCompletionTokens is the maximum completion tokens\n\tMaxCompletionTokens int `json:\"max_completion_tokens,omitempty\"`\n\t// SupportedParameters lists supported parameters\n\tSupportedParameters []string `json:\"supported_parameters,omitempty\"`\n\t// SupportedInputModalities lists supported input modalities (e.g., TEXT, IMAGE, VIDEO, AUDIO)\n\tSupportedInputModalities []string `json:\"supportedInputModalities,omitempty\"`\n\t// SupportedOutputModalities lists supported output modalities (e.g., TEXT, IMAGE)\n\tSupportedOutputModalities []string `json:\"supportedOutputModalities,omitempty\"`\n\n\t// Thinking holds provider-specific reasoning/thinking budget capabilities.\n\t// This is optional and currently used for Gemini thinking budget normalization.\n\tThinking *ThinkingSupport `json:\"thinking,omitempty\"`\n\n\t// UserDefined indicates this model was defined through config file's models[]\n\t// array (e.g., openai-compatibility.*.models[], *-api-key.models[]).\n\t// UserDefined models have thinking configuration passed through without validation.\n\tUserDefined bool `json:\"-\"`\n}\n\ntype availableModelsCacheEntry struct {\n\tmodels    []map[string]any\n\texpiresAt time.Time\n}\n\n// ThinkingSupport describes a model family's supported internal reasoning budget range.\n// Values are interpreted in provider-native token units.\ntype ThinkingSupport struct {\n\t// Min is the minimum allowed thinking budget (inclusive).\n\tMin int `json:\"min,omitempty\"`\n\t// Max is the maximum allowed thinking budget (inclusive).\n\tMax int `json:\"max,omitempty\"`\n\t// ZeroAllowed indicates whether 0 is a valid value (to disable thinking).\n\tZeroAllowed bool `json:\"zero_allowed,omitempty\"`\n\t// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).\n\tDynamicAllowed bool `json:\"dynamic_allowed,omitempty\"`\n\t// Levels defines discrete reasoning effort levels (e.g., \"low\", \"medium\", \"high\").\n\t// When set, the model uses level-based reasoning instead of token budgets.\n\tLevels []string `json:\"levels,omitempty\"`\n}\n\n// ModelRegistration tracks a model's availability\ntype ModelRegistration struct {\n\t// Info contains the model metadata\n\tInfo *ModelInfo\n\t// InfoByProvider maps provider identifiers to specific ModelInfo to support differing capabilities.\n\tInfoByProvider map[string]*ModelInfo\n\t// Count is the number of active clients that can provide this model\n\tCount int\n\t// LastUpdated tracks when this registration was last modified\n\tLastUpdated time.Time\n\t// QuotaExceededClients tracks which clients have exceeded quota for this model\n\tQuotaExceededClients map[string]*time.Time\n\t// Providers tracks available clients grouped by provider identifier\n\tProviders map[string]int\n\t// SuspendedClients tracks temporarily disabled clients keyed by client ID\n\tSuspendedClients map[string]string\n}\n\n// ModelRegistryHook provides optional callbacks for external integrations to track model list changes.\n// Hook implementations must be non-blocking and resilient; calls are executed asynchronously and panics are recovered.\ntype ModelRegistryHook interface {\n\tOnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo)\n\tOnModelsUnregistered(ctx context.Context, provider, clientID string)\n}\n\n// ModelRegistry manages the global registry of available models\ntype ModelRegistry struct {\n\t// models maps model ID to registration information\n\tmodels map[string]*ModelRegistration\n\t// clientModels maps client ID to the models it provides\n\tclientModels map[string][]string\n\t// clientModelInfos maps client ID to a map of model ID -> ModelInfo\n\t// This preserves the original model info provided by each client\n\tclientModelInfos map[string]map[string]*ModelInfo\n\t// clientProviders maps client ID to its provider identifier\n\tclientProviders map[string]string\n\t// mutex ensures thread-safe access to the registry\n\tmutex *sync.RWMutex\n\t// availableModelsCache stores per-handler snapshots for GetAvailableModels.\n\tavailableModelsCache map[string]availableModelsCacheEntry\n\t// hook is an optional callback sink for model registration changes\n\thook ModelRegistryHook\n}\n\n// Global model registry instance\nvar globalRegistry *ModelRegistry\nvar registryOnce sync.Once\n\n// GetGlobalRegistry returns the global model registry instance\nfunc GetGlobalRegistry() *ModelRegistry {\n\tregistryOnce.Do(func() {\n\t\tglobalRegistry = &ModelRegistry{\n\t\t\tmodels:               make(map[string]*ModelRegistration),\n\t\t\tclientModels:         make(map[string][]string),\n\t\t\tclientModelInfos:     make(map[string]map[string]*ModelInfo),\n\t\t\tclientProviders:      make(map[string]string),\n\t\t\tavailableModelsCache: make(map[string]availableModelsCacheEntry),\n\t\t\tmutex:                &sync.RWMutex{},\n\t\t}\n\t})\n\treturn globalRegistry\n}\nfunc (r *ModelRegistry) ensureAvailableModelsCacheLocked() {\n\tif r.availableModelsCache == nil {\n\t\tr.availableModelsCache = make(map[string]availableModelsCacheEntry)\n\t}\n}\n\nfunc (r *ModelRegistry) invalidateAvailableModelsCacheLocked() {\n\tif len(r.availableModelsCache) == 0 {\n\t\treturn\n\t}\n\tclear(r.availableModelsCache)\n}\n\n// LookupModelInfo searches dynamic registry (provider-specific > global) then static definitions.\nfunc LookupModelInfo(modelID string, provider ...string) *ModelInfo {\n\tmodelID = strings.TrimSpace(modelID)\n\tif modelID == \"\" {\n\t\treturn nil\n\t}\n\n\tp := \"\"\n\tif len(provider) > 0 {\n\t\tp = strings.ToLower(strings.TrimSpace(provider[0]))\n\t}\n\n\tif info := GetGlobalRegistry().GetModelInfo(modelID, p); info != nil {\n\t\treturn cloneModelInfo(info)\n\t}\n\treturn cloneModelInfo(LookupStaticModelInfo(modelID))\n}\n\n// SetHook sets an optional hook for observing model registration changes.\nfunc (r *ModelRegistry) SetHook(hook ModelRegistryHook) {\n\tif r == nil {\n\t\treturn\n\t}\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.hook = hook\n}\n\nconst defaultModelRegistryHookTimeout = 5 * time.Second\nconst modelQuotaExceededWindow = 5 * time.Minute\n\nfunc (r *ModelRegistry) triggerModelsRegistered(provider, clientID string, models []*ModelInfo) {\n\thook := r.hook\n\tif hook == nil {\n\t\treturn\n\t}\n\tmodelsCopy := cloneModelInfosUnique(models)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif recovered := recover(); recovered != nil {\n\t\t\t\tlog.Errorf(\"model registry hook OnModelsRegistered panic: %v\", recovered)\n\t\t\t}\n\t\t}()\n\t\tctx, cancel := context.WithTimeout(context.Background(), defaultModelRegistryHookTimeout)\n\t\tdefer cancel()\n\t\thook.OnModelsRegistered(ctx, provider, clientID, modelsCopy)\n\t}()\n}\n\nfunc (r *ModelRegistry) triggerModelsUnregistered(provider, clientID string) {\n\thook := r.hook\n\tif hook == nil {\n\t\treturn\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif recovered := recover(); recovered != nil {\n\t\t\t\tlog.Errorf(\"model registry hook OnModelsUnregistered panic: %v\", recovered)\n\t\t\t}\n\t\t}()\n\t\tctx, cancel := context.WithTimeout(context.Background(), defaultModelRegistryHookTimeout)\n\t\tdefer cancel()\n\t\thook.OnModelsUnregistered(ctx, provider, clientID)\n\t}()\n}\n\n// RegisterClient registers a client and its supported models\n// Parameters:\n//   - clientID: Unique identifier for the client\n//   - clientProvider: Provider name (e.g., \"gemini\", \"claude\", \"openai\")\n//   - models: List of models that this client can provide\nfunc (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models []*ModelInfo) {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tprovider := strings.ToLower(clientProvider)\n\tuniqueModelIDs := make([]string, 0, len(models))\n\trawModelIDs := make([]string, 0, len(models))\n\tnewModels := make(map[string]*ModelInfo, len(models))\n\tnewCounts := make(map[string]int, len(models))\n\tfor _, model := range models {\n\t\tif model == nil || model.ID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trawModelIDs = append(rawModelIDs, model.ID)\n\t\tnewCounts[model.ID]++\n\t\tif _, exists := newModels[model.ID]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tnewModels[model.ID] = model\n\t\tuniqueModelIDs = append(uniqueModelIDs, model.ID)\n\t}\n\n\tif len(uniqueModelIDs) == 0 {\n\t\t// No models supplied; unregister existing client state if present.\n\t\tr.unregisterClientInternal(clientID)\n\t\tdelete(r.clientModels, clientID)\n\t\tdelete(r.clientModelInfos, clientID)\n\t\tdelete(r.clientProviders, clientID)\n\t\tr.invalidateAvailableModelsCacheLocked()\n\t\tmisc.LogCredentialSeparator()\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\n\toldModels, hadExisting := r.clientModels[clientID]\n\toldProvider := r.clientProviders[clientID]\n\tproviderChanged := oldProvider != provider\n\tif !hadExisting {\n\t\t// Pure addition path.\n\t\tfor _, modelID := range rawModelIDs {\n\t\t\tmodel := newModels[modelID]\n\t\t\tr.addModelRegistration(modelID, provider, model, now)\n\t\t}\n\t\tr.clientModels[clientID] = append([]string(nil), rawModelIDs...)\n\t\t// Store client's own model infos\n\t\tclientInfos := make(map[string]*ModelInfo, len(newModels))\n\t\tfor id, m := range newModels {\n\t\t\tclientInfos[id] = cloneModelInfo(m)\n\t\t}\n\t\tr.clientModelInfos[clientID] = clientInfos\n\t\tif provider != \"\" {\n\t\t\tr.clientProviders[clientID] = provider\n\t\t} else {\n\t\t\tdelete(r.clientProviders, clientID)\n\t\t}\n\t\tr.invalidateAvailableModelsCacheLocked()\n\t\tr.triggerModelsRegistered(provider, clientID, models)\n\t\tlog.Debugf(\"Registered client %s from provider %s with %d models\", clientID, clientProvider, len(rawModelIDs))\n\t\tmisc.LogCredentialSeparator()\n\t\treturn\n\t}\n\n\toldCounts := make(map[string]int, len(oldModels))\n\tfor _, id := range oldModels {\n\t\toldCounts[id]++\n\t}\n\n\tadded := make([]string, 0)\n\tfor _, id := range uniqueModelIDs {\n\t\tif oldCounts[id] == 0 {\n\t\t\tadded = append(added, id)\n\t\t}\n\t}\n\n\tremoved := make([]string, 0)\n\tfor id := range oldCounts {\n\t\tif newCounts[id] == 0 {\n\t\t\tremoved = append(removed, id)\n\t\t}\n\t}\n\n\t// Handle provider change for overlapping models before modifications.\n\tif providerChanged && oldProvider != \"\" {\n\t\tfor id, newCount := range newCounts {\n\t\t\tif newCount == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\toldCount := oldCounts[id]\n\t\t\tif oldCount == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttoRemove := newCount\n\t\t\tif oldCount < toRemove {\n\t\t\t\ttoRemove = oldCount\n\t\t\t}\n\t\t\tif reg, ok := r.models[id]; ok && reg.Providers != nil {\n\t\t\t\tif count, okProv := reg.Providers[oldProvider]; okProv {\n\t\t\t\t\tif count <= toRemove {\n\t\t\t\t\t\tdelete(reg.Providers, oldProvider)\n\t\t\t\t\t\tif reg.InfoByProvider != nil {\n\t\t\t\t\t\t\tdelete(reg.InfoByProvider, oldProvider)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\treg.Providers[oldProvider] = count - toRemove\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Apply removals first to keep counters accurate.\n\tfor _, id := range removed {\n\t\toldCount := oldCounts[id]\n\t\tfor i := 0; i < oldCount; i++ {\n\t\t\tr.removeModelRegistration(clientID, id, oldProvider, now)\n\t\t}\n\t}\n\n\tfor id, oldCount := range oldCounts {\n\t\tnewCount := newCounts[id]\n\t\tif newCount == 0 || oldCount <= newCount {\n\t\t\tcontinue\n\t\t}\n\t\toverage := oldCount - newCount\n\t\tfor i := 0; i < overage; i++ {\n\t\t\tr.removeModelRegistration(clientID, id, oldProvider, now)\n\t\t}\n\t}\n\n\t// Apply additions.\n\tfor id, newCount := range newCounts {\n\t\toldCount := oldCounts[id]\n\t\tif newCount <= oldCount {\n\t\t\tcontinue\n\t\t}\n\t\tmodel := newModels[id]\n\t\tdiff := newCount - oldCount\n\t\tfor i := 0; i < diff; i++ {\n\t\t\tr.addModelRegistration(id, provider, model, now)\n\t\t}\n\t}\n\n\t// Update metadata for models that remain associated with the client.\n\taddedSet := make(map[string]struct{}, len(added))\n\tfor _, id := range added {\n\t\taddedSet[id] = struct{}{}\n\t}\n\tfor _, id := range uniqueModelIDs {\n\t\tmodel := newModels[id]\n\t\tif reg, ok := r.models[id]; ok {\n\t\t\treg.Info = cloneModelInfo(model)\n\t\t\tif provider != \"\" {\n\t\t\t\tif reg.InfoByProvider == nil {\n\t\t\t\t\treg.InfoByProvider = make(map[string]*ModelInfo)\n\t\t\t\t}\n\t\t\t\treg.InfoByProvider[provider] = cloneModelInfo(model)\n\t\t\t}\n\t\t\treg.LastUpdated = now\n\t\t\t// Re-registering an existing client/model binding starts a fresh registry\n\t\t\t// snapshot for that binding. Cooldown and suspension are transient\n\t\t\t// scheduling state and must not survive this reconciliation step.\n\t\t\tif reg.QuotaExceededClients != nil {\n\t\t\t\tdelete(reg.QuotaExceededClients, clientID)\n\t\t\t}\n\t\t\tif reg.SuspendedClients != nil {\n\t\t\t\tdelete(reg.SuspendedClients, clientID)\n\t\t\t}\n\t\t\tif providerChanged && provider != \"\" {\n\t\t\t\tif _, newlyAdded := addedSet[id]; newlyAdded {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\toverlapCount := newCounts[id]\n\t\t\t\tif oldCount := oldCounts[id]; oldCount < overlapCount {\n\t\t\t\t\toverlapCount = oldCount\n\t\t\t\t}\n\t\t\t\tif overlapCount <= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif reg.Providers == nil {\n\t\t\t\t\treg.Providers = make(map[string]int)\n\t\t\t\t}\n\t\t\t\treg.Providers[provider] += overlapCount\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update client bookkeeping.\n\tif len(rawModelIDs) > 0 {\n\t\tr.clientModels[clientID] = append([]string(nil), rawModelIDs...)\n\t}\n\t// Update client's own model infos\n\tclientInfos := make(map[string]*ModelInfo, len(newModels))\n\tfor id, m := range newModels {\n\t\tclientInfos[id] = cloneModelInfo(m)\n\t}\n\tr.clientModelInfos[clientID] = clientInfos\n\tif provider != \"\" {\n\t\tr.clientProviders[clientID] = provider\n\t} else {\n\t\tdelete(r.clientProviders, clientID)\n\t}\n\n\tr.invalidateAvailableModelsCacheLocked()\n\tr.triggerModelsRegistered(provider, clientID, models)\n\tif len(added) == 0 && len(removed) == 0 && !providerChanged {\n\t\t// Only metadata (e.g., display name) changed; skip separator when no log output.\n\t\treturn\n\t}\n\n\tlog.Debugf(\"Reconciled client %s (provider %s) models: +%d, -%d\", clientID, provider, len(added), len(removed))\n\tmisc.LogCredentialSeparator()\n}\n\nfunc (r *ModelRegistry) addModelRegistration(modelID, provider string, model *ModelInfo, now time.Time) {\n\tif model == nil || modelID == \"\" {\n\t\treturn\n\t}\n\tif existing, exists := r.models[modelID]; exists {\n\t\texisting.Count++\n\t\texisting.LastUpdated = now\n\t\texisting.Info = cloneModelInfo(model)\n\t\tif existing.SuspendedClients == nil {\n\t\t\texisting.SuspendedClients = make(map[string]string)\n\t\t}\n\t\tif existing.InfoByProvider == nil {\n\t\t\texisting.InfoByProvider = make(map[string]*ModelInfo)\n\t\t}\n\t\tif provider != \"\" {\n\t\t\tif existing.Providers == nil {\n\t\t\t\texisting.Providers = make(map[string]int)\n\t\t\t}\n\t\t\texisting.Providers[provider]++\n\t\t\texisting.InfoByProvider[provider] = cloneModelInfo(model)\n\t\t}\n\t\tlog.Debugf(\"Incremented count for model %s, now %d clients\", modelID, existing.Count)\n\t\treturn\n\t}\n\n\tregistration := &ModelRegistration{\n\t\tInfo:                 cloneModelInfo(model),\n\t\tInfoByProvider:       make(map[string]*ModelInfo),\n\t\tCount:                1,\n\t\tLastUpdated:          now,\n\t\tQuotaExceededClients: make(map[string]*time.Time),\n\t\tSuspendedClients:     make(map[string]string),\n\t}\n\tif provider != \"\" {\n\t\tregistration.Providers = map[string]int{provider: 1}\n\t\tregistration.InfoByProvider[provider] = cloneModelInfo(model)\n\t}\n\tr.models[modelID] = registration\n\tlog.Debugf(\"Registered new model %s from provider %s\", modelID, provider)\n}\n\nfunc (r *ModelRegistry) removeModelRegistration(clientID, modelID, provider string, now time.Time) {\n\tregistration, exists := r.models[modelID]\n\tif !exists {\n\t\treturn\n\t}\n\tregistration.Count--\n\tregistration.LastUpdated = now\n\tif registration.QuotaExceededClients != nil {\n\t\tdelete(registration.QuotaExceededClients, clientID)\n\t}\n\tif registration.SuspendedClients != nil {\n\t\tdelete(registration.SuspendedClients, clientID)\n\t}\n\tif registration.Count < 0 {\n\t\tregistration.Count = 0\n\t}\n\tif provider != \"\" && registration.Providers != nil {\n\t\tif count, ok := registration.Providers[provider]; ok {\n\t\t\tif count <= 1 {\n\t\t\t\tdelete(registration.Providers, provider)\n\t\t\t\tif registration.InfoByProvider != nil {\n\t\t\t\t\tdelete(registration.InfoByProvider, provider)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tregistration.Providers[provider] = count - 1\n\t\t\t}\n\t\t}\n\t}\n\tlog.Debugf(\"Decremented count for model %s, now %d clients\", modelID, registration.Count)\n\tif registration.Count <= 0 {\n\t\tdelete(r.models, modelID)\n\t\tlog.Debugf(\"Removed model %s as no clients remain\", modelID)\n\t}\n}\n\nfunc cloneModelInfo(model *ModelInfo) *ModelInfo {\n\tif model == nil {\n\t\treturn nil\n\t}\n\tcopyModel := *model\n\tif len(model.SupportedGenerationMethods) > 0 {\n\t\tcopyModel.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)\n\t}\n\tif len(model.SupportedParameters) > 0 {\n\t\tcopyModel.SupportedParameters = append([]string(nil), model.SupportedParameters...)\n\t}\n\tif len(model.SupportedInputModalities) > 0 {\n\t\tcopyModel.SupportedInputModalities = append([]string(nil), model.SupportedInputModalities...)\n\t}\n\tif len(model.SupportedOutputModalities) > 0 {\n\t\tcopyModel.SupportedOutputModalities = append([]string(nil), model.SupportedOutputModalities...)\n\t}\n\tif model.Thinking != nil {\n\t\tcopyThinking := *model.Thinking\n\t\tif len(model.Thinking.Levels) > 0 {\n\t\t\tcopyThinking.Levels = append([]string(nil), model.Thinking.Levels...)\n\t\t}\n\t\tcopyModel.Thinking = &copyThinking\n\t}\n\treturn &copyModel\n}\n\nfunc cloneModelInfosUnique(models []*ModelInfo) []*ModelInfo {\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tcloned := make([]*ModelInfo, 0, len(models))\n\tseen := make(map[string]struct{}, len(models))\n\tfor _, model := range models {\n\t\tif model == nil || model.ID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[model.ID]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[model.ID] = struct{}{}\n\t\tcloned = append(cloned, cloneModelInfo(model))\n\t}\n\treturn cloned\n}\n\n// UnregisterClient removes a client and decrements counts for its models\n// Parameters:\n//   - clientID: Unique identifier for the client to remove\nfunc (r *ModelRegistry) UnregisterClient(clientID string) {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.unregisterClientInternal(clientID)\n\tr.invalidateAvailableModelsCacheLocked()\n}\n\n// unregisterClientInternal performs the actual client unregistration (internal, no locking)\nfunc (r *ModelRegistry) unregisterClientInternal(clientID string) {\n\tmodels, exists := r.clientModels[clientID]\n\tprovider, hasProvider := r.clientProviders[clientID]\n\tif !exists {\n\t\tif hasProvider {\n\t\t\tdelete(r.clientProviders, clientID)\n\t\t}\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\tfor _, modelID := range models {\n\t\tif registration, isExists := r.models[modelID]; isExists {\n\t\t\tregistration.Count--\n\t\t\tregistration.LastUpdated = now\n\n\t\t\t// Remove quota tracking for this client\n\t\t\tdelete(registration.QuotaExceededClients, clientID)\n\t\t\tif registration.SuspendedClients != nil {\n\t\t\t\tdelete(registration.SuspendedClients, clientID)\n\t\t\t}\n\n\t\t\tif hasProvider && registration.Providers != nil {\n\t\t\t\tif count, ok := registration.Providers[provider]; ok {\n\t\t\t\t\tif count <= 1 {\n\t\t\t\t\t\tdelete(registration.Providers, provider)\n\t\t\t\t\t\tif registration.InfoByProvider != nil {\n\t\t\t\t\t\t\tdelete(registration.InfoByProvider, provider)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tregistration.Providers[provider] = count - 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Debugf(\"Decremented count for model %s, now %d clients\", modelID, registration.Count)\n\n\t\t\t// Remove model if no clients remain\n\t\t\tif registration.Count <= 0 {\n\t\t\t\tdelete(r.models, modelID)\n\t\t\t\tlog.Debugf(\"Removed model %s as no clients remain\", modelID)\n\t\t\t}\n\t\t}\n\t}\n\n\tdelete(r.clientModels, clientID)\n\tdelete(r.clientModelInfos, clientID)\n\tif hasProvider {\n\t\tdelete(r.clientProviders, clientID)\n\t}\n\tlog.Debugf(\"Unregistered client %s\", clientID)\n\t// Separator line after completing client unregistration (after the summary line)\n\tmisc.LogCredentialSeparator()\n\tr.triggerModelsUnregistered(provider, clientID)\n}\n\n// SetModelQuotaExceeded marks a model as quota exceeded for a specific client\n// Parameters:\n//   - clientID: The client that exceeded quota\n//   - modelID: The model that exceeded quota\nfunc (r *ModelRegistry) SetModelQuotaExceeded(clientID, modelID string) {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tif registration, exists := r.models[modelID]; exists {\n\t\tnow := time.Now()\n\t\tregistration.QuotaExceededClients[clientID] = &now\n\t\tr.invalidateAvailableModelsCacheLocked()\n\t\tlog.Debugf(\"Marked model %s as quota exceeded for client %s\", modelID, clientID)\n\t}\n}\n\n// ClearModelQuotaExceeded removes quota exceeded status for a model and client\n// Parameters:\n//   - clientID: The client to clear quota status for\n//   - modelID: The model to clear quota status for\nfunc (r *ModelRegistry) ClearModelQuotaExceeded(clientID, modelID string) {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tif registration, exists := r.models[modelID]; exists {\n\t\tdelete(registration.QuotaExceededClients, clientID)\n\t\tr.invalidateAvailableModelsCacheLocked()\n\t\t// log.Debugf(\"Cleared quota exceeded status for model %s and client %s\", modelID, clientID)\n\t}\n}\n\n// SuspendClientModel marks a client's model as temporarily unavailable until explicitly resumed.\n// Parameters:\n//   - clientID: The client to suspend\n//   - modelID: The model affected by the suspension\n//   - reason: Optional description for observability\nfunc (r *ModelRegistry) SuspendClientModel(clientID, modelID, reason string) {\n\tif clientID == \"\" || modelID == \"\" {\n\t\treturn\n\t}\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tregistration, exists := r.models[modelID]\n\tif !exists || registration == nil {\n\t\treturn\n\t}\n\tif registration.SuspendedClients == nil {\n\t\tregistration.SuspendedClients = make(map[string]string)\n\t}\n\tif _, already := registration.SuspendedClients[clientID]; already {\n\t\treturn\n\t}\n\tregistration.SuspendedClients[clientID] = reason\n\tregistration.LastUpdated = time.Now()\n\tr.invalidateAvailableModelsCacheLocked()\n\tif reason != \"\" {\n\t\tlog.Debugf(\"Suspended client %s for model %s: %s\", clientID, modelID, reason)\n\t} else {\n\t\tlog.Debugf(\"Suspended client %s for model %s\", clientID, modelID)\n\t}\n}\n\n// ResumeClientModel clears a previous suspension so the client counts toward availability again.\n// Parameters:\n//   - clientID: The client to resume\n//   - modelID: The model being resumed\nfunc (r *ModelRegistry) ResumeClientModel(clientID, modelID string) {\n\tif clientID == \"\" || modelID == \"\" {\n\t\treturn\n\t}\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tregistration, exists := r.models[modelID]\n\tif !exists || registration == nil || registration.SuspendedClients == nil {\n\t\treturn\n\t}\n\tif _, ok := registration.SuspendedClients[clientID]; !ok {\n\t\treturn\n\t}\n\tdelete(registration.SuspendedClients, clientID)\n\tregistration.LastUpdated = time.Now()\n\tr.invalidateAvailableModelsCacheLocked()\n\tlog.Debugf(\"Resumed client %s for model %s\", clientID, modelID)\n}\n\n// ClientSupportsModel reports whether the client registered support for modelID.\nfunc (r *ModelRegistry) ClientSupportsModel(clientID, modelID string) bool {\n\tclientID = strings.TrimSpace(clientID)\n\tmodelID = strings.TrimSpace(modelID)\n\tif clientID == \"\" || modelID == \"\" {\n\t\treturn false\n\t}\n\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\tmodels, exists := r.clientModels[clientID]\n\tif !exists || len(models) == 0 {\n\t\treturn false\n\t}\n\n\tfor _, id := range models {\n\t\tif strings.EqualFold(strings.TrimSpace(id), modelID) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// GetAvailableModels returns all models that have at least one available client\n// Parameters:\n//   - handlerType: The handler type to filter models for (e.g., \"openai\", \"claude\", \"gemini\")\n//\n// Returns:\n//   - []map[string]any: List of available models in the requested format\nfunc (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any {\n\tnow := time.Now()\n\n\tr.mutex.RLock()\n\tif cache, ok := r.availableModelsCache[handlerType]; ok && (cache.expiresAt.IsZero() || now.Before(cache.expiresAt)) {\n\t\tmodels := cloneModelMaps(cache.models)\n\t\tr.mutex.RUnlock()\n\t\treturn models\n\t}\n\tr.mutex.RUnlock()\n\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tr.ensureAvailableModelsCacheLocked()\n\n\tif cache, ok := r.availableModelsCache[handlerType]; ok && (cache.expiresAt.IsZero() || now.Before(cache.expiresAt)) {\n\t\treturn cloneModelMaps(cache.models)\n\t}\n\n\tmodels, expiresAt := r.buildAvailableModelsLocked(handlerType, now)\n\tr.availableModelsCache[handlerType] = availableModelsCacheEntry{\n\t\tmodels:    cloneModelMaps(models),\n\t\texpiresAt: expiresAt,\n\t}\n\n\treturn models\n}\n\nfunc (r *ModelRegistry) buildAvailableModelsLocked(handlerType string, now time.Time) ([]map[string]any, time.Time) {\n\tmodels := make([]map[string]any, 0, len(r.models))\n\tvar expiresAt time.Time\n\n\tfor _, registration := range r.models {\n\t\tavailableClients := registration.Count\n\n\t\texpiredClients := 0\n\t\tfor _, quotaTime := range registration.QuotaExceededClients {\n\t\t\tif quotaTime == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trecoveryAt := quotaTime.Add(modelQuotaExceededWindow)\n\t\t\tif now.Before(recoveryAt) {\n\t\t\t\texpiredClients++\n\t\t\t\tif expiresAt.IsZero() || recoveryAt.Before(expiresAt) {\n\t\t\t\t\texpiresAt = recoveryAt\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tcooldownSuspended := 0\n\t\totherSuspended := 0\n\t\tif registration.SuspendedClients != nil {\n\t\t\tfor _, reason := range registration.SuspendedClients {\n\t\t\t\tif strings.EqualFold(reason, \"quota\") {\n\t\t\t\t\tcooldownSuspended++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\totherSuspended++\n\t\t\t}\n\t\t}\n\n\t\teffectiveClients := availableClients - expiredClients - otherSuspended\n\t\tif effectiveClients < 0 {\n\t\t\teffectiveClients = 0\n\t\t}\n\n\t\tif effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {\n\t\t\tmodel := r.convertModelToMap(registration.Info, handlerType)\n\t\t\tif model != nil {\n\t\t\t\tmodels = append(models, model)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn models, expiresAt\n}\n\nfunc cloneModelMaps(models []map[string]any) []map[string]any {\n\tcloned := make([]map[string]any, 0, len(models))\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcloned = append(cloned, nil)\n\t\t\tcontinue\n\t\t}\n\t\tcopyModel := make(map[string]any, len(model))\n\t\tfor key, value := range model {\n\t\t\tcopyModel[key] = cloneModelMapValue(value)\n\t\t}\n\t\tcloned = append(cloned, copyModel)\n\t}\n\treturn cloned\n}\n\nfunc cloneModelMapValue(value any) any {\n\tswitch typed := value.(type) {\n\tcase map[string]any:\n\t\tcopyMap := make(map[string]any, len(typed))\n\t\tfor key, entry := range typed {\n\t\t\tcopyMap[key] = cloneModelMapValue(entry)\n\t\t}\n\t\treturn copyMap\n\tcase []any:\n\t\tcopySlice := make([]any, len(typed))\n\t\tfor i, entry := range typed {\n\t\t\tcopySlice[i] = cloneModelMapValue(entry)\n\t\t}\n\t\treturn copySlice\n\tcase []string:\n\t\treturn append([]string(nil), typed...)\n\tdefault:\n\t\treturn value\n\t}\n}\n\n// GetAvailableModelsByProvider returns models available for the given provider identifier.\n// Parameters:\n//   - provider: Provider identifier (e.g., \"codex\", \"gemini\", \"antigravity\")\n//\n// Returns:\n//   - []*ModelInfo: List of available models for the provider\nfunc (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelInfo {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif provider == \"\" {\n\t\treturn nil\n\t}\n\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\ttype providerModel struct {\n\t\tcount int\n\t\tinfo  *ModelInfo\n\t}\n\n\tproviderModels := make(map[string]*providerModel)\n\n\tfor clientID, clientProvider := range r.clientProviders {\n\t\tif clientProvider != provider {\n\t\t\tcontinue\n\t\t}\n\t\tmodelIDs := r.clientModels[clientID]\n\t\tif len(modelIDs) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tclientInfos := r.clientModelInfos[clientID]\n\t\tfor _, modelID := range modelIDs {\n\t\t\tmodelID = strings.TrimSpace(modelID)\n\t\t\tif modelID == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tentry := providerModels[modelID]\n\t\t\tif entry == nil {\n\t\t\t\tentry = &providerModel{}\n\t\t\t\tproviderModels[modelID] = entry\n\t\t\t}\n\t\t\tentry.count++\n\t\t\tif entry.info == nil {\n\t\t\t\tif clientInfos != nil {\n\t\t\t\t\tif info := clientInfos[modelID]; info != nil {\n\t\t\t\t\t\tentry.info = info\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif entry.info == nil {\n\t\t\t\t\tif reg, ok := r.models[modelID]; ok && reg != nil && reg.Info != nil {\n\t\t\t\t\t\tentry.info = reg.Info\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(providerModels) == 0 {\n\t\treturn nil\n\t}\n\n\tnow := time.Now()\n\tresult := make([]*ModelInfo, 0, len(providerModels))\n\n\tfor modelID, entry := range providerModels {\n\t\tif entry == nil || entry.count <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tregistration, ok := r.models[modelID]\n\n\t\texpiredClients := 0\n\t\tcooldownSuspended := 0\n\t\totherSuspended := 0\n\t\tif ok && registration != nil {\n\t\t\tif registration.QuotaExceededClients != nil {\n\t\t\t\tfor clientID, quotaTime := range registration.QuotaExceededClients {\n\t\t\t\t\tif clientID == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif quotaTime != nil && now.Sub(*quotaTime) < modelQuotaExceededWindow {\n\t\t\t\t\t\texpiredClients++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif registration.SuspendedClients != nil {\n\t\t\t\tfor clientID, reason := range registration.SuspendedClients {\n\t\t\t\t\tif clientID == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif strings.EqualFold(reason, \"quota\") {\n\t\t\t\t\t\tcooldownSuspended++\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\totherSuspended++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tavailableClients := entry.count\n\t\teffectiveClients := availableClients - expiredClients - otherSuspended\n\t\tif effectiveClients < 0 {\n\t\t\teffectiveClients = 0\n\t\t}\n\n\t\tif effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {\n\t\t\tif entry.info != nil {\n\t\t\t\tresult = append(result, cloneModelInfo(entry.info))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif ok && registration != nil && registration.Info != nil {\n\t\t\t\tresult = append(result, cloneModelInfo(registration.Info))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\n// GetModelCount returns the number of available clients for a specific model\n// Parameters:\n//   - modelID: The model ID to check\n//\n// Returns:\n//   - int: Number of available clients for the model\nfunc (r *ModelRegistry) GetModelCount(modelID string) int {\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\tif registration, exists := r.models[modelID]; exists {\n\t\tnow := time.Now()\n\n\t\t// Count clients that have exceeded quota but haven't recovered yet\n\t\texpiredClients := 0\n\t\tfor _, quotaTime := range registration.QuotaExceededClients {\n\t\t\tif quotaTime != nil && now.Sub(*quotaTime) < modelQuotaExceededWindow {\n\t\t\t\texpiredClients++\n\t\t\t}\n\t\t}\n\t\tsuspendedClients := 0\n\t\tif registration.SuspendedClients != nil {\n\t\t\tsuspendedClients = len(registration.SuspendedClients)\n\t\t}\n\t\tresult := registration.Count - expiredClients - suspendedClients\n\t\tif result < 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn result\n\t}\n\treturn 0\n}\n\n// GetModelProviders returns provider identifiers that currently supply the given model\n// Parameters:\n//   - modelID: The model ID to check\n//\n// Returns:\n//   - []string: Provider identifiers ordered by availability count (descending)\nfunc (r *ModelRegistry) GetModelProviders(modelID string) []string {\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\tregistration, exists := r.models[modelID]\n\tif !exists || registration == nil || len(registration.Providers) == 0 {\n\t\treturn nil\n\t}\n\n\ttype providerCount struct {\n\t\tname  string\n\t\tcount int\n\t}\n\tproviders := make([]providerCount, 0, len(registration.Providers))\n\t// suspendedByProvider := make(map[string]int)\n\t// if registration.SuspendedClients != nil {\n\t// \tfor clientID := range registration.SuspendedClients {\n\t// \t\tif provider, ok := r.clientProviders[clientID]; ok && provider != \"\" {\n\t// \t\t\tsuspendedByProvider[provider]++\n\t// \t\t}\n\t// \t}\n\t// }\n\tfor name, count := range registration.Providers {\n\t\tif count <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\t// adjusted := count - suspendedByProvider[name]\n\t\t// if adjusted <= 0 {\n\t\t// \tcontinue\n\t\t// }\n\t\t// providers = append(providers, providerCount{name: name, count: adjusted})\n\t\tproviders = append(providers, providerCount{name: name, count: count})\n\t}\n\tif len(providers) == 0 {\n\t\treturn nil\n\t}\n\n\tsort.Slice(providers, func(i, j int) bool {\n\t\tif providers[i].count == providers[j].count {\n\t\t\treturn providers[i].name < providers[j].name\n\t\t}\n\t\treturn providers[i].count > providers[j].count\n\t})\n\n\tresult := make([]string, 0, len(providers))\n\tfor _, item := range providers {\n\t\tresult = append(result, item.name)\n\t}\n\treturn result\n}\n\n// GetModelInfo returns ModelInfo, prioritizing provider-specific definition if available.\nfunc (r *ModelRegistry) GetModelInfo(modelID, provider string) *ModelInfo {\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\tif reg, ok := r.models[modelID]; ok && reg != nil {\n\t\t// Try provider specific definition first\n\t\tif provider != \"\" && reg.InfoByProvider != nil {\n\t\t\tif reg.Providers != nil {\n\t\t\t\tif count, ok := reg.Providers[provider]; ok && count > 0 {\n\t\t\t\t\tif info, ok := reg.InfoByProvider[provider]; ok && info != nil {\n\t\t\t\t\t\treturn cloneModelInfo(info)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Fallback to global info (last registered)\n\t\treturn cloneModelInfo(reg.Info)\n\t}\n\treturn nil\n}\n\n// convertModelToMap converts ModelInfo to the appropriate format for different handler types\nfunc (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) map[string]any {\n\tif model == nil {\n\t\treturn nil\n\t}\n\n\tswitch handlerType {\n\tcase \"openai\":\n\t\tresult := map[string]any{\n\t\t\t\"id\":       model.ID,\n\t\t\t\"object\":   \"model\",\n\t\t\t\"owned_by\": model.OwnedBy,\n\t\t}\n\t\tif model.Created > 0 {\n\t\t\tresult[\"created\"] = model.Created\n\t\t}\n\t\tif model.Type != \"\" {\n\t\t\tresult[\"type\"] = model.Type\n\t\t}\n\t\tif model.DisplayName != \"\" {\n\t\t\tresult[\"display_name\"] = model.DisplayName\n\t\t}\n\t\tif model.Version != \"\" {\n\t\t\tresult[\"version\"] = model.Version\n\t\t}\n\t\tif model.Description != \"\" {\n\t\t\tresult[\"description\"] = model.Description\n\t\t}\n\t\tif model.ContextLength > 0 {\n\t\t\tresult[\"context_length\"] = model.ContextLength\n\t\t}\n\t\tif model.MaxCompletionTokens > 0 {\n\t\t\tresult[\"max_completion_tokens\"] = model.MaxCompletionTokens\n\t\t}\n\t\tif len(model.SupportedParameters) > 0 {\n\t\t\tresult[\"supported_parameters\"] = append([]string(nil), model.SupportedParameters...)\n\t\t}\n\t\treturn result\n\n\tcase \"claude\":\n\t\tresult := map[string]any{\n\t\t\t\"id\":       model.ID,\n\t\t\t\"object\":   \"model\",\n\t\t\t\"owned_by\": model.OwnedBy,\n\t\t}\n\t\tif model.Created > 0 {\n\t\t\tresult[\"created_at\"] = model.Created\n\t\t}\n\t\tif model.Type != \"\" {\n\t\t\tresult[\"type\"] = \"model\"\n\t\t}\n\t\tif model.DisplayName != \"\" {\n\t\t\tresult[\"display_name\"] = model.DisplayName\n\t\t}\n\t\treturn result\n\n\tcase \"gemini\":\n\t\tresult := map[string]any{}\n\t\tif model.Name != \"\" {\n\t\t\tresult[\"name\"] = model.Name\n\t\t} else {\n\t\t\tresult[\"name\"] = model.ID\n\t\t}\n\t\tif model.Version != \"\" {\n\t\t\tresult[\"version\"] = model.Version\n\t\t}\n\t\tif model.DisplayName != \"\" {\n\t\t\tresult[\"displayName\"] = model.DisplayName\n\t\t}\n\t\tif model.Description != \"\" {\n\t\t\tresult[\"description\"] = model.Description\n\t\t}\n\t\tif model.InputTokenLimit > 0 {\n\t\t\tresult[\"inputTokenLimit\"] = model.InputTokenLimit\n\t\t}\n\t\tif model.OutputTokenLimit > 0 {\n\t\t\tresult[\"outputTokenLimit\"] = model.OutputTokenLimit\n\t\t}\n\t\tif len(model.SupportedGenerationMethods) > 0 {\n\t\t\tresult[\"supportedGenerationMethods\"] = append([]string(nil), model.SupportedGenerationMethods...)\n\t\t}\n\t\tif len(model.SupportedInputModalities) > 0 {\n\t\t\tresult[\"supportedInputModalities\"] = append([]string(nil), model.SupportedInputModalities...)\n\t\t}\n\t\tif len(model.SupportedOutputModalities) > 0 {\n\t\t\tresult[\"supportedOutputModalities\"] = append([]string(nil), model.SupportedOutputModalities...)\n\t\t}\n\t\treturn result\n\n\tdefault:\n\t\t// Generic format\n\t\tresult := map[string]any{\n\t\t\t\"id\":     model.ID,\n\t\t\t\"object\": \"model\",\n\t\t}\n\t\tif model.OwnedBy != \"\" {\n\t\t\tresult[\"owned_by\"] = model.OwnedBy\n\t\t}\n\t\tif model.Type != \"\" {\n\t\t\tresult[\"type\"] = model.Type\n\t\t}\n\t\tif model.Created != 0 {\n\t\t\tresult[\"created\"] = model.Created\n\t\t}\n\t\treturn result\n\t}\n}\n\n// CleanupExpiredQuotas removes expired quota tracking entries\nfunc (r *ModelRegistry) CleanupExpiredQuotas() {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\n\tnow := time.Now()\n\tinvalidated := false\n\n\tfor modelID, registration := range r.models {\n\t\tfor clientID, quotaTime := range registration.QuotaExceededClients {\n\t\t\tif quotaTime != nil && now.Sub(*quotaTime) >= modelQuotaExceededWindow {\n\t\t\t\tdelete(registration.QuotaExceededClients, clientID)\n\t\t\t\tinvalidated = true\n\t\t\t\tlog.Debugf(\"Cleaned up expired quota tracking for model %s, client %s\", modelID, clientID)\n\t\t\t}\n\t\t}\n\t}\n\tif invalidated {\n\t\tr.invalidateAvailableModelsCacheLocked()\n\t}\n}\n\n// GetFirstAvailableModel returns the first available model for the given handler type.\n// It prioritizes models by their creation timestamp (newest first) and checks if they have\n// available clients that are not suspended or over quota.\n//\n// Parameters:\n//   - handlerType: The API handler type (e.g., \"openai\", \"claude\", \"gemini\")\n//\n// Returns:\n//   - string: The model ID of the first available model, or empty string if none available\n//   - error: An error if no models are available\nfunc (r *ModelRegistry) GetFirstAvailableModel(handlerType string) (string, error) {\n\n\t// Get all available models for this handler type\n\tmodels := r.GetAvailableModels(handlerType)\n\tif len(models) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no models available for handler type: %s\", handlerType)\n\t}\n\n\t// Sort models by creation timestamp (newest first)\n\tsort.Slice(models, func(i, j int) bool {\n\t\t// Extract created timestamps from map\n\t\tcreatedI, okI := models[i][\"created\"].(int64)\n\t\tcreatedJ, okJ := models[j][\"created\"].(int64)\n\t\tif !okI || !okJ {\n\t\t\treturn false\n\t\t}\n\t\treturn createdI > createdJ\n\t})\n\n\t// Find the first model with available clients\n\tfor _, model := range models {\n\t\tif modelID, ok := model[\"id\"].(string); ok {\n\t\t\tif count := r.GetModelCount(modelID); count > 0 {\n\t\t\t\treturn modelID, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"no available clients for any model in handler type: %s\", handlerType)\n}\n\n// GetModelsForClient returns the models registered for a specific client.\n// Parameters:\n//   - clientID: The client identifier (typically auth file name or auth ID)\n//\n// Returns:\n//   - []*ModelInfo: List of models registered for this client, nil if client not found\nfunc (r *ModelRegistry) GetModelsForClient(clientID string) []*ModelInfo {\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\tmodelIDs, exists := r.clientModels[clientID]\n\tif !exists || len(modelIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Try to use client-specific model infos first\n\tclientInfos := r.clientModelInfos[clientID]\n\n\tseen := make(map[string]struct{})\n\tresult := make([]*ModelInfo, 0, len(modelIDs))\n\tfor _, modelID := range modelIDs {\n\t\tif _, dup := seen[modelID]; dup {\n\t\t\tcontinue\n\t\t}\n\t\tseen[modelID] = struct{}{}\n\n\t\t// Prefer client's own model info to preserve original type/owned_by\n\t\tif clientInfos != nil {\n\t\t\tif info, ok := clientInfos[modelID]; ok && info != nil {\n\t\t\t\tresult = append(result, cloneModelInfo(info))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t// Fallback to global registry (for backwards compatibility)\n\t\tif reg, ok := r.models[modelID]; ok && reg.Info != nil {\n\t\t\tresult = append(result, cloneModelInfo(reg.Info))\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/registry/model_registry_cache_test.go",
    "content": "package registry\n\nimport \"testing\"\n\nfunc TestGetAvailableModelsReturnsClonedSnapshots(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\", OwnedBy: \"team-a\", DisplayName: \"Model One\"}})\n\n\tfirst := r.GetAvailableModels(\"openai\")\n\tif len(first) != 1 {\n\t\tt.Fatalf(\"expected 1 model, got %d\", len(first))\n\t}\n\tfirst[0][\"id\"] = \"mutated\"\n\tfirst[0][\"display_name\"] = \"Mutated\"\n\n\tsecond := r.GetAvailableModels(\"openai\")\n\tif got := second[0][\"id\"]; got != \"m1\" {\n\t\tt.Fatalf(\"expected cached snapshot to stay isolated, got id %v\", got)\n\t}\n\tif got := second[0][\"display_name\"]; got != \"Model One\" {\n\t\tt.Fatalf(\"expected cached snapshot to stay isolated, got display_name %v\", got)\n\t}\n}\n\nfunc TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\", OwnedBy: \"team-a\", DisplayName: \"Model One\"}})\n\n\tmodels := r.GetAvailableModels(\"openai\")\n\tif len(models) != 1 {\n\t\tt.Fatalf(\"expected 1 model, got %d\", len(models))\n\t}\n\tif got := models[0][\"display_name\"]; got != \"Model One\" {\n\t\tt.Fatalf(\"expected initial display_name Model One, got %v\", got)\n\t}\n\n\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\", OwnedBy: \"team-a\", DisplayName: \"Model One Updated\"}})\n\tmodels = r.GetAvailableModels(\"openai\")\n\tif got := models[0][\"display_name\"]; got != \"Model One Updated\" {\n\t\tt.Fatalf(\"expected updated display_name after cache invalidation, got %v\", got)\n\t}\n\n\tr.SuspendClientModel(\"client-1\", \"m1\", \"manual\")\n\tmodels = r.GetAvailableModels(\"openai\")\n\tif len(models) != 0 {\n\t\tt.Fatalf(\"expected no available models after suspension, got %d\", len(models))\n\t}\n\n\tr.ResumeClientModel(\"client-1\", \"m1\")\n\tmodels = r.GetAvailableModels(\"openai\")\n\tif len(models) != 1 {\n\t\tt.Fatalf(\"expected model to reappear after resume, got %d\", len(models))\n\t}\n}\n"
  },
  {
    "path": "internal/registry/model_registry_hook_test.go",
    "content": "package registry\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc newTestModelRegistry() *ModelRegistry {\n\treturn &ModelRegistry{\n\t\tmodels:           make(map[string]*ModelRegistration),\n\t\tclientModels:     make(map[string][]string),\n\t\tclientModelInfos: make(map[string]map[string]*ModelInfo),\n\t\tclientProviders:  make(map[string]string),\n\t\tmutex:            &sync.RWMutex{},\n\t}\n}\n\ntype registeredCall struct {\n\tprovider string\n\tclientID string\n\tmodels   []*ModelInfo\n}\n\ntype unregisteredCall struct {\n\tprovider string\n\tclientID string\n}\n\ntype capturingHook struct {\n\tregisteredCh   chan registeredCall\n\tunregisteredCh chan unregisteredCall\n}\n\nfunc (h *capturingHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {\n\th.registeredCh <- registeredCall{provider: provider, clientID: clientID, models: models}\n}\n\nfunc (h *capturingHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {\n\th.unregisteredCh <- unregisteredCall{provider: provider, clientID: clientID}\n}\n\nfunc TestModelRegistryHook_OnModelsRegisteredCalled(t *testing.T) {\n\tr := newTestModelRegistry()\n\thook := &capturingHook{\n\t\tregisteredCh:   make(chan registeredCall, 1),\n\t\tunregisteredCh: make(chan unregisteredCall, 1),\n\t}\n\tr.SetHook(hook)\n\n\tinputModels := []*ModelInfo{\n\t\t{ID: \"m1\", DisplayName: \"Model One\"},\n\t\t{ID: \"m2\", DisplayName: \"Model Two\"},\n\t}\n\tr.RegisterClient(\"client-1\", \"OpenAI\", inputModels)\n\n\tselect {\n\tcase call := <-hook.registeredCh:\n\t\tif call.provider != \"openai\" {\n\t\t\tt.Fatalf(\"provider mismatch: got %q, want %q\", call.provider, \"openai\")\n\t\t}\n\t\tif call.clientID != \"client-1\" {\n\t\t\tt.Fatalf(\"clientID mismatch: got %q, want %q\", call.clientID, \"client-1\")\n\t\t}\n\t\tif len(call.models) != 2 {\n\t\t\tt.Fatalf(\"models length mismatch: got %d, want %d\", len(call.models), 2)\n\t\t}\n\t\tif call.models[0] == nil || call.models[0].ID != \"m1\" {\n\t\t\tt.Fatalf(\"models[0] mismatch: got %#v, want ID=%q\", call.models[0], \"m1\")\n\t\t}\n\t\tif call.models[1] == nil || call.models[1].ID != \"m2\" {\n\t\t\tt.Fatalf(\"models[1] mismatch: got %#v, want ID=%q\", call.models[1], \"m2\")\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for OnModelsRegistered hook call\")\n\t}\n}\n\nfunc TestModelRegistryHook_OnModelsUnregisteredCalled(t *testing.T) {\n\tr := newTestModelRegistry()\n\thook := &capturingHook{\n\t\tregisteredCh:   make(chan registeredCall, 1),\n\t\tunregisteredCh: make(chan unregisteredCall, 1),\n\t}\n\tr.SetHook(hook)\n\n\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\"}})\n\tselect {\n\tcase <-hook.registeredCh:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for OnModelsRegistered hook call\")\n\t}\n\n\tr.UnregisterClient(\"client-1\")\n\n\tselect {\n\tcase call := <-hook.unregisteredCh:\n\t\tif call.provider != \"openai\" {\n\t\t\tt.Fatalf(\"provider mismatch: got %q, want %q\", call.provider, \"openai\")\n\t\t}\n\t\tif call.clientID != \"client-1\" {\n\t\t\tt.Fatalf(\"clientID mismatch: got %q, want %q\", call.clientID, \"client-1\")\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for OnModelsUnregistered hook call\")\n\t}\n}\n\ntype blockingHook struct {\n\tstarted chan struct{}\n\tunblock chan struct{}\n}\n\nfunc (h *blockingHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {\n\tselect {\n\tcase <-h.started:\n\tdefault:\n\t\tclose(h.started)\n\t}\n\t<-h.unblock\n}\n\nfunc (h *blockingHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {}\n\nfunc TestModelRegistryHook_DoesNotBlockRegisterClient(t *testing.T) {\n\tr := newTestModelRegistry()\n\thook := &blockingHook{\n\t\tstarted: make(chan struct{}),\n\t\tunblock: make(chan struct{}),\n\t}\n\tr.SetHook(hook)\n\tdefer close(hook.unblock)\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\"}})\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-hook.started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for hook to start\")\n\t}\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(200 * time.Millisecond):\n\t\tt.Fatal(\"RegisterClient appears to be blocked by hook\")\n\t}\n\n\tif !r.ClientSupportsModel(\"client-1\", \"m1\") {\n\t\tt.Fatal(\"model registration failed; expected client to support model\")\n\t}\n}\n\ntype panicHook struct {\n\tregisteredCalled   chan struct{}\n\tunregisteredCalled chan struct{}\n}\n\nfunc (h *panicHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {\n\tif h.registeredCalled != nil {\n\t\th.registeredCalled <- struct{}{}\n\t}\n\tpanic(\"boom\")\n}\n\nfunc (h *panicHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {\n\tif h.unregisteredCalled != nil {\n\t\th.unregisteredCalled <- struct{}{}\n\t}\n\tpanic(\"boom\")\n}\n\nfunc TestModelRegistryHook_PanicDoesNotAffectRegistry(t *testing.T) {\n\tr := newTestModelRegistry()\n\thook := &panicHook{\n\t\tregisteredCalled:   make(chan struct{}, 1),\n\t\tunregisteredCalled: make(chan struct{}, 1),\n\t}\n\tr.SetHook(hook)\n\n\tr.RegisterClient(\"client-1\", \"OpenAI\", []*ModelInfo{{ID: \"m1\"}})\n\n\tselect {\n\tcase <-hook.registeredCalled:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for OnModelsRegistered hook call\")\n\t}\n\n\tif !r.ClientSupportsModel(\"client-1\", \"m1\") {\n\t\tt.Fatal(\"model registration failed; expected client to support model\")\n\t}\n\n\tr.UnregisterClient(\"client-1\")\n\n\tselect {\n\tcase <-hook.unregisteredCalled:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timeout waiting for OnModelsUnregistered hook call\")\n\t}\n}\n"
  },
  {
    "path": "internal/registry/model_registry_safety_test.go",
    "content": "package registry\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestGetModelInfoReturnsClone(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"gemini\", []*ModelInfo{{\n\t\tID:          \"m1\",\n\t\tDisplayName: \"Model One\",\n\t\tThinking:    &ThinkingSupport{Min: 1, Max: 2, Levels: []string{\"low\", \"high\"}},\n\t}})\n\n\tfirst := r.GetModelInfo(\"m1\", \"gemini\")\n\tif first == nil {\n\t\tt.Fatal(\"expected model info\")\n\t}\n\tfirst.DisplayName = \"mutated\"\n\tfirst.Thinking.Levels[0] = \"mutated\"\n\n\tsecond := r.GetModelInfo(\"m1\", \"gemini\")\n\tif second.DisplayName != \"Model One\" {\n\t\tt.Fatalf(\"expected cloned display name, got %q\", second.DisplayName)\n\t}\n\tif second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] != \"low\" {\n\t\tt.Fatalf(\"expected cloned thinking levels, got %+v\", second.Thinking)\n\t}\n}\n\nfunc TestGetModelsForClientReturnsClones(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"gemini\", []*ModelInfo{{\n\t\tID:          \"m1\",\n\t\tDisplayName: \"Model One\",\n\t\tThinking:    &ThinkingSupport{Levels: []string{\"low\", \"high\"}},\n\t}})\n\n\tfirst := r.GetModelsForClient(\"client-1\")\n\tif len(first) != 1 || first[0] == nil {\n\t\tt.Fatalf(\"expected one model, got %+v\", first)\n\t}\n\tfirst[0].DisplayName = \"mutated\"\n\tfirst[0].Thinking.Levels[0] = \"mutated\"\n\n\tsecond := r.GetModelsForClient(\"client-1\")\n\tif len(second) != 1 || second[0] == nil {\n\t\tt.Fatalf(\"expected one model on second fetch, got %+v\", second)\n\t}\n\tif second[0].DisplayName != \"Model One\" {\n\t\tt.Fatalf(\"expected cloned display name, got %q\", second[0].DisplayName)\n\t}\n\tif second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != \"low\" {\n\t\tt.Fatalf(\"expected cloned thinking levels, got %+v\", second[0].Thinking)\n\t}\n}\n\nfunc TestGetAvailableModelsByProviderReturnsClones(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"gemini\", []*ModelInfo{{\n\t\tID:          \"m1\",\n\t\tDisplayName: \"Model One\",\n\t\tThinking:    &ThinkingSupport{Levels: []string{\"low\", \"high\"}},\n\t}})\n\n\tfirst := r.GetAvailableModelsByProvider(\"gemini\")\n\tif len(first) != 1 || first[0] == nil {\n\t\tt.Fatalf(\"expected one model, got %+v\", first)\n\t}\n\tfirst[0].DisplayName = \"mutated\"\n\tfirst[0].Thinking.Levels[0] = \"mutated\"\n\n\tsecond := r.GetAvailableModelsByProvider(\"gemini\")\n\tif len(second) != 1 || second[0] == nil {\n\t\tt.Fatalf(\"expected one model on second fetch, got %+v\", second)\n\t}\n\tif second[0].DisplayName != \"Model One\" {\n\t\tt.Fatalf(\"expected cloned display name, got %q\", second[0].DisplayName)\n\t}\n\tif second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != \"low\" {\n\t\tt.Fatalf(\"expected cloned thinking levels, got %+v\", second[0].Thinking)\n\t}\n}\n\nfunc TestCleanupExpiredQuotasInvalidatesAvailableModelsCache(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"openai\", []*ModelInfo{{ID: \"m1\", Created: 1}})\n\tr.SetModelQuotaExceeded(\"client-1\", \"m1\")\n\tif models := r.GetAvailableModels(\"openai\"); len(models) != 1 {\n\t\tt.Fatalf(\"expected cooldown model to remain listed before cleanup, got %d\", len(models))\n\t}\n\n\tr.mutex.Lock()\n\tquotaTime := time.Now().Add(-6 * time.Minute)\n\tr.models[\"m1\"].QuotaExceededClients[\"client-1\"] = &quotaTime\n\tr.mutex.Unlock()\n\n\tr.CleanupExpiredQuotas()\n\n\tif count := r.GetModelCount(\"m1\"); count != 1 {\n\t\tt.Fatalf(\"expected model count 1 after cleanup, got %d\", count)\n\t}\n\tmodels := r.GetAvailableModels(\"openai\")\n\tif len(models) != 1 {\n\t\tt.Fatalf(\"expected model to stay available after cleanup, got %d\", len(models))\n\t}\n\tif got := models[0][\"id\"]; got != \"m1\" {\n\t\tt.Fatalf(\"expected model id m1, got %v\", got)\n\t}\n}\n\nfunc TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) {\n\tr := newTestModelRegistry()\n\tr.RegisterClient(\"client-1\", \"openai\", []*ModelInfo{{\n\t\tID:                  \"m1\",\n\t\tDisplayName:         \"Model One\",\n\t\tSupportedParameters: []string{\"temperature\", \"top_p\"},\n\t}})\n\n\tfirst := r.GetAvailableModels(\"openai\")\n\tif len(first) != 1 {\n\t\tt.Fatalf(\"expected one model, got %d\", len(first))\n\t}\n\tparams, ok := first[0][\"supported_parameters\"].([]string)\n\tif !ok || len(params) != 2 {\n\t\tt.Fatalf(\"expected supported_parameters slice, got %#v\", first[0][\"supported_parameters\"])\n\t}\n\tparams[0] = \"mutated\"\n\n\tsecond := r.GetAvailableModels(\"openai\")\n\tparams, ok = second[0][\"supported_parameters\"].([]string)\n\tif !ok || len(params) != 2 || params[0] != \"temperature\" {\n\t\tt.Fatalf(\"expected cloned supported_parameters, got %#v\", second[0][\"supported_parameters\"])\n\t}\n}\n\nfunc TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) {\n\tfirst := LookupModelInfo(\"glm-4.6\")\n\tif first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 {\n\t\tt.Fatalf(\"expected static model with thinking levels, got %+v\", first)\n\t}\n\tfirst.Thinking.Levels[0] = \"mutated\"\n\n\tsecond := LookupModelInfo(\"glm-4.6\")\n\tif second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == \"mutated\" {\n\t\tt.Fatalf(\"expected static lookup clone, got %+v\", second)\n\t}\n}\n"
  },
  {
    "path": "internal/registry/model_updater.go",
    "content": "package registry\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tmodelsFetchTimeout    = 30 * time.Second\n\tmodelsRefreshInterval = 3 * time.Hour\n)\n\nvar modelsURLs = []string{\n\t\"https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/models.json\",\n\t\"https://models.router-for.me/models.json\",\n}\n\n//go:embed models/models.json\nvar embeddedModelsJSON []byte\n\ntype modelStore struct {\n\tmu   sync.RWMutex\n\tdata *staticModelsJSON\n}\n\nvar modelsCatalogStore = &modelStore{}\n\nvar updaterOnce sync.Once\n\n// ModelRefreshCallback is invoked when startup or periodic model refresh detects changes.\n// changedProviders contains the provider names whose model definitions changed.\ntype ModelRefreshCallback func(changedProviders []string)\n\nvar (\n\trefreshCallbackMu     sync.Mutex\n\trefreshCallback       ModelRefreshCallback\n\tpendingRefreshChanges []string\n)\n\n// SetModelRefreshCallback registers a callback that is invoked when startup or\n// periodic model refresh detects changes. Only one callback is supported;\n// subsequent calls replace the previous callback.\nfunc SetModelRefreshCallback(cb ModelRefreshCallback) {\n\trefreshCallbackMu.Lock()\n\trefreshCallback = cb\n\tvar pending []string\n\tif cb != nil && len(pendingRefreshChanges) > 0 {\n\t\tpending = append([]string(nil), pendingRefreshChanges...)\n\t\tpendingRefreshChanges = nil\n\t}\n\trefreshCallbackMu.Unlock()\n\n\tif cb != nil && len(pending) > 0 {\n\t\tcb(pending)\n\t}\n}\n\nfunc init() {\n\t// Load embedded data as fallback on startup.\n\tif err := loadModelsFromBytes(embeddedModelsJSON, \"embed\"); err != nil {\n\t\tpanic(fmt.Sprintf(\"registry: failed to parse embedded models.json: %v\", err))\n\t}\n}\n\n// StartModelsUpdater starts a background updater that fetches models\n// immediately on startup and then refreshes the model catalog every 3 hours.\n// Safe to call multiple times; only one updater will run.\nfunc StartModelsUpdater(ctx context.Context) {\n\tupdaterOnce.Do(func() {\n\t\tgo runModelsUpdater(ctx)\n\t})\n}\n\nfunc runModelsUpdater(ctx context.Context) {\n\ttryStartupRefresh(ctx)\n\tperiodicRefresh(ctx)\n}\n\nfunc periodicRefresh(ctx context.Context) {\n\tticker := time.NewTicker(modelsRefreshInterval)\n\tdefer ticker.Stop()\n\tlog.Infof(\"periodic model refresh started (interval=%s)\", modelsRefreshInterval)\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\ttryPeriodicRefresh(ctx)\n\t\t}\n\t}\n}\n\n// tryPeriodicRefresh fetches models from remote, compares with the current\n// catalog, and notifies the registered callback if any provider changed.\nfunc tryPeriodicRefresh(ctx context.Context) {\n\ttryRefreshModels(ctx, \"periodic model refresh\")\n}\n\n// tryStartupRefresh fetches models from remote in the background during\n// process startup. It uses the same change detection as periodic refresh so\n// existing auth registrations can be updated after the callback is registered.\nfunc tryStartupRefresh(ctx context.Context) {\n\ttryRefreshModels(ctx, \"startup model refresh\")\n}\n\nfunc tryRefreshModels(ctx context.Context, label string) {\n\toldData := getModels()\n\n\tparsed, url := fetchModelsFromRemote(ctx)\n\tif parsed == nil {\n\t\tlog.Warnf(\"%s: fetch failed from all URLs, keeping current data\", label)\n\t\treturn\n\t}\n\n\t// Detect changes before updating store.\n\tchanged := detectChangedProviders(oldData, parsed)\n\n\t// Update store with new data regardless.\n\tmodelsCatalogStore.mu.Lock()\n\tmodelsCatalogStore.data = parsed\n\tmodelsCatalogStore.mu.Unlock()\n\n\tif len(changed) == 0 {\n\t\tlog.Infof(\"%s completed from %s, no changes detected\", label, url)\n\t\treturn\n\t}\n\n\tlog.Infof(\"%s completed from %s, changes detected for providers: %v\", label, url, changed)\n\tnotifyModelRefresh(changed)\n}\n\n// fetchModelsFromRemote tries all remote URLs and returns the parsed model catalog\n// along with the URL it was fetched from. Returns (nil, \"\") if all fetches fail.\nfunc fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) {\n\tclient := &http.Client{Timeout: modelsFetchTimeout}\n\tfor _, url := range modelsURLs {\n\t\treqCtx, cancel := context.WithTimeout(ctx, modelsFetchTimeout)\n\t\treq, err := http.NewRequestWithContext(reqCtx, \"GET\", url, nil)\n\t\tif err != nil {\n\t\t\tcancel()\n\t\t\tlog.Debugf(\"models fetch request creation failed for %s: %v\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tcancel()\n\t\t\tlog.Debugf(\"models fetch failed from %s: %v\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode != 200 {\n\t\t\tresp.Body.Close()\n\t\t\tcancel()\n\t\t\tlog.Debugf(\"models fetch returned %d from %s\", resp.StatusCode, url)\n\t\t\tcontinue\n\t\t}\n\n\t\tdata, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tcancel()\n\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"models fetch read error from %s: %v\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar parsed staticModelsJSON\n\t\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\t\tlog.Warnf(\"models parse failed from %s: %v\", url, err)\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateModelsCatalog(&parsed); err != nil {\n\t\t\tlog.Warnf(\"models validate failed from %s: %v\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\treturn &parsed, url\n\t}\n\treturn nil, \"\"\n}\n\n// detectChangedProviders compares two model catalogs and returns provider names\n// whose model definitions differ. Codex tiers (free/team/plus/pro) are grouped\n// under a single \"codex\" provider.\nfunc detectChangedProviders(oldData, newData *staticModelsJSON) []string {\n\tif oldData == nil || newData == nil {\n\t\treturn nil\n\t}\n\n\ttype section struct {\n\t\tprovider string\n\t\toldList  []*ModelInfo\n\t\tnewList  []*ModelInfo\n\t}\n\n\tsections := []section{\n\t\t{\"claude\", oldData.Claude, newData.Claude},\n\t\t{\"gemini\", oldData.Gemini, newData.Gemini},\n\t\t{\"vertex\", oldData.Vertex, newData.Vertex},\n\t\t{\"gemini-cli\", oldData.GeminiCLI, newData.GeminiCLI},\n\t\t{\"aistudio\", oldData.AIStudio, newData.AIStudio},\n\t\t{\"codex\", oldData.CodexFree, newData.CodexFree},\n\t\t{\"codex\", oldData.CodexTeam, newData.CodexTeam},\n\t\t{\"codex\", oldData.CodexPlus, newData.CodexPlus},\n\t\t{\"codex\", oldData.CodexPro, newData.CodexPro},\n\t\t{\"qwen\", oldData.Qwen, newData.Qwen},\n\t\t{\"iflow\", oldData.IFlow, newData.IFlow},\n\t\t{\"kimi\", oldData.Kimi, newData.Kimi},\n\t\t{\"antigravity\", oldData.Antigravity, newData.Antigravity},\n\t}\n\n\tseen := make(map[string]bool, len(sections))\n\tvar changed []string\n\tfor _, s := range sections {\n\t\tif seen[s.provider] {\n\t\t\tcontinue\n\t\t}\n\t\tif modelSectionChanged(s.oldList, s.newList) {\n\t\t\tchanged = append(changed, s.provider)\n\t\t\tseen[s.provider] = true\n\t\t}\n\t}\n\treturn changed\n}\n\n// modelSectionChanged reports whether two model slices differ.\nfunc modelSectionChanged(a, b []*ModelInfo) bool {\n\tif len(a) != len(b) {\n\t\treturn true\n\t}\n\tif len(a) == 0 {\n\t\treturn false\n\t}\n\taj, err1 := json.Marshal(a)\n\tbj, err2 := json.Marshal(b)\n\tif err1 != nil || err2 != nil {\n\t\treturn true\n\t}\n\treturn string(aj) != string(bj)\n}\n\nfunc notifyModelRefresh(changedProviders []string) {\n\tif len(changedProviders) == 0 {\n\t\treturn\n\t}\n\n\trefreshCallbackMu.Lock()\n\tcb := refreshCallback\n\tif cb == nil {\n\t\tpendingRefreshChanges = mergeProviderNames(pendingRefreshChanges, changedProviders)\n\t\trefreshCallbackMu.Unlock()\n\t\treturn\n\t}\n\trefreshCallbackMu.Unlock()\n\tcb(changedProviders)\n}\n\nfunc mergeProviderNames(existing, incoming []string) []string {\n\tif len(incoming) == 0 {\n\t\treturn existing\n\t}\n\tseen := make(map[string]struct{}, len(existing)+len(incoming))\n\tmerged := make([]string, 0, len(existing)+len(incoming))\n\tfor _, provider := range existing {\n\t\tname := strings.ToLower(strings.TrimSpace(provider))\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[name]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = struct{}{}\n\t\tmerged = append(merged, name)\n\t}\n\tfor _, provider := range incoming {\n\t\tname := strings.ToLower(strings.TrimSpace(provider))\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[name]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = struct{}{}\n\t\tmerged = append(merged, name)\n\t}\n\treturn merged\n}\n\nfunc loadModelsFromBytes(data []byte, source string) error {\n\tvar parsed staticModelsJSON\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\treturn fmt.Errorf(\"%s: decode models catalog: %w\", source, err)\n\t}\n\tif err := validateModelsCatalog(&parsed); err != nil {\n\t\treturn fmt.Errorf(\"%s: validate models catalog: %w\", source, err)\n\t}\n\n\tmodelsCatalogStore.mu.Lock()\n\tmodelsCatalogStore.data = &parsed\n\tmodelsCatalogStore.mu.Unlock()\n\treturn nil\n}\n\nfunc getModels() *staticModelsJSON {\n\tmodelsCatalogStore.mu.RLock()\n\tdefer modelsCatalogStore.mu.RUnlock()\n\treturn modelsCatalogStore.data\n}\n\nfunc validateModelsCatalog(data *staticModelsJSON) error {\n\tif data == nil {\n\t\treturn fmt.Errorf(\"catalog is nil\")\n\t}\n\n\trequiredSections := []struct {\n\t\tname   string\n\t\tmodels []*ModelInfo\n\t}{\n\t\t{name: \"claude\", models: data.Claude},\n\t\t{name: \"gemini\", models: data.Gemini},\n\t\t{name: \"vertex\", models: data.Vertex},\n\t\t{name: \"gemini-cli\", models: data.GeminiCLI},\n\t\t{name: \"aistudio\", models: data.AIStudio},\n\t\t{name: \"codex-free\", models: data.CodexFree},\n\t\t{name: \"codex-team\", models: data.CodexTeam},\n\t\t{name: \"codex-plus\", models: data.CodexPlus},\n\t\t{name: \"codex-pro\", models: data.CodexPro},\n\t\t{name: \"qwen\", models: data.Qwen},\n\t\t{name: \"iflow\", models: data.IFlow},\n\t\t{name: \"kimi\", models: data.Kimi},\n\t\t{name: \"antigravity\", models: data.Antigravity},\n\t}\n\n\tfor _, section := range requiredSections {\n\t\tif err := validateModelSection(section.name, section.models); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc validateModelSection(section string, models []*ModelInfo) error {\n\tif len(models) == 0 {\n\t\treturn fmt.Errorf(\"%s section is empty\", section)\n\t}\n\n\tseen := make(map[string]struct{}, len(models))\n\tfor i, model := range models {\n\t\tif model == nil {\n\t\t\treturn fmt.Errorf(\"%s[%d] is null\", section, i)\n\t\t}\n\t\tmodelID := strings.TrimSpace(model.ID)\n\t\tif modelID == \"\" {\n\t\t\treturn fmt.Errorf(\"%s[%d] has empty id\", section, i)\n\t\t}\n\t\tif _, exists := seen[modelID]; exists {\n\t\t\treturn fmt.Errorf(\"%s contains duplicate model id %q\", section, modelID)\n\t\t}\n\t\tseen[modelID] = struct{}{}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/registry/models/models.json",
    "content": "{\n  \"claude\": [\n    {\n      \"id\": \"claude-haiku-4-5-20251001\",\n      \"object\": \"model\",\n      \"created\": 1759276800,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.5 Haiku\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000,\n        \"zero_allowed\": true\n      }\n    },\n    {\n      \"id\": \"claude-sonnet-4-5-20250929\",\n      \"object\": \"model\",\n      \"created\": 1759104000,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.5 Sonnet\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000,\n        \"zero_allowed\": true\n      }\n    },\n    {\n      \"id\": \"claude-sonnet-4-6\",\n      \"object\": \"model\",\n      \"created\": 1771372800,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.6 Sonnet\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000,\n        \"zero_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"claude-opus-4-6\",\n      \"object\": \"model\",\n      \"created\": 1770318000,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.6 Opus\",\n      \"description\": \"Premium model combining maximum intelligence with practical performance\",\n      \"context_length\": 1000000,\n      \"max_completion_tokens\": 128000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000,\n        \"zero_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"max\"\n        ]\n      }\n    },\n    {\n      \"id\": \"claude-opus-4-5-20251101\",\n      \"object\": \"model\",\n      \"created\": 1761955200,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.5 Opus\",\n      \"description\": \"Premium model combining maximum intelligence with practical performance\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000,\n        \"zero_allowed\": true\n      }\n    },\n    {\n      \"id\": \"claude-opus-4-1-20250805\",\n      \"object\": \"model\",\n      \"created\": 1722945600,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4.1 Opus\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 32000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000\n      }\n    },\n    {\n      \"id\": \"claude-opus-4-20250514\",\n      \"object\": \"model\",\n      \"created\": 1715644800,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4 Opus\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 32000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000\n      }\n    },\n    {\n      \"id\": \"claude-sonnet-4-20250514\",\n      \"object\": \"model\",\n      \"created\": 1715644800,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 4 Sonnet\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000\n      }\n    },\n    {\n      \"id\": \"claude-3-7-sonnet-20250219\",\n      \"object\": \"model\",\n      \"created\": 1708300800,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 3.7 Sonnet\",\n      \"context_length\": 128000,\n      \"max_completion_tokens\": 8192,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 128000\n      }\n    },\n    {\n      \"id\": \"claude-3-5-haiku-20241022\",\n      \"object\": \"model\",\n      \"created\": 1729555200,\n      \"owned_by\": \"anthropic\",\n      \"type\": \"claude\",\n      \"display_name\": \"Claude 3.5 Haiku\",\n      \"context_length\": 128000,\n      \"max_completion_tokens\": 8192\n    }\n  ],\n  \"gemini\": [\n    {\n      \"id\": \"gemini-2.5-pro\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Pro\",\n      \"name\": \"models/gemini-2.5-pro\",\n      \"version\": \"2.5\",\n      \"description\": \"Stable release (June 17th, 2025) of Gemini 2.5 Pro\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash\",\n      \"name\": \"models/gemini-2.5-flash\",\n      \"version\": \"001\",\n      \"description\": \"Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-lite\",\n      \"object\": \"model\",\n      \"created\": 1753142400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash Lite\",\n      \"name\": \"models/gemini-2.5-flash-lite\",\n      \"version\": \"2.5\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Preview\",\n      \"name\": \"models/gemini-3-pro-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Gemini 3 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Pro Preview\",\n      \"name\": \"models/gemini-3.1-pro-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-image-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Image Preview\",\n      \"name\": \"models/gemini-3.1-flash-image-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Flash Image Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-flash-preview\",\n      \"object\": \"model\",\n      \"created\": 1765929600,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Flash Preview\",\n      \"name\": \"models/gemini-3-flash-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Our most intelligent model built for speed, combining frontier intelligence with superior search and grounding.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-lite-preview\",\n      \"object\": \"model\",\n      \"created\": 1776288000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Lite Preview\",\n      \"name\": \"models/gemini-3.1-flash-lite-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-image-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Image Preview\",\n      \"name\": \"models/gemini-3-pro-image-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Gemini 3 Pro Image Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    }\n  ],\n  \"vertex\": [\n    {\n      \"id\": \"gemini-2.5-pro\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Pro\",\n      \"name\": \"models/gemini-2.5-pro\",\n      \"version\": \"2.5\",\n      \"description\": \"Stable release (June 17th, 2025) of Gemini 2.5 Pro\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash\",\n      \"name\": \"models/gemini-2.5-flash\",\n      \"version\": \"001\",\n      \"description\": \"Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-lite\",\n      \"object\": \"model\",\n      \"created\": 1753142400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash Lite\",\n      \"name\": \"models/gemini-2.5-flash-lite\",\n      \"version\": \"2.5\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Preview\",\n      \"name\": \"models/gemini-3-pro-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Gemini 3 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-flash-preview\",\n      \"object\": \"model\",\n      \"created\": 1765929600,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Flash Preview\",\n      \"name\": \"models/gemini-3-flash-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Our most intelligent model built for speed, combining frontier intelligence with superior search and grounding.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Pro Preview\",\n      \"name\": \"models/gemini-3.1-pro-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-image-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Image Preview\",\n      \"name\": \"models/gemini-3.1-flash-image-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Flash Image Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-lite-preview\",\n      \"object\": \"model\",\n      \"created\": 1776288000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Lite Preview\",\n      \"name\": \"models/gemini-3.1-flash-lite-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-image-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Image Preview\",\n      \"name\": \"models/gemini-3-pro-image-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Gemini 3 Pro Image Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"imagen-4.0-generate-001\",\n      \"object\": \"model\",\n      \"created\": 1750000000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Imagen 4.0 Generate\",\n      \"name\": \"models/imagen-4.0-generate-001\",\n      \"version\": \"4.0\",\n      \"description\": \"Imagen 4.0 image generation model\",\n      \"supportedGenerationMethods\": [\n        \"predict\"\n      ]\n    },\n    {\n      \"id\": \"imagen-4.0-ultra-generate-001\",\n      \"object\": \"model\",\n      \"created\": 1750000000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Imagen 4.0 Ultra Generate\",\n      \"name\": \"models/imagen-4.0-ultra-generate-001\",\n      \"version\": \"4.0\",\n      \"description\": \"Imagen 4.0 Ultra high-quality image generation model\",\n      \"supportedGenerationMethods\": [\n        \"predict\"\n      ]\n    },\n    {\n      \"id\": \"imagen-3.0-generate-002\",\n      \"object\": \"model\",\n      \"created\": 1740000000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Imagen 3.0 Generate\",\n      \"name\": \"models/imagen-3.0-generate-002\",\n      \"version\": \"3.0\",\n      \"description\": \"Imagen 3.0 image generation model\",\n      \"supportedGenerationMethods\": [\n        \"predict\"\n      ]\n    },\n    {\n      \"id\": \"imagen-3.0-fast-generate-001\",\n      \"object\": \"model\",\n      \"created\": 1740000000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Imagen 3.0 Fast Generate\",\n      \"name\": \"models/imagen-3.0-fast-generate-001\",\n      \"version\": \"3.0\",\n      \"description\": \"Imagen 3.0 fast image generation model\",\n      \"supportedGenerationMethods\": [\n        \"predict\"\n      ]\n    },\n    {\n      \"id\": \"imagen-4.0-fast-generate-001\",\n      \"object\": \"model\",\n      \"created\": 1750000000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Imagen 4.0 Fast Generate\",\n      \"name\": \"models/imagen-4.0-fast-generate-001\",\n      \"version\": \"4.0\",\n      \"description\": \"Imagen 4.0 fast image generation model\",\n      \"supportedGenerationMethods\": [\n        \"predict\"\n      ]\n    }\n  ],\n  \"gemini-cli\": [\n    {\n      \"id\": \"gemini-2.5-pro\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Pro\",\n      \"name\": \"models/gemini-2.5-pro\",\n      \"version\": \"2.5\",\n      \"description\": \"Stable release (June 17th, 2025) of Gemini 2.5 Pro\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash\",\n      \"name\": \"models/gemini-2.5-flash\",\n      \"version\": \"001\",\n      \"description\": \"Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-lite\",\n      \"object\": \"model\",\n      \"created\": 1753142400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash Lite\",\n      \"name\": \"models/gemini-2.5-flash-lite\",\n      \"version\": \"2.5\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Preview\",\n      \"name\": \"models/gemini-3-pro-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Our most intelligent model with SOTA reasoning and multimodal understanding, and powerful agentic and vibe coding capabilities\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Pro Preview\",\n      \"name\": \"models/gemini-3.1-pro-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-flash-preview\",\n      \"object\": \"model\",\n      \"created\": 1765929600,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Flash Preview\",\n      \"name\": \"models/gemini-3-flash-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Our most intelligent model built for speed, combining frontier intelligence with superior search and grounding.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-lite-preview\",\n      \"object\": \"model\",\n      \"created\": 1776288000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Lite Preview\",\n      \"name\": \"models/gemini-3.1-flash-lite-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    }\n  ],\n  \"aistudio\": [\n    {\n      \"id\": \"gemini-2.5-pro\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Pro\",\n      \"name\": \"models/gemini-2.5-pro\",\n      \"version\": \"2.5\",\n      \"description\": \"Stable release (June 17th, 2025) of Gemini 2.5 Pro\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash\",\n      \"name\": \"models/gemini-2.5-flash\",\n      \"version\": \"001\",\n      \"description\": \"Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-lite\",\n      \"object\": \"model\",\n      \"created\": 1753142400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash Lite\",\n      \"name\": \"models/gemini-2.5-flash-lite\",\n      \"version\": \"2.5\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1737158400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Pro Preview\",\n      \"name\": \"models/gemini-3-pro-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Gemini 3 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-preview\",\n      \"object\": \"model\",\n      \"created\": 1771459200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Pro Preview\",\n      \"name\": \"models/gemini-3.1-pro-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Gemini 3.1 Pro Preview\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-flash-preview\",\n      \"object\": \"model\",\n      \"created\": 1765929600,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3 Flash Preview\",\n      \"name\": \"models/gemini-3-flash-preview\",\n      \"version\": \"3.0\",\n      \"description\": \"Our most intelligent model built for speed, combining frontier intelligence with superior search and grounding.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-lite-preview\",\n      \"object\": \"model\",\n      \"created\": 1776288000,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 3.1 Flash Lite Preview\",\n      \"name\": \"models/gemini-3.1-flash-lite-preview\",\n      \"version\": \"3.1\",\n      \"description\": \"Our smallest and most cost effective model, built for at scale usage.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-pro-latest\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini Pro Latest\",\n      \"name\": \"models/gemini-pro-latest\",\n      \"version\": \"2.5\",\n      \"description\": \"Latest release of Gemini Pro\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-flash-latest\",\n      \"object\": \"model\",\n      \"created\": 1750118400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini Flash Latest\",\n      \"name\": \"models/gemini-flash-latest\",\n      \"version\": \"2.5\",\n      \"description\": \"Latest release of Gemini Flash\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-flash-lite-latest\",\n      \"object\": \"model\",\n      \"created\": 1753142400,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini Flash-Lite Latest\",\n      \"name\": \"models/gemini-flash-lite-latest\",\n      \"version\": \"2.5\",\n      \"description\": \"Latest release of Gemini Flash-Lite\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 65536,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ],\n      \"thinking\": {\n        \"min\": 512,\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-image\",\n      \"object\": \"model\",\n      \"created\": 1759363200,\n      \"owned_by\": \"google\",\n      \"type\": \"gemini\",\n      \"display_name\": \"Gemini 2.5 Flash Image\",\n      \"name\": \"models/gemini-2.5-flash-image\",\n      \"version\": \"2.5\",\n      \"description\": \"State-of-the-art image generation and editing model.\",\n      \"inputTokenLimit\": 1048576,\n      \"outputTokenLimit\": 8192,\n      \"supportedGenerationMethods\": [\n        \"generateContent\",\n        \"countTokens\",\n        \"createCachedContent\",\n        \"batchGenerateContent\"\n      ]\n    }\n  ],\n  \"codex-free\": [\n    {\n      \"id\": \"gpt-5\",\n      \"object\": \"model\",\n      \"created\": 1754524800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5-2025-08-07\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex\",\n      \"object\": \"model\",\n      \"created\": 1757894400,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex\",\n      \"version\": \"gpt-5-2025-09-15\",\n      \"description\": \"Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762473600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex Mini\",\n      \"version\": \"gpt-5-2025-11-07\",\n      \"description\": \"Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Mini\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-max\",\n      \"object\": \"model\",\n      \"created\": 1763424000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Max\",\n      \"version\": \"gpt-5.1-max\",\n      \"description\": \"Stable version of GPT 5.1 Codex Max\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2-codex\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2 Codex\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    }\n  ],\n  \"codex-team\": [\n    {\n      \"id\": \"gpt-5\",\n      \"object\": \"model\",\n      \"created\": 1754524800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5-2025-08-07\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex\",\n      \"object\": \"model\",\n      \"created\": 1757894400,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex\",\n      \"version\": \"gpt-5-2025-09-15\",\n      \"description\": \"Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762473600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex Mini\",\n      \"version\": \"gpt-5-2025-11-07\",\n      \"description\": \"Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Mini\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-max\",\n      \"object\": \"model\",\n      \"created\": 1763424000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Max\",\n      \"version\": \"gpt-5.1-max\",\n      \"description\": \"Stable version of GPT 5.1 Codex Max\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2-codex\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2 Codex\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.3-codex\",\n      \"object\": \"model\",\n      \"created\": 1770307200,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.3 Codex\",\n      \"version\": \"gpt-5.3\",\n      \"description\": \"Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.4\",\n      \"object\": \"model\",\n      \"created\": 1772668800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.4\",\n      \"version\": \"gpt-5.4\",\n      \"description\": \"Stable version of GPT 5.4\",\n      \"context_length\": 1050000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    }\n  ],\n  \"codex-plus\": [\n    {\n      \"id\": \"gpt-5\",\n      \"object\": \"model\",\n      \"created\": 1754524800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5-2025-08-07\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex\",\n      \"object\": \"model\",\n      \"created\": 1757894400,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex\",\n      \"version\": \"gpt-5-2025-09-15\",\n      \"description\": \"Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762473600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex Mini\",\n      \"version\": \"gpt-5-2025-11-07\",\n      \"description\": \"Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Mini\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-max\",\n      \"object\": \"model\",\n      \"created\": 1763424000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Max\",\n      \"version\": \"gpt-5.1-max\",\n      \"description\": \"Stable version of GPT 5.1 Codex Max\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2-codex\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2 Codex\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.3-codex\",\n      \"object\": \"model\",\n      \"created\": 1770307200,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.3 Codex\",\n      \"version\": \"gpt-5.3\",\n      \"description\": \"Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.3-codex-spark\",\n      \"object\": \"model\",\n      \"created\": 1770912000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.3 Codex Spark\",\n      \"version\": \"gpt-5.3\",\n      \"description\": \"Ultra-fast coding model.\",\n      \"context_length\": 128000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.4\",\n      \"object\": \"model\",\n      \"created\": 1772668800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.4\",\n      \"version\": \"gpt-5.4\",\n      \"description\": \"Stable version of GPT 5.4\",\n      \"context_length\": 1050000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    }\n  ],\n  \"codex-pro\": [\n    {\n      \"id\": \"gpt-5\",\n      \"object\": \"model\",\n      \"created\": 1754524800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5-2025-08-07\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex\",\n      \"object\": \"model\",\n      \"created\": 1757894400,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex\",\n      \"version\": \"gpt-5-2025-09-15\",\n      \"description\": \"Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762473600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5 Codex Mini\",\n      \"version\": \"gpt-5-2025-11-07\",\n      \"description\": \"Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-mini\",\n      \"object\": \"model\",\n      \"created\": 1762905600,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Mini\",\n      \"version\": \"gpt-5.1-2025-11-12\",\n      \"description\": \"Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.1-codex-max\",\n      \"object\": \"model\",\n      \"created\": 1763424000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.1 Codex Max\",\n      \"version\": \"gpt-5.1-max\",\n      \"description\": \"Stable version of GPT 5.1 Codex Max\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.2-codex\",\n      \"object\": \"model\",\n      \"created\": 1765440000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.2 Codex\",\n      \"version\": \"gpt-5.2\",\n      \"description\": \"Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.3-codex\",\n      \"object\": \"model\",\n      \"created\": 1770307200,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.3 Codex\",\n      \"version\": \"gpt-5.3\",\n      \"description\": \"Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.\",\n      \"context_length\": 400000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.3-codex-spark\",\n      \"object\": \"model\",\n      \"created\": 1770912000,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.3 Codex Spark\",\n      \"version\": \"gpt-5.3\",\n      \"description\": \"Ultra-fast coding model.\",\n      \"context_length\": 128000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-5.4\",\n      \"object\": \"model\",\n      \"created\": 1772668800,\n      \"owned_by\": \"openai\",\n      \"type\": \"openai\",\n      \"display_name\": \"GPT 5.4\",\n      \"version\": \"gpt-5.4\",\n      \"description\": \"Stable version of GPT 5.4\",\n      \"context_length\": 1050000,\n      \"max_completion_tokens\": 128000,\n      \"supported_parameters\": [\n        \"tools\"\n      ],\n      \"thinking\": {\n        \"levels\": [\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    }\n  ],\n  \"qwen\": [\n    {\n      \"id\": \"qwen3-coder-plus\",\n      \"object\": \"model\",\n      \"created\": 1753228800,\n      \"owned_by\": \"qwen\",\n      \"type\": \"qwen\",\n      \"display_name\": \"Qwen3 Coder Plus\",\n      \"version\": \"3.0\",\n      \"description\": \"Advanced code generation and understanding model\",\n      \"context_length\": 32768,\n      \"max_completion_tokens\": 8192,\n      \"supported_parameters\": [\n        \"temperature\",\n        \"top_p\",\n        \"max_tokens\",\n        \"stream\",\n        \"stop\"\n      ]\n    },\n    {\n      \"id\": \"qwen3-coder-flash\",\n      \"object\": \"model\",\n      \"created\": 1753228800,\n      \"owned_by\": \"qwen\",\n      \"type\": \"qwen\",\n      \"display_name\": \"Qwen3 Coder Flash\",\n      \"version\": \"3.0\",\n      \"description\": \"Fast code generation model\",\n      \"context_length\": 8192,\n      \"max_completion_tokens\": 2048,\n      \"supported_parameters\": [\n        \"temperature\",\n        \"top_p\",\n        \"max_tokens\",\n        \"stream\",\n        \"stop\"\n      ]\n    },\n    {\n      \"id\": \"coder-model\",\n      \"object\": \"model\",\n      \"created\": 1771171200,\n      \"owned_by\": \"qwen\",\n      \"type\": \"qwen\",\n      \"display_name\": \"Qwen 3.5 Plus\",\n      \"version\": \"3.5\",\n      \"description\": \"efficient hybrid model with leading coding performance\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65536,\n      \"supported_parameters\": [\n        \"temperature\",\n        \"top_p\",\n        \"max_tokens\",\n        \"stream\",\n        \"stop\"\n      ]\n    },\n    {\n      \"id\": \"vision-model\",\n      \"object\": \"model\",\n      \"created\": 1758672000,\n      \"owned_by\": \"qwen\",\n      \"type\": \"qwen\",\n      \"display_name\": \"Qwen3 Vision Model\",\n      \"version\": \"3.0\",\n      \"description\": \"Vision model model\",\n      \"context_length\": 32768,\n      \"max_completion_tokens\": 2048,\n      \"supported_parameters\": [\n        \"temperature\",\n        \"top_p\",\n        \"max_tokens\",\n        \"stream\",\n        \"stop\"\n      ]\n    }\n  ],\n  \"iflow\": [\n    {\n      \"id\": \"qwen3-coder-plus\",\n      \"object\": \"model\",\n      \"created\": 1753228800,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-Coder-Plus\",\n      \"description\": \"Qwen3 Coder Plus code generation\"\n    },\n    {\n      \"id\": \"qwen3-max\",\n      \"object\": \"model\",\n      \"created\": 1758672000,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-Max\",\n      \"description\": \"Qwen3 flagship model\"\n    },\n    {\n      \"id\": \"qwen3-vl-plus\",\n      \"object\": \"model\",\n      \"created\": 1758672000,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-VL-Plus\",\n      \"description\": \"Qwen3 multimodal vision-language\"\n    },\n    {\n      \"id\": \"qwen3-max-preview\",\n      \"object\": \"model\",\n      \"created\": 1757030400,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-Max-Preview\",\n      \"description\": \"Qwen3 Max preview build\",\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"auto\",\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"glm-4.6\",\n      \"object\": \"model\",\n      \"created\": 1759190400,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"GLM-4.6\",\n      \"description\": \"Zhipu GLM 4.6 general model\",\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"auto\",\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"kimi-k2\",\n      \"object\": \"model\",\n      \"created\": 1752192000,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Kimi-K2\",\n      \"description\": \"Moonshot Kimi K2 general model\"\n    },\n    {\n      \"id\": \"deepseek-v3.2\",\n      \"object\": \"model\",\n      \"created\": 1759104000,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"DeepSeek-V3.2-Exp\",\n      \"description\": \"DeepSeek V3.2 experimental\",\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"auto\",\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"deepseek-v3.1\",\n      \"object\": \"model\",\n      \"created\": 1756339200,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"DeepSeek-V3.1-Terminus\",\n      \"description\": \"DeepSeek V3.1 Terminus\",\n      \"thinking\": {\n        \"levels\": [\n          \"none\",\n          \"auto\",\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\",\n          \"xhigh\"\n        ]\n      }\n    },\n    {\n      \"id\": \"deepseek-r1\",\n      \"object\": \"model\",\n      \"created\": 1737331200,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"DeepSeek-R1\",\n      \"description\": \"DeepSeek reasoning model R1\"\n    },\n    {\n      \"id\": \"deepseek-v3\",\n      \"object\": \"model\",\n      \"created\": 1734307200,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"DeepSeek-V3-671B\",\n      \"description\": \"DeepSeek V3 671B\"\n    },\n    {\n      \"id\": \"qwen3-32b\",\n      \"object\": \"model\",\n      \"created\": 1747094400,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-32B\",\n      \"description\": \"Qwen3 32B\"\n    },\n    {\n      \"id\": \"qwen3-235b-a22b-thinking-2507\",\n      \"object\": \"model\",\n      \"created\": 1753401600,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-235B-A22B-Thinking\",\n      \"description\": \"Qwen3 235B A22B Thinking (2507)\"\n    },\n    {\n      \"id\": \"qwen3-235b-a22b-instruct\",\n      \"object\": \"model\",\n      \"created\": 1753401600,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-235B-A22B-Instruct\",\n      \"description\": \"Qwen3 235B A22B Instruct\"\n    },\n    {\n      \"id\": \"qwen3-235b\",\n      \"object\": \"model\",\n      \"created\": 1753401600,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"Qwen3-235B-A22B\",\n      \"description\": \"Qwen3 235B A22B\"\n    },\n    {\n      \"id\": \"iflow-rome-30ba3b\",\n      \"object\": \"model\",\n      \"created\": 1736899200,\n      \"owned_by\": \"iflow\",\n      \"type\": \"iflow\",\n      \"display_name\": \"iFlow-ROME\",\n      \"description\": \"iFlow Rome 30BA3B model\"\n    }\n  ],\n  \"kimi\": [\n    {\n      \"id\": \"kimi-k2\",\n      \"object\": \"model\",\n      \"created\": 1752192000,\n      \"owned_by\": \"moonshot\",\n      \"type\": \"kimi\",\n      \"display_name\": \"Kimi K2\",\n      \"description\": \"Kimi K2 - Moonshot AI's flagship coding model\",\n      \"context_length\": 131072,\n      \"max_completion_tokens\": 32768\n    },\n    {\n      \"id\": \"kimi-k2-thinking\",\n      \"object\": \"model\",\n      \"created\": 1762387200,\n      \"owned_by\": \"moonshot\",\n      \"type\": \"kimi\",\n      \"display_name\": \"Kimi K2 Thinking\",\n      \"description\": \"Kimi K2 Thinking - Extended reasoning model\",\n      \"context_length\": 131072,\n      \"max_completion_tokens\": 32768,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 32000,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"kimi-k2.5\",\n      \"object\": \"model\",\n      \"created\": 1769472000,\n      \"owned_by\": \"moonshot\",\n      \"type\": \"kimi\",\n      \"display_name\": \"Kimi K2.5\",\n      \"description\": \"Kimi K2.5 - Latest Moonshot AI coding model with improved capabilities\",\n      \"context_length\": 131072,\n      \"max_completion_tokens\": 32768,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 32000,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    }\n  ],\n  \"antigravity\": [\n    {\n      \"id\": \"claude-opus-4-6-thinking\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Claude Opus 4.6 (Thinking)\",\n      \"name\": \"claude-opus-4-6-thinking\",\n      \"description\": \"Claude Opus 4.6 (Thinking)\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 64000,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"claude-sonnet-4-6\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Claude Sonnet 4.6 (Thinking)\",\n      \"name\": \"claude-sonnet-4-6\",\n      \"description\": \"Claude Sonnet 4.6 (Thinking)\",\n      \"context_length\": 200000,\n      \"max_completion_tokens\": 64000,\n      \"thinking\": {\n        \"min\": 1024,\n        \"max\": 64000,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 2.5 Flash\",\n      \"name\": \"gemini-2.5-flash\",\n      \"description\": \"Gemini 2.5 Flash\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-2.5-flash-lite\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 2.5 Flash Lite\",\n      \"name\": \"gemini-2.5-flash-lite\",\n      \"description\": \"Gemini 2.5 Flash Lite\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"max\": 24576,\n        \"zero_allowed\": true,\n        \"dynamic_allowed\": true\n      }\n    },\n    {\n      \"id\": \"gemini-3-flash\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3 Flash\",\n      \"name\": \"gemini-3-flash\",\n      \"description\": \"Gemini 3 Flash\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65536,\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-high\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3 Pro (High)\",\n      \"name\": \"gemini-3-pro-high\",\n      \"description\": \"Gemini 3 Pro (High)\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3-pro-low\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3 Pro (Low)\",\n      \"name\": \"gemini-3-pro-low\",\n      \"description\": \"Gemini 3 Pro (Low)\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-flash-image\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3.1 Flash Image\",\n      \"name\": \"gemini-3.1-flash-image\",\n      \"description\": \"Gemini 3.1 Flash Image\",\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"minimal\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-high\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3.1 Pro (High)\",\n      \"name\": \"gemini-3.1-pro-high\",\n      \"description\": \"Gemini 3.1 Pro (High)\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gemini-3.1-pro-low\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"Gemini 3.1 Pro (Low)\",\n      \"name\": \"gemini-3.1-pro-low\",\n      \"description\": \"Gemini 3.1 Pro (Low)\",\n      \"context_length\": 1048576,\n      \"max_completion_tokens\": 65535,\n      \"thinking\": {\n        \"min\": 128,\n        \"max\": 32768,\n        \"dynamic_allowed\": true,\n        \"levels\": [\n          \"low\",\n          \"high\"\n        ]\n      }\n    },\n    {\n      \"id\": \"gpt-oss-120b-medium\",\n      \"object\": \"model\",\n      \"owned_by\": \"antigravity\",\n      \"type\": \"antigravity\",\n      \"display_name\": \"GPT-OSS 120B (Medium)\",\n      \"name\": \"gpt-oss-120b-medium\",\n      \"description\": \"GPT-OSS 120B (Medium)\",\n      \"context_length\": 114000,\n      \"max_completion_tokens\": 32768\n    }\n  ]\n}"
  },
  {
    "path": "internal/runtime/executor/aistudio_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// This file implements the AI Studio executor that routes requests through a websocket-backed\n// transport for the AI Studio provider.\npackage executor\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// AIStudioExecutor routes AI Studio requests through a websocket-backed transport.\ntype AIStudioExecutor struct {\n\tprovider string\n\trelay    *wsrelay.Manager\n\tcfg      *config.Config\n}\n\n// NewAIStudioExecutor creates a new AI Studio executor instance.\n//\n// Parameters:\n//   - cfg: The application configuration\n//   - provider: The provider name\n//   - relay: The websocket relay manager\n//\n// Returns:\n//   - *AIStudioExecutor: A new AI Studio executor instance\nfunc NewAIStudioExecutor(cfg *config.Config, provider string, relay *wsrelay.Manager) *AIStudioExecutor {\n\treturn &AIStudioExecutor{provider: strings.ToLower(provider), relay: relay, cfg: cfg}\n}\n\n// Identifier returns the executor identifier.\nfunc (e *AIStudioExecutor) Identifier() string { return \"aistudio\" }\n\n// PrepareRequest prepares the HTTP request for execution (no-op for AI Studio).\nfunc (e *AIStudioExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {\n\treturn nil\n}\n\n// HttpRequest forwards an arbitrary HTTP request through the websocket relay.\nfunc (e *AIStudioExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"aistudio executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\tif e.relay == nil {\n\t\treturn nil, fmt.Errorf(\"aistudio executor: ws relay is nil\")\n\t}\n\tif auth == nil || auth.ID == \"\" {\n\t\treturn nil, fmt.Errorf(\"aistudio executor: missing auth\")\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif httpReq.URL == nil || strings.TrimSpace(httpReq.URL.String()) == \"\" {\n\t\treturn nil, fmt.Errorf(\"aistudio executor: request URL is empty\")\n\t}\n\n\tvar body []byte\n\tif httpReq.Body != nil {\n\t\tb, errRead := io.ReadAll(httpReq.Body)\n\t\tif errRead != nil {\n\t\t\treturn nil, errRead\n\t\t}\n\t\tbody = b\n\t\thttpReq.Body = io.NopCloser(bytes.NewReader(b))\n\t}\n\n\twsReq := &wsrelay.HTTPRequest{\n\t\tMethod:  httpReq.Method,\n\t\tURL:     httpReq.URL.String(),\n\t\tHeaders: httpReq.Header.Clone(),\n\t\tBody:    body,\n\t}\n\twsResp, errRelay := e.relay.NonStream(ctx, auth.ID, wsReq)\n\tif errRelay != nil {\n\t\treturn nil, errRelay\n\t}\n\tif wsResp == nil {\n\t\treturn nil, fmt.Errorf(\"aistudio executor: ws response is nil\")\n\t}\n\n\tstatusText := http.StatusText(wsResp.Status)\n\tif statusText == \"\" {\n\t\tstatusText = \"Unknown\"\n\t}\n\tresp := &http.Response{\n\t\tStatusCode:    wsResp.Status,\n\t\tStatus:        fmt.Sprintf(\"%d %s\", wsResp.Status, statusText),\n\t\tHeader:        wsResp.Headers.Clone(),\n\t\tBody:          io.NopCloser(bytes.NewReader(wsResp.Body)),\n\t\tContentLength: int64(len(wsResp.Body)),\n\t\tRequest:       httpReq,\n\t}\n\treturn resp, nil\n}\n\n// Execute performs a non-streaming request to the AI Studio API.\nfunc (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\ttranslatedReq, body, err := e.translateRequest(req, opts, false)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tendpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)\n\twsReq := &wsrelay.HTTPRequest{\n\t\tMethod:  http.MethodPost,\n\t\tURL:     endpoint,\n\t\tHeaders: http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\tBody:    body.payload,\n\t}\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       endpoint,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   wsReq.Headers.Clone(),\n\t\tBody:      body.payload,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\twsResp, err := e.relay.NonStream(ctx, authID, wsReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, wsResp.Status, wsResp.Headers.Clone())\n\tif len(wsResp.Body) > 0 {\n\t\tappendAPIResponseChunk(ctx, e.cfg, wsResp.Body)\n\t}\n\tif wsResp.Status < 200 || wsResp.Status >= 300 {\n\t\treturn resp, statusErr{code: wsResp.Status, msg: string(wsResp.Body)}\n\t}\n\treporter.publish(ctx, parseGeminiUsage(wsResp.Body))\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, &param)\n\tresp = cliproxyexecutor.Response{Payload: ensureColonSpacedJSON([]byte(out)), Headers: wsResp.Headers.Clone()}\n\treturn resp, nil\n}\n\n// ExecuteStream performs a streaming request to the AI Studio API.\nfunc (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\ttranslatedReq, body, err := e.translateRequest(req, opts, true)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)\n\twsReq := &wsrelay.HTTPRequest{\n\t\tMethod:  http.MethodPost,\n\t\tURL:     endpoint,\n\t\tHeaders: http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\tBody:    body.payload,\n\t}\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       endpoint,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   wsReq.Headers.Clone(),\n\t\tBody:      body.payload,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\twsStream, err := e.relay.Stream(ctx, authID, wsReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\tfirstEvent, ok := <-wsStream\n\tif !ok {\n\t\terr = fmt.Errorf(\"wsrelay: stream closed before start\")\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\tif firstEvent.Status > 0 && firstEvent.Status != http.StatusOK {\n\t\tmetadataLogged := false\n\t\tif firstEvent.Status > 0 {\n\t\t\trecordAPIResponseMetadata(ctx, e.cfg, firstEvent.Status, firstEvent.Headers.Clone())\n\t\t\tmetadataLogged = true\n\t\t}\n\t\tvar body bytes.Buffer\n\t\tif len(firstEvent.Payload) > 0 {\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, firstEvent.Payload)\n\t\t\tbody.Write(firstEvent.Payload)\n\t\t}\n\t\tif firstEvent.Type == wsrelay.MessageTypeStreamEnd {\n\t\t\treturn nil, statusErr{code: firstEvent.Status, msg: body.String()}\n\t\t}\n\t\tfor event := range wsStream {\n\t\t\tif event.Err != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, event.Err)\n\t\t\t\tif body.Len() == 0 {\n\t\t\t\t\tbody.WriteString(event.Err.Error())\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif !metadataLogged && event.Status > 0 {\n\t\t\t\trecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())\n\t\t\t\tmetadataLogged = true\n\t\t\t}\n\t\t\tif len(event.Payload) > 0 {\n\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, event.Payload)\n\t\t\t\tbody.Write(event.Payload)\n\t\t\t}\n\t\t\tif event.Type == wsrelay.MessageTypeStreamEnd {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn nil, statusErr{code: firstEvent.Status, msg: body.String()}\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func(first wsrelay.StreamEvent) {\n\t\tdefer close(out)\n\t\tvar param any\n\t\tmetadataLogged := false\n\t\tprocessEvent := func(event wsrelay.StreamEvent) bool {\n\t\t\tif event.Err != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, event.Err)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf(\"wsrelay: %v\", event.Err)}\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tswitch event.Type {\n\t\t\tcase wsrelay.MessageTypeStreamStart:\n\t\t\t\tif !metadataLogged && event.Status > 0 {\n\t\t\t\t\trecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())\n\t\t\t\t\tmetadataLogged = true\n\t\t\t\t}\n\t\t\tcase wsrelay.MessageTypeStreamChunk:\n\t\t\t\tif len(event.Payload) > 0 {\n\t\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, event.Payload)\n\t\t\t\t\tfiltered := FilterSSEUsageMetadata(event.Payload)\n\t\t\t\t\tif detail, ok := parseGeminiStreamUsage(filtered); ok {\n\t\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t\t}\n\t\t\t\t\tlines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, &param)\n\t\t\t\t\tfor i := range lines {\n\t\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\tcase wsrelay.MessageTypeStreamEnd:\n\t\t\t\treturn false\n\t\t\tcase wsrelay.MessageTypeHTTPResp:\n\t\t\t\tif !metadataLogged && event.Status > 0 {\n\t\t\t\t\trecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())\n\t\t\t\t\tmetadataLogged = true\n\t\t\t\t}\n\t\t\t\tif len(event.Payload) > 0 {\n\t\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, event.Payload)\n\t\t\t\t}\n\t\t\t\tlines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, &param)\n\t\t\t\tfor i := range lines {\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}\n\t\t\t\t}\n\t\t\t\treporter.publish(ctx, parseGeminiUsage(event.Payload))\n\t\t\t\treturn false\n\t\t\tcase wsrelay.MessageTypeError:\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, event.Err)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf(\"wsrelay: %v\", event.Err)}\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t\tif !processEvent(first) {\n\t\t\treturn\n\t\t}\n\t\tfor event := range wsStream {\n\t\t\tif !processEvent(event) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(firstEvent)\n\treturn &cliproxyexecutor.StreamResult{Headers: firstEvent.Headers.Clone(), Chunks: out}, nil\n}\n\n// CountTokens counts tokens for the given request using the AI Studio API.\nfunc (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\t_, body, err := e.translateRequest(req, opts, false)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\tbody.payload, _ = sjson.DeleteBytes(body.payload, \"generationConfig\")\n\tbody.payload, _ = sjson.DeleteBytes(body.payload, \"tools\")\n\tbody.payload, _ = sjson.DeleteBytes(body.payload, \"safetySettings\")\n\n\tendpoint := e.buildEndpoint(baseModel, \"countTokens\", \"\")\n\twsReq := &wsrelay.HTTPRequest{\n\t\tMethod:  http.MethodPost,\n\t\tURL:     endpoint,\n\t\tHeaders: http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\tBody:    body.payload,\n\t}\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       endpoint,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   wsReq.Headers.Clone(),\n\t\tBody:      body.payload,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\tresp, err := e.relay.NonStream(ctx, authID, wsReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, resp.Status, resp.Headers.Clone())\n\tif len(resp.Body) > 0 {\n\t\tappendAPIResponseChunk(ctx, e.cfg, resp.Body)\n\t}\n\tif resp.Status < 200 || resp.Status >= 300 {\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: resp.Status, msg: string(resp.Body)}\n\t}\n\ttotalTokens := gjson.GetBytes(resp.Body, \"totalTokens\").Int()\n\tif totalTokens <= 0 {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"wsrelay: totalTokens missing in response\")\n\t}\n\ttranslated := sdktranslator.TranslateTokenCount(ctx, body.toFormat, opts.SourceFormat, totalTokens, resp.Body)\n\treturn cliproxyexecutor.Response{Payload: []byte(translated)}, nil\n}\n\n// Refresh refreshes the authentication credentials (no-op for AI Studio).\nfunc (e *AIStudioExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\treturn auth, nil\n}\n\ntype translatedPayload struct {\n\tpayload  []byte\n\taction   string\n\ttoFormat sdktranslator.Format\n}\n\nfunc (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, translatedPayload, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)\n\tpayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)\n\tpayload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, translatedPayload{}, err\n\t}\n\tpayload = fixGeminiImageAspectRatio(baseModel, payload)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tpayload = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", payload, originalTranslated, requestedModel)\n\tpayload, _ = sjson.DeleteBytes(payload, \"generationConfig.maxOutputTokens\")\n\tpayload, _ = sjson.DeleteBytes(payload, \"generationConfig.responseMimeType\")\n\tpayload, _ = sjson.DeleteBytes(payload, \"generationConfig.responseJsonSchema\")\n\tmetadataAction := \"generateContent\"\n\tif req.Metadata != nil {\n\t\tif action, _ := req.Metadata[\"action\"].(string); action == \"countTokens\" {\n\t\t\tmetadataAction = action\n\t\t}\n\t}\n\taction := metadataAction\n\tif stream && action != \"countTokens\" {\n\t\taction = \"streamGenerateContent\"\n\t}\n\tpayload, _ = sjson.DeleteBytes(payload, \"session_id\")\n\treturn payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil\n}\n\nfunc (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string {\n\tbase := fmt.Sprintf(\"%s/%s/models/%s:%s\", glEndpoint, glAPIVersion, model, action)\n\tif action == \"streamGenerateContent\" {\n\t\tif alt == \"\" {\n\t\t\treturn base + \"?alt=sse\"\n\t\t}\n\t\treturn base + \"?$alt=\" + url.QueryEscape(alt)\n\t}\n\tif alt != \"\" && action != \"countTokens\" {\n\t\treturn base + \"?$alt=\" + url.QueryEscape(alt)\n\t}\n\treturn base\n}\n\n// ensureColonSpacedJSON normalizes JSON objects so that colons are followed by a single space while\n// keeping the payload otherwise compact. Non-JSON inputs are returned unchanged.\nfunc ensureColonSpacedJSON(payload []byte) []byte {\n\ttrimmed := bytes.TrimSpace(payload)\n\tif len(trimmed) == 0 {\n\t\treturn payload\n\t}\n\n\tvar decoded any\n\tif err := json.Unmarshal(trimmed, &decoded); err != nil {\n\t\treturn payload\n\t}\n\n\tindented, err := json.MarshalIndent(decoded, \"\", \"  \")\n\tif err != nil {\n\t\treturn payload\n\t}\n\n\tcompacted := make([]byte, 0, len(indented))\n\tinString := false\n\tskipSpace := false\n\n\tfor i := 0; i < len(indented); i++ {\n\t\tch := indented[i]\n\t\tif ch == '\"' {\n\t\t\t// A quote is escaped only when preceded by an odd number of consecutive backslashes.\n\t\t\t// For example: \"\\\\\\\"\" keeps the quote inside the string, but \"\\\\\\\\\" closes the string.\n\t\t\tbackslashes := 0\n\t\t\tfor j := i - 1; j >= 0 && indented[j] == '\\\\'; j-- {\n\t\t\t\tbackslashes++\n\t\t\t}\n\t\t\tif backslashes%2 == 0 {\n\t\t\t\tinString = !inString\n\t\t\t}\n\t\t}\n\n\t\tif !inString {\n\t\t\tif ch == '\\n' || ch == '\\r' {\n\t\t\t\tskipSpace = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif skipSpace {\n\t\t\t\tif ch == ' ' || ch == '\\t' {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tskipSpace = false\n\t\t\t}\n\t\t}\n\n\t\tcompacted = append(compacted, ch)\n\t}\n\n\treturn compacted\n}\n"
  },
  {
    "path": "internal/runtime/executor/antigravity_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// This file implements the Antigravity executor that proxies requests to the antigravity\n// upstream using OAuth credentials.\npackage executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"crypto/tls\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst (\n\tantigravityBaseURLDaily        = \"https://daily-cloudcode-pa.googleapis.com\"\n\tantigravitySandboxBaseURLDaily = \"https://daily-cloudcode-pa.sandbox.googleapis.com\"\n\tantigravityBaseURLProd         = \"https://cloudcode-pa.googleapis.com\"\n\tantigravityCountTokensPath     = \"/v1internal:countTokens\"\n\tantigravityStreamPath          = \"/v1internal:streamGenerateContent\"\n\tantigravityGeneratePath        = \"/v1internal:generateContent\"\n\tantigravityClientID            = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\"\n\tantigravityClientSecret        = \"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\"\n\tdefaultAntigravityAgent        = \"antigravity/1.19.6 darwin/arm64\"\n\tantigravityAuthType            = \"antigravity\"\n\trefreshSkew                    = 3000 * time.Second\n\t// systemInstruction              = \"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**\"\n)\n\nvar (\n\trandSource      = rand.New(rand.NewSource(time.Now().UnixNano()))\n\trandSourceMutex sync.Mutex\n)\n\n// AntigravityExecutor proxies requests to the antigravity upstream.\ntype AntigravityExecutor struct {\n\tcfg *config.Config\n}\n\n// NewAntigravityExecutor creates a new Antigravity executor instance.\n//\n// Parameters:\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *AntigravityExecutor: A new Antigravity executor instance\nfunc NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor {\n\treturn &AntigravityExecutor{cfg: cfg}\n}\n\n// antigravityTransport is a singleton HTTP/1.1 transport shared by all Antigravity requests.\n// It is initialized once via antigravityTransportOnce to avoid leaking a new connection pool\n// (and the goroutines managing it) on every request.\nvar (\n\tantigravityTransport     *http.Transport\n\tantigravityTransportOnce sync.Once\n)\n\nfunc cloneTransportWithHTTP11(base *http.Transport) *http.Transport {\n\tif base == nil {\n\t\treturn nil\n\t}\n\n\tclone := base.Clone()\n\tclone.ForceAttemptHTTP2 = false\n\t// Wipe TLSNextProto to prevent implicit HTTP/2 upgrade.\n\tclone.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)\n\tif clone.TLSClientConfig == nil {\n\t\tclone.TLSClientConfig = &tls.Config{}\n\t} else {\n\t\tclone.TLSClientConfig = clone.TLSClientConfig.Clone()\n\t}\n\t// Actively advertise only HTTP/1.1 in the ALPN handshake.\n\tclone.TLSClientConfig.NextProtos = []string{\"http/1.1\"}\n\treturn clone\n}\n\n// initAntigravityTransport creates the shared HTTP/1.1 transport exactly once.\nfunc initAntigravityTransport() {\n\tbase, ok := http.DefaultTransport.(*http.Transport)\n\tif !ok {\n\t\tbase = &http.Transport{}\n\t}\n\tantigravityTransport = cloneTransportWithHTTP11(base)\n}\n\n// newAntigravityHTTPClient creates an HTTP client specifically for Antigravity,\n// enforcing HTTP/1.1 by disabling HTTP/2 to perfectly mimic Node.js https defaults.\n// The underlying Transport is a singleton to avoid leaking connection pools.\nfunc newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {\n\tantigravityTransportOnce.Do(initAntigravityTransport)\n\n\tclient := newProxyAwareHTTPClient(ctx, cfg, auth, timeout)\n\t// If no transport is set, use the shared HTTP/1.1 transport.\n\tif client.Transport == nil {\n\t\tclient.Transport = antigravityTransport\n\t\treturn client\n\t}\n\n\t// Preserve proxy settings from proxy-aware transports while forcing HTTP/1.1.\n\tif transport, ok := client.Transport.(*http.Transport); ok {\n\t\tclient.Transport = cloneTransportWithHTTP11(transport)\n\t}\n\treturn client\n}\n\n// Identifier returns the executor identifier.\nfunc (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }\n\n// PrepareRequest injects Antigravity credentials into the outgoing HTTP request.\nfunc (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\ttoken, _, errToken := e.ensureAccessToken(req.Context(), auth)\n\tif errToken != nil {\n\t\treturn errToken\n\t}\n\tif strings.TrimSpace(token) == \"\" {\n\t\treturn statusErr{code: http.StatusUnauthorized, msg: \"missing access token\"}\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\treturn nil\n}\n\n// HttpRequest injects Antigravity credentials into the request and executes it.\n// It uses a whitelist approach: all incoming headers are stripped and only\n// the minimum set required by the Antigravity protocol is explicitly set.\nfunc (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"antigravity executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\n\t// --- Whitelist: save only the headers we need from the original request ---\n\tcontentType := httpReq.Header.Get(\"Content-Type\")\n\n\t// Wipe ALL incoming headers\n\tfor k := range httpReq.Header {\n\t\tdelete(httpReq.Header, k)\n\t}\n\n\t// --- Set only the headers Antigravity actually sends ---\n\tif contentType != \"\" {\n\t\thttpReq.Header.Set(\"Content-Type\", contentType)\n\t}\n\t// Content-Length is managed automatically by Go's http.Client from the Body\n\thttpReq.Header.Set(\"User-Agent\", resolveUserAgent(auth))\n\thttpReq.Close = true // sends Connection: close\n\n\t// Inject Authorization: Bearer <token>\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming request to the Antigravity API.\nfunc (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\tisClaude := strings.Contains(strings.ToLower(baseModel), \"claude\")\n\n\tif isClaude || strings.Contains(baseModel, \"gemini-3-pro\") || strings.Contains(baseModel, \"gemini-3.1-flash-image\") {\n\t\treturn e.executeClaudeNonStream(ctx, auth, req, opts)\n\t}\n\n\ttoken, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)\n\tif errToken != nil {\n\t\treturn resp, errToken\n\t}\n\tif updatedAuth != nil {\n\t\tauth = updatedAuth\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"antigravity\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\ttranslated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\ttranslated = applyPayloadConfigWithRoot(e.cfg, baseModel, \"antigravity\", \"request\", translated, originalTranslated, requestedModel)\n\n\tbaseURLs := antigravityBaseURLFallbackOrder(auth)\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\n\tattempts := antigravityRetryAttempts(auth, e.cfg)\n\nattemptLoop:\n\tfor attempt := 0; attempt < attempts; attempt++ {\n\t\tvar lastStatus int\n\t\tvar lastBody []byte\n\t\tvar lastErr error\n\n\t\tfor idx, baseURL := range baseURLs {\n\t\t\thttpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, false, opts.Alt, baseURL)\n\t\t\tif errReq != nil {\n\t\t\t\terr = errReq\n\t\t\t\treturn resp, err\n\t\t\t}\n\n\t\t\thttpResp, errDo := httpClient.Do(httpReq)\n\t\t\tif errDo != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\t\tif errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {\n\t\t\t\t\treturn resp, errDo\n\t\t\t\t}\n\t\t\t\tlastStatus = 0\n\t\t\t\tlastBody = nil\n\t\t\t\tlastErr = errDo\n\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: request error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\terr = errDo\n\t\t\t\treturn resp, err\n\t\t\t}\n\n\t\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\t\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t\tif errRead != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\terr = errRead\n\t\t\t\treturn resp, err\n\t\t\t}\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, bodyBytes)\n\n\t\t\tif httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {\n\t\t\t\tlog.Debugf(\"antigravity executor: upstream error status: %d, body: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), bodyBytes))\n\t\t\t\tlastStatus = httpResp.StatusCode\n\t\t\t\tlastBody = append([]byte(nil), bodyBytes...)\n\t\t\t\tlastErr = nil\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: rate limited on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {\n\t\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif attempt+1 < attempts {\n\t\t\t\t\t\tdelay := antigravityNoCapacityRetryDelay(attempt)\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity for model %s, retrying in %s (attempt %d/%d)\", baseModel, delay, attempt+1, attempts)\n\t\t\t\t\t\tif errWait := antigravityWait(ctx, delay); errWait != nil {\n\t\t\t\t\t\t\treturn resp, errWait\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue attemptLoop\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests {\n\t\t\t\t\tif retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {\n\t\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\terr = sErr\n\t\t\t\treturn resp, err\n\t\t\t}\n\n\t\t\treporter.publish(ctx, parseAntigravityUsage(bodyBytes))\n\t\t\tvar param any\n\t\t\tconverted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, &param)\n\t\t\tresp = cliproxyexecutor.Response{Payload: []byte(converted), Headers: httpResp.Header.Clone()}\n\t\t\treporter.ensurePublished(ctx)\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tswitch {\n\t\tcase lastStatus != 0:\n\t\t\tsErr := statusErr{code: lastStatus, msg: string(lastBody)}\n\t\t\tif lastStatus == http.StatusTooManyRequests {\n\t\t\t\tif retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {\n\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t}\n\t\t\t}\n\t\t\terr = sErr\n\t\tcase lastErr != nil:\n\t\t\terr = lastErr\n\t\tdefault:\n\t\t\terr = statusErr{code: http.StatusServiceUnavailable, msg: \"antigravity executor: no base url available\"}\n\t\t}\n\t\treturn resp, err\n\t}\n\n\treturn resp, err\n}\n\n// executeClaudeNonStream performs a claude non-streaming request to the Antigravity API.\nfunc (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttoken, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)\n\tif errToken != nil {\n\t\treturn resp, errToken\n\t}\n\tif updatedAuth != nil {\n\t\tauth = updatedAuth\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"antigravity\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\ttranslated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\ttranslated = applyPayloadConfigWithRoot(e.cfg, baseModel, \"antigravity\", \"request\", translated, originalTranslated, requestedModel)\n\n\tbaseURLs := antigravityBaseURLFallbackOrder(auth)\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\n\tattempts := antigravityRetryAttempts(auth, e.cfg)\n\nattemptLoop:\n\tfor attempt := 0; attempt < attempts; attempt++ {\n\t\tvar lastStatus int\n\t\tvar lastBody []byte\n\t\tvar lastErr error\n\n\t\tfor idx, baseURL := range baseURLs {\n\t\t\thttpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, true, opts.Alt, baseURL)\n\t\t\tif errReq != nil {\n\t\t\t\terr = errReq\n\t\t\t\treturn resp, err\n\t\t\t}\n\n\t\t\thttpResp, errDo := httpClient.Do(httpReq)\n\t\t\tif errDo != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\t\tif errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {\n\t\t\t\t\treturn resp, errDo\n\t\t\t\t}\n\t\t\t\tlastStatus = 0\n\t\t\t\tlastBody = nil\n\t\t\t\tlastErr = errDo\n\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: request error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\terr = errDo\n\t\t\t\treturn resp, err\n\t\t\t}\n\t\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\t\tif httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {\n\t\t\t\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\t\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t\t\t}\n\t\t\t\tif errRead != nil {\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\t\tif errors.Is(errRead, context.Canceled) || errors.Is(errRead, context.DeadlineExceeded) {\n\t\t\t\t\t\terr = errRead\n\t\t\t\t\t\treturn resp, err\n\t\t\t\t\t}\n\t\t\t\t\tif errCtx := ctx.Err(); errCtx != nil {\n\t\t\t\t\t\terr = errCtx\n\t\t\t\t\t\treturn resp, err\n\t\t\t\t\t}\n\t\t\t\t\tlastStatus = 0\n\t\t\t\t\tlastBody = nil\n\t\t\t\t\tlastErr = errRead\n\t\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: read error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\terr = errRead\n\t\t\t\t\treturn resp, err\n\t\t\t\t}\n\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, bodyBytes)\n\t\t\t\tlastStatus = httpResp.StatusCode\n\t\t\t\tlastBody = append([]byte(nil), bodyBytes...)\n\t\t\t\tlastErr = nil\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: rate limited on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {\n\t\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif attempt+1 < attempts {\n\t\t\t\t\t\tdelay := antigravityNoCapacityRetryDelay(attempt)\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity for model %s, retrying in %s (attempt %d/%d)\", baseModel, delay, attempt+1, attempts)\n\t\t\t\t\t\tif errWait := antigravityWait(ctx, delay); errWait != nil {\n\t\t\t\t\t\t\treturn resp, errWait\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue attemptLoop\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests {\n\t\t\t\t\tif retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {\n\t\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\terr = sErr\n\t\t\t\treturn resp, err\n\t\t\t}\n\n\t\t\tout := make(chan cliproxyexecutor.StreamChunk)\n\t\t\tgo func(resp *http.Response) {\n\t\t\t\tdefer close(out)\n\t\t\t\tdefer func() {\n\t\t\t\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\t\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t\tscanner := bufio.NewScanner(resp.Body)\n\t\t\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\t\t\tfor scanner.Scan() {\n\t\t\t\t\tline := scanner.Bytes()\n\t\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\n\t\t\t\t\t// Filter usage metadata for all models\n\t\t\t\t\t// Only retain usage statistics in the terminal chunk\n\t\t\t\t\tline = FilterSSEUsageMetadata(line)\n\n\t\t\t\t\tpayload := jsonPayload(line)\n\t\t\t\t\tif payload == nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif detail, ok := parseAntigravityStreamUsage(payload); ok {\n\t\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t\t}\n\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: payload}\n\t\t\t\t}\n\t\t\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t\t\t} else {\n\t\t\t\t\treporter.ensurePublished(ctx)\n\t\t\t\t}\n\t\t\t}(httpResp)\n\n\t\t\tvar buffer bytes.Buffer\n\t\t\tfor chunk := range out {\n\t\t\t\tif chunk.Err != nil {\n\t\t\t\t\treturn resp, chunk.Err\n\t\t\t\t}\n\t\t\t\tif len(chunk.Payload) > 0 {\n\t\t\t\t\t_, _ = buffer.Write(chunk.Payload)\n\t\t\t\t\t_, _ = buffer.Write([]byte(\"\\n\"))\n\t\t\t\t}\n\t\t\t}\n\t\t\tresp = cliproxyexecutor.Response{Payload: e.convertStreamToNonStream(buffer.Bytes())}\n\n\t\t\treporter.publish(ctx, parseAntigravityUsage(resp.Payload))\n\t\t\tvar param any\n\t\t\tconverted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, resp.Payload, &param)\n\t\t\tresp = cliproxyexecutor.Response{Payload: []byte(converted), Headers: httpResp.Header.Clone()}\n\t\t\treporter.ensurePublished(ctx)\n\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tswitch {\n\t\tcase lastStatus != 0:\n\t\t\tsErr := statusErr{code: lastStatus, msg: string(lastBody)}\n\t\t\tif lastStatus == http.StatusTooManyRequests {\n\t\t\t\tif retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {\n\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t}\n\t\t\t}\n\t\t\terr = sErr\n\t\tcase lastErr != nil:\n\t\t\terr = lastErr\n\t\tdefault:\n\t\t\terr = statusErr{code: http.StatusServiceUnavailable, msg: \"antigravity executor: no base url available\"}\n\t\t}\n\t\treturn resp, err\n\t}\n\n\treturn resp, err\n}\n\nfunc (e *AntigravityExecutor) convertStreamToNonStream(stream []byte) []byte {\n\tresponseTemplate := \"\"\n\tvar traceID string\n\tvar finishReason string\n\tvar modelVersion string\n\tvar responseID string\n\tvar role string\n\tvar usageRaw string\n\tparts := make([]map[string]interface{}, 0)\n\tvar pendingKind string\n\tvar pendingText strings.Builder\n\tvar pendingThoughtSig string\n\n\tflushPending := func() {\n\t\tif pendingKind == \"\" {\n\t\t\treturn\n\t\t}\n\t\ttext := pendingText.String()\n\t\tswitch pendingKind {\n\t\tcase \"text\":\n\t\t\tif strings.TrimSpace(text) == \"\" {\n\t\t\t\tpendingKind = \"\"\n\t\t\t\tpendingText.Reset()\n\t\t\t\tpendingThoughtSig = \"\"\n\t\t\t\treturn\n\t\t\t}\n\t\t\tparts = append(parts, map[string]interface{}{\"text\": text})\n\t\tcase \"thought\":\n\t\t\tif strings.TrimSpace(text) == \"\" && pendingThoughtSig == \"\" {\n\t\t\t\tpendingKind = \"\"\n\t\t\t\tpendingText.Reset()\n\t\t\t\tpendingThoughtSig = \"\"\n\t\t\t\treturn\n\t\t\t}\n\t\t\tpart := map[string]interface{}{\"thought\": true}\n\t\t\tpart[\"text\"] = text\n\t\t\tif pendingThoughtSig != \"\" {\n\t\t\t\tpart[\"thoughtSignature\"] = pendingThoughtSig\n\t\t\t}\n\t\t\tparts = append(parts, part)\n\t\t}\n\t\tpendingKind = \"\"\n\t\tpendingText.Reset()\n\t\tpendingThoughtSig = \"\"\n\t}\n\n\tnormalizePart := func(partResult gjson.Result) map[string]interface{} {\n\t\tvar m map[string]interface{}\n\t\t_ = json.Unmarshal([]byte(partResult.Raw), &m)\n\t\tif m == nil {\n\t\t\tm = map[string]interface{}{}\n\t\t}\n\t\tsig := partResult.Get(\"thoughtSignature\").String()\n\t\tif sig == \"\" {\n\t\t\tsig = partResult.Get(\"thought_signature\").String()\n\t\t}\n\t\tif sig != \"\" {\n\t\t\tm[\"thoughtSignature\"] = sig\n\t\t\tdelete(m, \"thought_signature\")\n\t\t}\n\t\tif inlineData, ok := m[\"inline_data\"]; ok {\n\t\t\tm[\"inlineData\"] = inlineData\n\t\t\tdelete(m, \"inline_data\")\n\t\t}\n\t\treturn m\n\t}\n\n\tfor _, line := range bytes.Split(stream, []byte(\"\\n\")) {\n\t\ttrimmed := bytes.TrimSpace(line)\n\t\tif len(trimmed) == 0 || !gjson.ValidBytes(trimmed) {\n\t\t\tcontinue\n\t\t}\n\n\t\troot := gjson.ParseBytes(trimmed)\n\t\tresponseNode := root.Get(\"response\")\n\t\tif !responseNode.Exists() {\n\t\t\tif root.Get(\"candidates\").Exists() {\n\t\t\t\tresponseNode = root\n\t\t\t} else {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tresponseTemplate = responseNode.Raw\n\n\t\tif traceResult := root.Get(\"traceId\"); traceResult.Exists() && traceResult.String() != \"\" {\n\t\t\ttraceID = traceResult.String()\n\t\t}\n\n\t\tif roleResult := responseNode.Get(\"candidates.0.content.role\"); roleResult.Exists() {\n\t\t\trole = roleResult.String()\n\t\t}\n\n\t\tif finishResult := responseNode.Get(\"candidates.0.finishReason\"); finishResult.Exists() && finishResult.String() != \"\" {\n\t\t\tfinishReason = finishResult.String()\n\t\t}\n\n\t\tif modelResult := responseNode.Get(\"modelVersion\"); modelResult.Exists() && modelResult.String() != \"\" {\n\t\t\tmodelVersion = modelResult.String()\n\t\t}\n\t\tif responseIDResult := responseNode.Get(\"responseId\"); responseIDResult.Exists() && responseIDResult.String() != \"\" {\n\t\t\tresponseID = responseIDResult.String()\n\t\t}\n\t\tif usageResult := responseNode.Get(\"usageMetadata\"); usageResult.Exists() {\n\t\t\tusageRaw = usageResult.Raw\n\t\t} else if usageMetadataResult := root.Get(\"usageMetadata\"); usageMetadataResult.Exists() {\n\t\t\tusageRaw = usageMetadataResult.Raw\n\t\t}\n\n\t\tif partsResult := responseNode.Get(\"candidates.0.content.parts\"); partsResult.IsArray() {\n\t\t\tfor _, part := range partsResult.Array() {\n\t\t\t\thasFunctionCall := part.Get(\"functionCall\").Exists()\n\t\t\t\thasInlineData := part.Get(\"inlineData\").Exists() || part.Get(\"inline_data\").Exists()\n\t\t\t\tsig := part.Get(\"thoughtSignature\").String()\n\t\t\t\tif sig == \"\" {\n\t\t\t\t\tsig = part.Get(\"thought_signature\").String()\n\t\t\t\t}\n\t\t\t\ttext := part.Get(\"text\").String()\n\t\t\t\tthought := part.Get(\"thought\").Bool()\n\n\t\t\t\tif hasFunctionCall || hasInlineData {\n\t\t\t\t\tflushPending()\n\t\t\t\t\tparts = append(parts, normalizePart(part))\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif thought || part.Get(\"text\").Exists() {\n\t\t\t\t\tkind := \"text\"\n\t\t\t\t\tif thought {\n\t\t\t\t\t\tkind = \"thought\"\n\t\t\t\t\t}\n\t\t\t\t\tif pendingKind != \"\" && pendingKind != kind {\n\t\t\t\t\t\tflushPending()\n\t\t\t\t\t}\n\t\t\t\t\tpendingKind = kind\n\t\t\t\t\tpendingText.WriteString(text)\n\t\t\t\t\tif kind == \"thought\" && sig != \"\" {\n\t\t\t\t\t\tpendingThoughtSig = sig\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tflushPending()\n\t\t\t\tparts = append(parts, normalizePart(part))\n\t\t\t}\n\t\t}\n\t}\n\tflushPending()\n\n\tif responseTemplate == \"\" {\n\t\tresponseTemplate = `{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[]}}]}`\n\t}\n\n\tpartsJSON, _ := json.Marshal(parts)\n\tresponseTemplate, _ = sjson.SetRaw(responseTemplate, \"candidates.0.content.parts\", string(partsJSON))\n\tif role != \"\" {\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"candidates.0.content.role\", role)\n\t}\n\tif finishReason != \"\" {\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"candidates.0.finishReason\", finishReason)\n\t}\n\tif modelVersion != \"\" {\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"modelVersion\", modelVersion)\n\t}\n\tif responseID != \"\" {\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"responseId\", responseID)\n\t}\n\tif usageRaw != \"\" {\n\t\tresponseTemplate, _ = sjson.SetRaw(responseTemplate, \"usageMetadata\", usageRaw)\n\t} else if !gjson.Get(responseTemplate, \"usageMetadata\").Exists() {\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"usageMetadata.promptTokenCount\", 0)\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"usageMetadata.candidatesTokenCount\", 0)\n\t\tresponseTemplate, _ = sjson.Set(responseTemplate, \"usageMetadata.totalTokenCount\", 0)\n\t}\n\n\toutput := `{\"response\":{},\"traceId\":\"\"}`\n\toutput, _ = sjson.SetRaw(output, \"response\", responseTemplate)\n\tif traceID != \"\" {\n\t\toutput, _ = sjson.Set(output, \"traceId\", traceID)\n\t}\n\treturn []byte(output)\n}\n\n// ExecuteStream performs a streaming request to the Antigravity API.\nfunc (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tctx = context.WithValue(ctx, \"alt\", \"\")\n\n\ttoken, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)\n\tif errToken != nil {\n\t\treturn nil, errToken\n\t}\n\tif updatedAuth != nil {\n\t\tauth = updatedAuth\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"antigravity\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\ttranslated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\ttranslated = applyPayloadConfigWithRoot(e.cfg, baseModel, \"antigravity\", \"request\", translated, originalTranslated, requestedModel)\n\n\tbaseURLs := antigravityBaseURLFallbackOrder(auth)\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\n\tattempts := antigravityRetryAttempts(auth, e.cfg)\n\nattemptLoop:\n\tfor attempt := 0; attempt < attempts; attempt++ {\n\t\tvar lastStatus int\n\t\tvar lastBody []byte\n\t\tvar lastErr error\n\n\t\tfor idx, baseURL := range baseURLs {\n\t\t\thttpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, translated, true, opts.Alt, baseURL)\n\t\t\tif errReq != nil {\n\t\t\t\terr = errReq\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\thttpResp, errDo := httpClient.Do(httpReq)\n\t\t\tif errDo != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\t\tif errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {\n\t\t\t\t\treturn nil, errDo\n\t\t\t\t}\n\t\t\t\tlastStatus = 0\n\t\t\t\tlastBody = nil\n\t\t\t\tlastErr = errDo\n\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: request error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\terr = errDo\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\t\tif httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {\n\t\t\t\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\t\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t\t\t}\n\t\t\t\tif errRead != nil {\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\t\tif errors.Is(errRead, context.Canceled) || errors.Is(errRead, context.DeadlineExceeded) {\n\t\t\t\t\t\terr = errRead\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t\tif errCtx := ctx.Err(); errCtx != nil {\n\t\t\t\t\t\terr = errCtx\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t\tlastStatus = 0\n\t\t\t\t\tlastBody = nil\n\t\t\t\t\tlastErr = errRead\n\t\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: read error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\terr = errRead\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, bodyBytes)\n\t\t\t\tlastStatus = httpResp.StatusCode\n\t\t\t\tlastBody = append([]byte(nil), bodyBytes...)\n\t\t\t\tlastErr = nil\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {\n\t\t\t\t\tlog.Debugf(\"antigravity executor: rate limited on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {\n\t\t\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif attempt+1 < attempts {\n\t\t\t\t\t\tdelay := antigravityNoCapacityRetryDelay(attempt)\n\t\t\t\t\t\tlog.Debugf(\"antigravity executor: no capacity for model %s, retrying in %s (attempt %d/%d)\", baseModel, delay, attempt+1, attempts)\n\t\t\t\t\t\tif errWait := antigravityWait(ctx, delay); errWait != nil {\n\t\t\t\t\t\t\treturn nil, errWait\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue attemptLoop\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}\n\t\t\t\tif httpResp.StatusCode == http.StatusTooManyRequests {\n\t\t\t\t\tif retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {\n\t\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\terr = sErr\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tout := make(chan cliproxyexecutor.StreamChunk)\n\t\t\tgo func(resp *http.Response) {\n\t\t\t\tdefer close(out)\n\t\t\t\tdefer func() {\n\t\t\t\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\t\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t\tscanner := bufio.NewScanner(resp.Body)\n\t\t\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\t\t\tvar param any\n\t\t\t\tfor scanner.Scan() {\n\t\t\t\t\tline := scanner.Bytes()\n\t\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\n\t\t\t\t\t// Filter usage metadata for all models\n\t\t\t\t\t// Only retain usage statistics in the terminal chunk\n\t\t\t\t\tline = FilterSSEUsageMetadata(line)\n\n\t\t\t\t\tpayload := jsonPayload(line)\n\t\t\t\t\tif payload == nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif detail, ok := parseAntigravityStreamUsage(payload); ok {\n\t\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t\t}\n\n\t\t\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param)\n\t\t\t\t\tfor i := range chunks {\n\t\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte(\"[DONE]\"), &param)\n\t\t\t\tfor i := range tail {\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(tail[i])}\n\t\t\t\t}\n\t\t\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t\t\t} else {\n\t\t\t\t\treporter.ensurePublished(ctx)\n\t\t\t\t}\n\t\t\t}(httpResp)\n\t\t\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n\t\t}\n\n\t\tswitch {\n\t\tcase lastStatus != 0:\n\t\t\tsErr := statusErr{code: lastStatus, msg: string(lastBody)}\n\t\t\tif lastStatus == http.StatusTooManyRequests {\n\t\t\t\tif retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {\n\t\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t\t}\n\t\t\t}\n\t\t\terr = sErr\n\t\tcase lastErr != nil:\n\t\t\terr = lastErr\n\t\tdefault:\n\t\t\terr = statusErr{code: http.StatusServiceUnavailable, msg: \"antigravity executor: no base url available\"}\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn nil, err\n}\n\n// Refresh refreshes the authentication credentials using the refresh token.\nfunc (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tif auth == nil {\n\t\treturn auth, nil\n\t}\n\tupdated, errRefresh := e.refreshToken(ctx, auth.Clone())\n\tif errRefresh != nil {\n\t\treturn nil, errRefresh\n\t}\n\treturn updated, nil\n}\n\n// CountTokens counts tokens for the given request using the Antigravity API.\nfunc (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttoken, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)\n\tif errToken != nil {\n\t\treturn cliproxyexecutor.Response{}, errToken\n\t}\n\tif updatedAuth != nil {\n\t\tauth = updatedAuth\n\t}\n\tif strings.TrimSpace(token) == \"\" {\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: \"missing access token\"}\n\t}\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"antigravity\")\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\n\t// Prepare payload once (doesn't depend on baseURL)\n\tpayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tpayload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\tpayload = deleteJSONField(payload, \"project\")\n\tpayload = deleteJSONField(payload, \"model\")\n\tpayload = deleteJSONField(payload, \"request.safetySettings\")\n\n\tbaseURLs := antigravityBaseURLFallbackOrder(auth)\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\n\tvar lastStatus int\n\tvar lastBody []byte\n\tvar lastErr error\n\n\tfor idx, baseURL := range baseURLs {\n\t\tbase := strings.TrimSuffix(baseURL, \"/\")\n\t\tif base == \"\" {\n\t\t\tbase = buildBaseURL(auth)\n\t\t}\n\n\t\tvar requestURL strings.Builder\n\t\trequestURL.WriteString(base)\n\t\trequestURL.WriteString(antigravityCountTokensPath)\n\t\tif opts.Alt != \"\" {\n\t\t\trequestURL.WriteString(\"?$alt=\")\n\t\t\trequestURL.WriteString(url.QueryEscape(opts.Alt))\n\t\t}\n\n\t\thttpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))\n\t\tif errReq != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errReq\n\t\t}\n\t\thttpReq.Close = true\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t\thttpReq.Header.Set(\"User-Agent\", resolveUserAgent(auth))\n\t\tif host := resolveHost(base); host != \"\" {\n\t\t\thttpReq.Host = host\n\t\t}\n\n\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\tURL:       requestURL.String(),\n\t\t\tMethod:    http.MethodPost,\n\t\t\tHeaders:   httpReq.Header.Clone(),\n\t\t\tBody:      payload,\n\t\t\tProvider:  e.Identifier(),\n\t\t\tAuthID:    authID,\n\t\t\tAuthLabel: authLabel,\n\t\t\tAuthType:  authType,\n\t\t\tAuthValue: authValue,\n\t\t})\n\n\t\thttpResp, errDo := httpClient.Do(httpReq)\n\t\tif errDo != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\tif errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {\n\t\t\t\treturn cliproxyexecutor.Response{}, errDo\n\t\t\t}\n\t\t\tlastStatus = 0\n\t\t\tlastBody = nil\n\t\t\tlastErr = errDo\n\t\t\tif idx+1 < len(baseURLs) {\n\t\t\t\tlog.Debugf(\"antigravity executor: request error on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn cliproxyexecutor.Response{}, errDo\n\t\t}\n\n\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t}\n\t\tif errRead != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\treturn cliproxyexecutor.Response{}, errRead\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, bodyBytes)\n\n\t\tif httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {\n\t\t\tcount := gjson.GetBytes(bodyBytes, \"totalTokens\").Int()\n\t\t\ttranslated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, bodyBytes)\n\t\t\treturn cliproxyexecutor.Response{Payload: []byte(translated), Headers: httpResp.Header.Clone()}, nil\n\t\t}\n\n\t\tlastStatus = httpResp.StatusCode\n\t\tlastBody = append([]byte(nil), bodyBytes...)\n\t\tlastErr = nil\n\t\tif httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {\n\t\t\tlog.Debugf(\"antigravity executor: rate limited on base url %s, retrying with fallback base url: %s\", baseURL, baseURLs[idx+1])\n\t\t\tcontinue\n\t\t}\n\t\tsErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}\n\t\tif httpResp.StatusCode == http.StatusTooManyRequests {\n\t\t\tif retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {\n\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t}\n\t\t}\n\t\treturn cliproxyexecutor.Response{}, sErr\n\t}\n\n\tswitch {\n\tcase lastStatus != 0:\n\t\tsErr := statusErr{code: lastStatus, msg: string(lastBody)}\n\t\tif lastStatus == http.StatusTooManyRequests {\n\t\t\tif retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {\n\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t}\n\t\t}\n\t\treturn cliproxyexecutor.Response{}, sErr\n\tcase lastErr != nil:\n\t\treturn cliproxyexecutor.Response{}, lastErr\n\tdefault:\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: http.StatusServiceUnavailable, msg: \"antigravity executor: no base url available\"}\n\t}\n}\n\nfunc (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {\n\tif auth == nil {\n\t\treturn \"\", nil, statusErr{code: http.StatusUnauthorized, msg: \"missing auth\"}\n\t}\n\taccessToken := metaStringValue(auth.Metadata, \"access_token\")\n\texpiry := tokenExpiry(auth.Metadata)\n\tif accessToken != \"\" && expiry.After(time.Now().Add(refreshSkew)) {\n\t\treturn accessToken, nil, nil\n\t}\n\trefreshCtx := context.Background()\n\tif ctx != nil {\n\t\tif rt, ok := ctx.Value(\"cliproxy.roundtripper\").(http.RoundTripper); ok && rt != nil {\n\t\t\trefreshCtx = context.WithValue(refreshCtx, \"cliproxy.roundtripper\", rt)\n\t\t}\n\t}\n\tupdated, errRefresh := e.refreshToken(refreshCtx, auth.Clone())\n\tif errRefresh != nil {\n\t\treturn \"\", nil, errRefresh\n\t}\n\treturn metaStringValue(updated.Metadata, \"access_token\"), updated, nil\n}\n\nfunc (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tif auth == nil {\n\t\treturn nil, statusErr{code: http.StatusUnauthorized, msg: \"missing auth\"}\n\t}\n\trefreshToken := metaStringValue(auth.Metadata, \"refresh_token\")\n\tif refreshToken == \"\" {\n\t\treturn auth, statusErr{code: http.StatusUnauthorized, msg: \"missing refresh token\"}\n\t}\n\n\tform := url.Values{}\n\tform.Set(\"client_id\", antigravityClientID)\n\tform.Set(\"client_secret\", antigravityClientSecret)\n\tform.Set(\"grant_type\", \"refresh_token\")\n\tform.Set(\"refresh_token\", refreshToken)\n\n\thttpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, \"https://oauth2.googleapis.com/token\", strings.NewReader(form.Encode()))\n\tif errReq != nil {\n\t\treturn auth, errReq\n\t}\n\thttpReq.Header.Set(\"Host\", \"oauth2.googleapis.com\")\n\thttpReq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t// Real Antigravity uses Go's default User-Agent for OAuth token refresh\n\thttpReq.Header.Set(\"User-Agent\", \"Go-http-client/2.0\")\n\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\treturn auth, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"antigravity executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\n\tbodyBytes, errRead := io.ReadAll(httpResp.Body)\n\tif errRead != nil {\n\t\treturn auth, errRead\n\t}\n\n\tif httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {\n\t\tsErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}\n\t\tif httpResp.StatusCode == http.StatusTooManyRequests {\n\t\t\tif retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {\n\t\t\t\tsErr.retryAfter = retryAfter\n\t\t\t}\n\t\t}\n\t\treturn auth, sErr\n\t}\n\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn    int64  `json:\"expires_in\"`\n\t\tTokenType    string `json:\"token_type\"`\n\t}\n\tif errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {\n\t\treturn auth, errUnmarshal\n\t}\n\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"access_token\"] = tokenResp.AccessToken\n\tif tokenResp.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = tokenResp.RefreshToken\n\t}\n\tauth.Metadata[\"expires_in\"] = tokenResp.ExpiresIn\n\tnow := time.Now()\n\tauth.Metadata[\"timestamp\"] = now.UnixMilli()\n\tauth.Metadata[\"expired\"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)\n\tauth.Metadata[\"type\"] = antigravityAuthType\n\tif errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {\n\t\tlog.Warnf(\"antigravity executor: ensure project id failed: %v\", errProject)\n\t}\n\treturn auth, nil\n}\n\nfunc (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) error {\n\tif auth == nil {\n\t\treturn nil\n\t}\n\n\tif auth.Metadata[\"project_id\"] != nil {\n\t\treturn nil\n\t}\n\n\ttoken := strings.TrimSpace(accessToken)\n\tif token == \"\" {\n\t\ttoken = metaStringValue(auth.Metadata, \"access_token\")\n\t}\n\tif token == \"\" {\n\t\treturn nil\n\t}\n\n\thttpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)\n\tprojectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)\n\tif errFetch != nil {\n\t\treturn errFetch\n\t}\n\tif strings.TrimSpace(projectID) == \"\" {\n\t\treturn nil\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"project_id\"] = strings.TrimSpace(projectID)\n\n\treturn nil\n}\n\nfunc (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {\n\tif token == \"\" {\n\t\treturn nil, statusErr{code: http.StatusUnauthorized, msg: \"missing access token\"}\n\t}\n\n\tbase := strings.TrimSuffix(baseURL, \"/\")\n\tif base == \"\" {\n\t\tbase = buildBaseURL(auth)\n\t}\n\tpath := antigravityGeneratePath\n\tif stream {\n\t\tpath = antigravityStreamPath\n\t}\n\tvar requestURL strings.Builder\n\trequestURL.WriteString(base)\n\trequestURL.WriteString(path)\n\tif stream {\n\t\tif alt != \"\" {\n\t\t\trequestURL.WriteString(\"?$alt=\")\n\t\t\trequestURL.WriteString(url.QueryEscape(alt))\n\t\t} else {\n\t\t\trequestURL.WriteString(\"?alt=sse\")\n\t\t}\n\t} else if alt != \"\" {\n\t\trequestURL.WriteString(\"?$alt=\")\n\t\trequestURL.WriteString(url.QueryEscape(alt))\n\t}\n\n\t// Extract project_id from auth metadata if available\n\tprojectID := \"\"\n\tif auth != nil && auth.Metadata != nil {\n\t\tif pid, ok := auth.Metadata[\"project_id\"].(string); ok {\n\t\t\tprojectID = strings.TrimSpace(pid)\n\t\t}\n\t}\n\tpayload = geminiToAntigravity(modelName, payload, projectID)\n\tpayload, _ = sjson.SetBytes(payload, \"model\", modelName)\n\n\tuseAntigravitySchema := strings.Contains(modelName, \"claude\") || strings.Contains(modelName, \"gemini-3-pro\") || strings.Contains(modelName, \"gemini-3.1-pro\")\n\tpayloadStr := string(payload)\n\tpaths := make([]string, 0)\n\tutil.Walk(gjson.Parse(payloadStr), \"\", \"parametersJsonSchema\", &paths)\n\tfor _, p := range paths {\n\t\tpayloadStr, _ = util.RenameKey(payloadStr, p, p[:len(p)-len(\"parametersJsonSchema\")]+\"parameters\")\n\t}\n\n\tif useAntigravitySchema {\n\t\tpayloadStr = util.CleanJSONSchemaForAntigravity(payloadStr)\n\t} else {\n\t\tpayloadStr = util.CleanJSONSchemaForGemini(payloadStr)\n\t}\n\n\t// if useAntigravitySchema {\n\t// \tsystemInstructionPartsResult := gjson.Get(payloadStr, \"request.systemInstruction.parts\")\n\t// \tpayloadStr, _ = sjson.Set(payloadStr, \"request.systemInstruction.role\", \"user\")\n\t// \tpayloadStr, _ = sjson.Set(payloadStr, \"request.systemInstruction.parts.0.text\", systemInstruction)\n\t// \tpayloadStr, _ = sjson.Set(payloadStr, \"request.systemInstruction.parts.1.text\", fmt.Sprintf(\"Please ignore following [ignore]%s[/ignore]\", systemInstruction))\n\n\t// \tif systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {\n\t// \t\tfor _, partResult := range systemInstructionPartsResult.Array() {\n\t// \t\t\tpayloadStr, _ = sjson.SetRaw(payloadStr, \"request.systemInstruction.parts.-1\", partResult.Raw)\n\t// \t\t}\n\t// \t}\n\t// }\n\n\tif strings.Contains(modelName, \"claude\") {\n\t\tpayloadStr, _ = sjson.Set(payloadStr, \"request.toolConfig.functionCallingConfig.mode\", \"VALIDATED\")\n\t} else {\n\t\tpayloadStr, _ = sjson.Delete(payloadStr, \"request.generationConfig.maxOutputTokens\")\n\t}\n\n\thttpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), strings.NewReader(payloadStr))\n\tif errReq != nil {\n\t\treturn nil, errReq\n\t}\n\thttpReq.Close = true\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\thttpReq.Header.Set(\"User-Agent\", resolveUserAgent(auth))\n\tif host := resolveHost(base); host != \"\" {\n\t\thttpReq.Host = host\n\t}\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\tvar payloadLog []byte\n\tif e.cfg != nil && e.cfg.RequestLog {\n\t\tpayloadLog = []byte(payloadStr)\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       requestURL.String(),\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      payloadLog,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\treturn httpReq, nil\n}\n\nfunc tokenExpiry(metadata map[string]any) time.Time {\n\tif metadata == nil {\n\t\treturn time.Time{}\n\t}\n\tif expStr, ok := metadata[\"expired\"].(string); ok {\n\t\texpStr = strings.TrimSpace(expStr)\n\t\tif expStr != \"\" {\n\t\t\tif parsed, errParse := time.Parse(time.RFC3339, expStr); errParse == nil {\n\t\t\t\treturn parsed\n\t\t\t}\n\t\t}\n\t}\n\texpiresIn, hasExpires := int64Value(metadata[\"expires_in\"])\n\ttsMs, hasTimestamp := int64Value(metadata[\"timestamp\"])\n\tif hasExpires && hasTimestamp {\n\t\treturn time.Unix(0, tsMs*int64(time.Millisecond)).Add(time.Duration(expiresIn) * time.Second)\n\t}\n\treturn time.Time{}\n}\n\nfunc metaStringValue(metadata map[string]any, key string) string {\n\tif metadata == nil {\n\t\treturn \"\"\n\t}\n\tif v, ok := metadata[key]; ok {\n\t\tswitch typed := v.(type) {\n\t\tcase string:\n\t\t\treturn strings.TrimSpace(typed)\n\t\tcase []byte:\n\t\t\treturn strings.TrimSpace(string(typed))\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc int64Value(value any) (int64, bool) {\n\tswitch typed := value.(type) {\n\tcase int:\n\t\treturn int64(typed), true\n\tcase int64:\n\t\treturn typed, true\n\tcase float64:\n\t\treturn int64(typed), true\n\tcase json.Number:\n\t\tif i, errParse := typed.Int64(); errParse == nil {\n\t\t\treturn i, true\n\t\t}\n\tcase string:\n\t\tif strings.TrimSpace(typed) == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif i, errParse := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); errParse == nil {\n\t\t\treturn i, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc buildBaseURL(auth *cliproxyauth.Auth) string {\n\tif baseURLs := antigravityBaseURLFallbackOrder(auth); len(baseURLs) > 0 {\n\t\treturn baseURLs[0]\n\t}\n\treturn antigravityBaseURLDaily\n}\n\nfunc resolveHost(base string) string {\n\tparsed, errParse := url.Parse(base)\n\tif errParse != nil {\n\t\treturn \"\"\n\t}\n\tif parsed.Host != \"\" {\n\t\treturn parsed.Host\n\t}\n\treturn strings.TrimPrefix(strings.TrimPrefix(base, \"https://\"), \"http://\")\n}\n\nfunc resolveUserAgent(auth *cliproxyauth.Auth) string {\n\tif auth != nil {\n\t\tif auth.Attributes != nil {\n\t\t\tif ua := strings.TrimSpace(auth.Attributes[\"user_agent\"]); ua != \"\" {\n\t\t\t\treturn ua\n\t\t\t}\n\t\t}\n\t\tif auth.Metadata != nil {\n\t\t\tif ua, ok := auth.Metadata[\"user_agent\"].(string); ok && strings.TrimSpace(ua) != \"\" {\n\t\t\t\treturn strings.TrimSpace(ua)\n\t\t\t}\n\t\t}\n\t}\n\treturn defaultAntigravityAgent\n}\n\nfunc antigravityRetryAttempts(auth *cliproxyauth.Auth, cfg *config.Config) int {\n\tretry := 0\n\tif cfg != nil {\n\t\tretry = cfg.RequestRetry\n\t}\n\tif auth != nil {\n\t\tif override, ok := auth.RequestRetryOverride(); ok {\n\t\t\tretry = override\n\t\t}\n\t}\n\tif retry < 0 {\n\t\tretry = 0\n\t}\n\tattempts := retry + 1\n\tif attempts < 1 {\n\t\treturn 1\n\t}\n\treturn attempts\n}\n\nfunc antigravityShouldRetryNoCapacity(statusCode int, body []byte) bool {\n\tif statusCode != http.StatusServiceUnavailable {\n\t\treturn false\n\t}\n\tif len(body) == 0 {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(string(body))\n\treturn strings.Contains(msg, \"no capacity available\")\n}\n\nfunc antigravityNoCapacityRetryDelay(attempt int) time.Duration {\n\tif attempt < 0 {\n\t\tattempt = 0\n\t}\n\tdelay := time.Duration(attempt+1) * 250 * time.Millisecond\n\tif delay > 2*time.Second {\n\t\tdelay = 2 * time.Second\n\t}\n\treturn delay\n}\n\nfunc antigravityWait(ctx context.Context, wait time.Duration) error {\n\tif wait <= 0 {\n\t\treturn nil\n\t}\n\ttimer := time.NewTimer(wait)\n\tdefer timer.Stop()\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n\nfunc antigravityBaseURLFallbackOrder(auth *cliproxyauth.Auth) []string {\n\tif base := resolveCustomAntigravityBaseURL(auth); base != \"\" {\n\t\treturn []string{base}\n\t}\n\treturn []string{\n\t\tantigravityBaseURLDaily,\n\t\tantigravitySandboxBaseURLDaily,\n\t\t// antigravityBaseURLProd,\n\t}\n}\n\nfunc resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"base_url\"]); v != \"\" {\n\t\t\treturn strings.TrimSuffix(v, \"/\")\n\t\t}\n\t}\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"base_url\"].(string); ok {\n\t\t\tv = strings.TrimSpace(v)\n\t\t\tif v != \"\" {\n\t\t\t\treturn strings.TrimSuffix(v, \"/\")\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc geminiToAntigravity(modelName string, payload []byte, projectID string) []byte {\n\ttemplate, _ := sjson.Set(string(payload), \"model\", modelName)\n\ttemplate, _ = sjson.Set(template, \"userAgent\", \"antigravity\")\n\n\tisImageModel := strings.Contains(modelName, \"image\")\n\n\tvar reqType string\n\tif isImageModel {\n\t\treqType = \"image_gen\"\n\t} else {\n\t\treqType = \"agent\"\n\t}\n\ttemplate, _ = sjson.Set(template, \"requestType\", reqType)\n\n\t// Use real project ID from auth if available, otherwise generate random (legacy fallback)\n\tif projectID != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"project\", projectID)\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"project\", generateProjectID())\n\t}\n\n\tif isImageModel {\n\t\ttemplate, _ = sjson.Set(template, \"requestId\", generateImageGenRequestID())\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"requestId\", generateRequestID())\n\t\ttemplate, _ = sjson.Set(template, \"request.sessionId\", generateStableSessionID(payload))\n\t}\n\n\ttemplate, _ = sjson.Delete(template, \"request.safetySettings\")\n\tif toolConfig := gjson.Get(template, \"toolConfig\"); toolConfig.Exists() && !gjson.Get(template, \"request.toolConfig\").Exists() {\n\t\ttemplate, _ = sjson.SetRaw(template, \"request.toolConfig\", toolConfig.Raw)\n\t\ttemplate, _ = sjson.Delete(template, \"toolConfig\")\n\t}\n\treturn []byte(template)\n}\n\nfunc generateRequestID() string {\n\treturn \"agent-\" + uuid.NewString()\n}\n\nfunc generateImageGenRequestID() string {\n\treturn fmt.Sprintf(\"image_gen/%d/%s/12\", time.Now().UnixMilli(), uuid.NewString())\n}\n\nfunc generateSessionID() string {\n\trandSourceMutex.Lock()\n\tn := randSource.Int63n(9_000_000_000_000_000_000)\n\trandSourceMutex.Unlock()\n\treturn \"-\" + strconv.FormatInt(n, 10)\n}\n\nfunc generateStableSessionID(payload []byte) string {\n\tcontents := gjson.GetBytes(payload, \"request.contents\")\n\tif contents.IsArray() {\n\t\tfor _, content := range contents.Array() {\n\t\t\tif content.Get(\"role\").String() == \"user\" {\n\t\t\t\ttext := content.Get(\"parts.0.text\").String()\n\t\t\t\tif text != \"\" {\n\t\t\t\t\th := sha256.Sum256([]byte(text))\n\t\t\t\t\tn := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF\n\t\t\t\t\treturn \"-\" + strconv.FormatInt(n, 10)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn generateSessionID()\n}\n\nfunc generateProjectID() string {\n\tadjectives := []string{\"useful\", \"bright\", \"swift\", \"calm\", \"bold\"}\n\tnouns := []string{\"fuze\", \"wave\", \"spark\", \"flow\", \"core\"}\n\trandSourceMutex.Lock()\n\tadj := adjectives[randSource.Intn(len(adjectives))]\n\tnoun := nouns[randSource.Intn(len(nouns))]\n\trandSourceMutex.Unlock()\n\trandomPart := strings.ToLower(uuid.NewString())[:5]\n\treturn adj + \"-\" + noun + \"-\" + randomPart\n}\n"
  },
  {
    "path": "internal/runtime/executor/antigravity_executor_buildrequest_test.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"testing\"\n\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) {\n\tbody := buildRequestBodyFromPayload(t, \"gemini-2.5-pro\")\n\n\tdecl := extractFirstFunctionDeclaration(t, body)\n\tif _, ok := decl[\"parametersJsonSchema\"]; ok {\n\t\tt.Fatalf(\"parametersJsonSchema should be renamed to parameters\")\n\t}\n\n\tparams, ok := decl[\"parameters\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"parameters missing or invalid type\")\n\t}\n\tassertSchemaSanitizedAndPropertyPreserved(t, params)\n}\n\nfunc TestAntigravityBuildRequest_SanitizesAntigravityToolSchema(t *testing.T) {\n\tbody := buildRequestBodyFromPayload(t, \"claude-opus-4-6\")\n\n\tdecl := extractFirstFunctionDeclaration(t, body)\n\tparams, ok := decl[\"parameters\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"parameters missing or invalid type\")\n\t}\n\tassertSchemaSanitizedAndPropertyPreserved(t, params)\n}\n\nfunc buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any {\n\tt.Helper()\n\n\texecutor := &AntigravityExecutor{}\n\tauth := &cliproxyauth.Auth{}\n\tpayload := []byte(`{\n\t\t\"request\": {\n\t\t\t\"tools\": [\n\t\t\t\t{\n\t\t\t\t\t\"function_declarations\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"tool_1\",\n\t\t\t\t\t\t\t\"parametersJsonSchema\": {\n\t\t\t\t\t\t\t\t\"$schema\": \"http://json-schema.org/draft-07/schema#\",\n\t\t\t\t\t\t\t\t\"$id\": \"root-schema\",\n\t\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\t\"$id\": {\"type\": \"string\"},\n\t\t\t\t\t\t\t\t\t\"arg\": {\n\t\t\t\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\t\t\t\"prefill\": \"hello\",\n\t\t\t\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"deprecated\": true,\n\t\t\t\t\t\t\t\t\t\t\t\t\"enum\": [\"a\", \"b\"],\n\t\t\t\t\t\t\t\t\t\t\t\t\"enumTitles\": [\"A\", \"B\"]\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"patternProperties\": {\n\t\t\t\t\t\t\t\t\t\"^x-\": {\"type\": \"string\"}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`)\n\n\treq, err := executor.buildRequest(context.Background(), auth, \"token\", modelName, payload, false, \"\", \"https://example.com\")\n\tif err != nil {\n\t\tt.Fatalf(\"buildRequest error: %v\", err)\n\t}\n\n\traw, err := io.ReadAll(req.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read request body error: %v\", err)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(raw, &body); err != nil {\n\t\tt.Fatalf(\"unmarshal request body error: %v, body=%s\", err, string(raw))\n\t}\n\treturn body\n}\n\nfunc extractFirstFunctionDeclaration(t *testing.T, body map[string]any) map[string]any {\n\tt.Helper()\n\n\trequest, ok := body[\"request\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"request missing or invalid type\")\n\t}\n\ttools, ok := request[\"tools\"].([]any)\n\tif !ok || len(tools) == 0 {\n\t\tt.Fatalf(\"tools missing or empty\")\n\t}\n\ttool, ok := tools[0].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"first tool invalid type\")\n\t}\n\tdecls, ok := tool[\"function_declarations\"].([]any)\n\tif !ok || len(decls) == 0 {\n\t\tt.Fatalf(\"function_declarations missing or empty\")\n\t}\n\tdecl, ok := decls[0].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"first function declaration invalid type\")\n\t}\n\treturn decl\n}\n\nfunc assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]any) {\n\tt.Helper()\n\n\tif _, ok := params[\"$id\"]; ok {\n\t\tt.Fatalf(\"root $id should be removed from schema\")\n\t}\n\tif _, ok := params[\"patternProperties\"]; ok {\n\t\tt.Fatalf(\"patternProperties should be removed from schema\")\n\t}\n\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"properties missing or invalid type\")\n\t}\n\tif _, ok := props[\"$id\"]; !ok {\n\t\tt.Fatalf(\"property named $id should be preserved\")\n\t}\n\n\targ, ok := props[\"arg\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"arg property missing or invalid type\")\n\t}\n\tif _, ok := arg[\"prefill\"]; ok {\n\t\tt.Fatalf(\"prefill should be removed from nested schema\")\n\t}\n\n\targProps, ok := arg[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"arg.properties missing or invalid type\")\n\t}\n\tmode, ok := argProps[\"mode\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"mode property missing or invalid type\")\n\t}\n\tif _, ok := mode[\"enumTitles\"]; ok {\n\t\tt.Fatalf(\"enumTitles should be removed from nested schema\")\n\t}\n\tif _, ok := mode[\"deprecated\"]; ok {\n\t\tt.Fatalf(\"deprecated should be removed from nested schema\")\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/cache_helpers.go",
    "content": "package executor\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\ntype codexCache struct {\n\tID     string\n\tExpire time.Time\n}\n\n// codexCacheMap stores prompt cache IDs keyed by model+user_id.\n// Protected by codexCacheMu. Entries expire after 1 hour.\nvar (\n\tcodexCacheMap = make(map[string]codexCache)\n\tcodexCacheMu  sync.RWMutex\n)\n\n// codexCacheCleanupInterval controls how often expired entries are purged.\nconst codexCacheCleanupInterval = 15 * time.Minute\n\n// codexCacheCleanupOnce ensures the background cleanup goroutine starts only once.\nvar codexCacheCleanupOnce sync.Once\n\n// startCodexCacheCleanup launches a background goroutine that periodically\n// removes expired entries from codexCacheMap to prevent memory leaks.\nfunc startCodexCacheCleanup() {\n\tgo func() {\n\t\tticker := time.NewTicker(codexCacheCleanupInterval)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tpurgeExpiredCodexCache()\n\t\t}\n\t}()\n}\n\n// purgeExpiredCodexCache removes entries that have expired.\nfunc purgeExpiredCodexCache() {\n\tnow := time.Now()\n\tcodexCacheMu.Lock()\n\tdefer codexCacheMu.Unlock()\n\tfor key, cache := range codexCacheMap {\n\t\tif cache.Expire.Before(now) {\n\t\t\tdelete(codexCacheMap, key)\n\t\t}\n\t}\n}\n\n// getCodexCache retrieves a cached entry, returning ok=false if not found or expired.\nfunc getCodexCache(key string) (codexCache, bool) {\n\tcodexCacheCleanupOnce.Do(startCodexCacheCleanup)\n\tcodexCacheMu.RLock()\n\tcache, ok := codexCacheMap[key]\n\tcodexCacheMu.RUnlock()\n\tif !ok || cache.Expire.Before(time.Now()) {\n\t\treturn codexCache{}, false\n\t}\n\treturn cache, true\n}\n\n// setCodexCache stores a cache entry.\nfunc setCodexCache(key string, cache codexCache) {\n\tcodexCacheCleanupOnce.Do(startCodexCacheCleanup)\n\tcodexCacheMu.Lock()\n\tcodexCacheMap[key] = cache\n\tcodexCacheMu.Unlock()\n}\n"
  },
  {
    "path": "internal/runtime/executor/caching_verify_test.go",
    "content": "package executor\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestEnsureCacheControl(t *testing.T) {\n\t// Test case 1: System prompt as string\n\tt.Run(\"String System Prompt\", func(t *testing.T) {\n\t\tinput := []byte(`{\"model\": \"claude-3-5-sonnet\", \"system\": \"This is a long system prompt\", \"messages\": []}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\tres := gjson.GetBytes(output, \"system.0.cache_control.type\")\n\t\tif res.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found in system string. Output: %s\", string(output))\n\t\t}\n\t})\n\n\t// Test case 2: System prompt as array\n\tt.Run(\"Array System Prompt\", func(t *testing.T) {\n\t\tinput := []byte(`{\"model\": \"claude-3-5-sonnet\", \"system\": [{\"type\": \"text\", \"text\": \"Part 1\"}, {\"type\": \"text\", \"text\": \"Part 2\"}], \"messages\": []}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\t// cache_control should only be on the LAST element\n\t\tres0 := gjson.GetBytes(output, \"system.0.cache_control\")\n\t\tres1 := gjson.GetBytes(output, \"system.1.cache_control.type\")\n\n\t\tif res0.Exists() {\n\t\t\tt.Errorf(\"cache_control should NOT be on the first element\")\n\t\t}\n\t\tif res1.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found on last system element. Output: %s\", string(output))\n\t\t}\n\t})\n\n\t// Test case 3: Tools are cached\n\tt.Run(\"Tools Caching\", func(t *testing.T) {\n\t\tinput := []byte(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"tools\": [\n\t\t\t\t{\"name\": \"tool1\", \"description\": \"First tool\", \"input_schema\": {\"type\": \"object\"}},\n\t\t\t\t{\"name\": \"tool2\", \"description\": \"Second tool\", \"input_schema\": {\"type\": \"object\"}}\n\t\t\t],\n\t\t\t\"system\": \"System prompt\",\n\t\t\t\"messages\": []\n\t\t}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\t// cache_control should only be on the LAST tool\n\t\ttool0Cache := gjson.GetBytes(output, \"tools.0.cache_control\")\n\t\ttool1Cache := gjson.GetBytes(output, \"tools.1.cache_control.type\")\n\n\t\tif tool0Cache.Exists() {\n\t\t\tt.Errorf(\"cache_control should NOT be on the first tool\")\n\t\t}\n\t\tif tool1Cache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found on last tool. Output: %s\", string(output))\n\t\t}\n\n\t\t// System should also have cache_control\n\t\tsystemCache := gjson.GetBytes(output, \"system.0.cache_control.type\")\n\t\tif systemCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found in system. Output: %s\", string(output))\n\t\t}\n\t})\n\n\t// Test case 4: Tools and system are INDEPENDENT breakpoints\n\t// Per Anthropic docs: Up to 4 breakpoints allowed, tools and system are cached separately\n\tt.Run(\"Independent Cache Breakpoints\", func(t *testing.T) {\n\t\tinput := []byte(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"tools\": [\n\t\t\t\t{\"name\": \"tool1\", \"description\": \"First tool\", \"input_schema\": {\"type\": \"object\"}, \"cache_control\": {\"type\": \"ephemeral\"}}\n\t\t\t],\n\t\t\t\"system\": [{\"type\": \"text\", \"text\": \"System\"}],\n\t\t\t\"messages\": []\n\t\t}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\t// Tool already has cache_control - should not be changed\n\t\ttool0Cache := gjson.GetBytes(output, \"tools.0.cache_control.type\")\n\t\tif tool0Cache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"existing cache_control was incorrectly removed\")\n\t\t}\n\n\t\t// System SHOULD get cache_control because it is an INDEPENDENT breakpoint\n\t\t// Tools and system are separate cache levels in the hierarchy\n\t\tsystemCache := gjson.GetBytes(output, \"system.0.cache_control.type\")\n\t\tif systemCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"system should have its own cache_control breakpoint (independent of tools)\")\n\t\t}\n\t})\n\n\t// Test case 5: Only tools, no system\n\tt.Run(\"Only Tools No System\", func(t *testing.T) {\n\t\tinput := []byte(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"tools\": [\n\t\t\t\t{\"name\": \"tool1\", \"description\": \"Tool\", \"input_schema\": {\"type\": \"object\"}}\n\t\t\t],\n\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n\t\t}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\ttoolCache := gjson.GetBytes(output, \"tools.0.cache_control.type\")\n\t\tif toolCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found on tool. Output: %s\", string(output))\n\t\t}\n\t})\n\n\t// Test case 6: Many tools (Claude Code scenario)\n\tt.Run(\"Many Tools (Claude Code Scenario)\", func(t *testing.T) {\n\t\t// Simulate Claude Code with many tools\n\t\ttoolsJSON := `[`\n\t\tfor i := 0; i < 50; i++ {\n\t\t\tif i > 0 {\n\t\t\t\ttoolsJSON += \",\"\n\t\t\t}\n\t\t\ttoolsJSON += fmt.Sprintf(`{\"name\": \"tool%d\", \"description\": \"Tool %d\", \"input_schema\": {\"type\": \"object\"}}`, i, i)\n\t\t}\n\t\ttoolsJSON += `]`\n\n\t\tinput := []byte(fmt.Sprintf(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"tools\": %s,\n\t\t\t\"system\": [{\"type\": \"text\", \"text\": \"You are Claude Code\"}],\n\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n\t\t}`, toolsJSON))\n\n\t\toutput := ensureCacheControl(input)\n\n\t\t// Only the last tool (index 49) should have cache_control\n\t\tfor i := 0; i < 49; i++ {\n\t\t\tpath := fmt.Sprintf(\"tools.%d.cache_control\", i)\n\t\t\tif gjson.GetBytes(output, path).Exists() {\n\t\t\t\tt.Errorf(\"tool %d should NOT have cache_control\", i)\n\t\t\t}\n\t\t}\n\n\t\tlastToolCache := gjson.GetBytes(output, \"tools.49.cache_control.type\")\n\t\tif lastToolCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"last tool (49) should have cache_control\")\n\t\t}\n\n\t\t// System should also have cache_control\n\t\tsystemCache := gjson.GetBytes(output, \"system.0.cache_control.type\")\n\t\tif systemCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"system should have cache_control\")\n\t\t}\n\n\t\tt.Log(\"test passed: 50 tools - cache_control only on last tool\")\n\t})\n\n\t// Test case 7: Empty tools array\n\tt.Run(\"Empty Tools Array\", func(t *testing.T) {\n\t\tinput := []byte(`{\"model\": \"claude-3-5-sonnet\", \"tools\": [], \"system\": \"Test\", \"messages\": []}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\t// System should still get cache_control\n\t\tsystemCache := gjson.GetBytes(output, \"system.0.cache_control.type\")\n\t\tif systemCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"system should have cache_control even with empty tools array\")\n\t\t}\n\t})\n\n\t// Test case 8: Messages caching for multi-turn (second-to-last user)\n\tt.Run(\"Messages Caching Second-To-Last User\", func(t *testing.T) {\n\t\tinput := []byte(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"messages\": [\n\t\t\t\t{\"role\": \"user\", \"content\": \"First user\"},\n\t\t\t\t{\"role\": \"assistant\", \"content\": \"Assistant reply\"},\n\t\t\t\t{\"role\": \"user\", \"content\": \"Second user\"},\n\t\t\t\t{\"role\": \"assistant\", \"content\": \"Assistant reply 2\"},\n\t\t\t\t{\"role\": \"user\", \"content\": \"Third user\"}\n\t\t\t]\n\t\t}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\tcacheType := gjson.GetBytes(output, \"messages.2.content.0.cache_control.type\")\n\t\tif cacheType.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"cache_control not found on second-to-last user turn. Output: %s\", string(output))\n\t\t}\n\n\t\tlastUserCache := gjson.GetBytes(output, \"messages.4.content.0.cache_control\")\n\t\tif lastUserCache.Exists() {\n\t\t\tt.Errorf(\"last user turn should NOT have cache_control\")\n\t\t}\n\t})\n\n\t// Test case 9: Existing message cache_control should skip injection\n\tt.Run(\"Messages Skip When Cache Control Exists\", func(t *testing.T) {\n\t\tinput := []byte(`{\n\t\t\t\"model\": \"claude-3-5-sonnet\",\n\t\t\t\"messages\": [\n\t\t\t\t{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"First user\"}]},\n\t\t\t\t{\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": \"Assistant reply\", \"cache_control\": {\"type\": \"ephemeral\"}}]},\n\t\t\t\t{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Second user\"}]}\n\t\t\t]\n\t\t}`)\n\t\toutput := ensureCacheControl(input)\n\n\t\tuserCache := gjson.GetBytes(output, \"messages.0.content.0.cache_control\")\n\t\tif userCache.Exists() {\n\t\t\tt.Errorf(\"cache_control should NOT be injected when a message already has cache_control\")\n\t\t}\n\n\t\texistingCache := gjson.GetBytes(output, \"messages.1.content.0.cache_control.type\")\n\t\tif existingCache.String() != \"ephemeral\" {\n\t\t\tt.Errorf(\"existing cache_control should be preserved. Output: %s\", string(output))\n\t\t}\n\t})\n}\n\n// TestCacheControlOrder verifies the correct order: tools -> system -> messages\nfunc TestCacheControlOrder(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"claude-sonnet-4\",\n\t\t\"tools\": [\n\t\t\t{\"name\": \"Read\", \"description\": \"Read file\", \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}}},\n\t\t\t{\"name\": \"Write\", \"description\": \"Write file\", \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}}}\n\t\t],\n\t\t\"system\": [\n\t\t\t{\"type\": \"text\", \"text\": \"You are Claude Code, Anthropic's official CLI for Claude.\"},\n\t\t\t{\"type\": \"text\", \"text\": \"Additional instructions here...\"}\n\t\t],\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Hello\"}\n\t\t]\n\t}`)\n\n\toutput := ensureCacheControl(input)\n\n\t// 1. Last tool has cache_control\n\tif gjson.GetBytes(output, \"tools.1.cache_control.type\").String() != \"ephemeral\" {\n\t\tt.Error(\"last tool should have cache_control\")\n\t}\n\n\t// 2. First tool has NO cache_control\n\tif gjson.GetBytes(output, \"tools.0.cache_control\").Exists() {\n\t\tt.Error(\"first tool should NOT have cache_control\")\n\t}\n\n\t// 3. Last system element has cache_control\n\tif gjson.GetBytes(output, \"system.1.cache_control.type\").String() != \"ephemeral\" {\n\t\tt.Error(\"last system element should have cache_control\")\n\t}\n\n\t// 4. First system element has NO cache_control\n\tif gjson.GetBytes(output, \"system.0.cache_control\").Exists() {\n\t\tt.Error(\"first system element should NOT have cache_control\")\n\t}\n\n\tt.Log(\"cache order correct: tools -> system\")\n}\n"
  },
  {
    "path": "internal/runtime/executor/claude_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"compress/flate\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/textproto\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/andybalholm/brotli\"\n\t\"github.com/klauspost/compress/zstd\"\n\tclaudeauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API.\n// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.\ntype ClaudeExecutor struct {\n\tcfg *config.Config\n}\n\n// claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix).\n// Previously \"proxy_\" was used but this is a detectable fingerprint difference.\nconst claudeToolPrefix = \"\"\n\nfunc NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} }\n\nfunc (e *ClaudeExecutor) Identifier() string { return \"claude\" }\n\n// PrepareRequest injects Claude credentials into the outgoing HTTP request.\nfunc (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\tapiKey, _ := claudeCreds(auth)\n\tif strings.TrimSpace(apiKey) == \"\" {\n\t\treturn nil\n\t}\n\tuseAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes[\"api_key\"]) != \"\"\n\tisAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, \"https\") && strings.EqualFold(req.URL.Host, \"api.anthropic.com\")\n\tif isAnthropicBase && useAPIKey {\n\t\treq.Header.Del(\"Authorization\")\n\t\treq.Header.Set(\"x-api-key\", apiKey)\n\t} else {\n\t\treq.Header.Del(\"x-api-key\")\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(req, attrs)\n\treturn nil\n}\n\n// HttpRequest injects Claude credentials into the request and executes it.\nfunc (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"claude executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\nfunc (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := claudeCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.anthropic.com\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"claude\")\n\t// Use streaming translation to preserve function calling, except for claude.\n\tstream := from != to\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\t// Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation)\n\t// based on client type and configuration.\n\tbody = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey)\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\t// Disable thinking if tool_choice forces tool use (Anthropic API constraint)\n\tbody = disableThinkingIfToolChoiceForced(body)\n\n\t// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)\n\tif countCacheControls(body) == 0 {\n\t\tbody = ensureCacheControl(body)\n\t}\n\n\t// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).\n\t// Cloaking and ensureCacheControl may push the total over 4 when the client\n\t// (e.g. Amp CLI) already sends multiple cache_control blocks.\n\tbody = enforceCacheControlLimit(body, 4)\n\n\t// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.\n\t// A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages).\n\tbody = normalizeCacheControlTTL(body)\n\n\t// Extract betas from body and convert to header\n\tvar extraBetas []string\n\textraBetas, body = extractAndRemoveBetas(body)\n\tbodyForTranslation := body\n\tbodyForUpstream := body\n\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\tbodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)\n\t}\n\n\turl := fmt.Sprintf(\"%s/v1/messages?beta=true\", baseURL)\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      bodyForUpstream,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\t// Decompress error responses — pass the Content-Encoding value (may be empty)\n\t\t// and let decodeResponseBody handle both header-declared and magic-byte-detected\n\t\t// compression.  This keeps error-path behaviour consistent with the success path.\n\t\terrBody, decErr := decodeResponseBody(httpResp.Body, httpResp.Header.Get(\"Content-Encoding\"))\n\t\tif decErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, decErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to decode error response body: %v\", decErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\treturn resp, statusErr{code: httpResp.StatusCode, msg: msg}\n\t\t}\n\t\tb, readErr := io.ReadAll(errBody)\n\t\tif readErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, readErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to read error response body: %v\", readErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\tb = []byte(msg)\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\tif errClose := errBody.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\treturn resp, err\n\t}\n\tdecodedBody, err := decodeResponseBody(httpResp.Body, httpResp.Header.Get(\"Content-Encoding\"))\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := decodedBody.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\tdata, err := io.ReadAll(decodedBody)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\tif stream {\n\t\tlines := bytes.Split(data, []byte(\"\\n\"))\n\t\tfor _, line := range lines {\n\t\t\tif detail, ok := parseClaudeStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t}\n\t} else {\n\t\treporter.publish(ctx, parseClaudeUsage(data))\n\t}\n\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\tdata = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix)\n\t}\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(\n\t\tctx,\n\t\tto,\n\t\tfrom,\n\t\treq.Model,\n\t\topts.OriginalRequest,\n\t\tbodyForTranslation,\n\t\tdata,\n\t\t&param,\n\t)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\nfunc (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := claudeCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.anthropic.com\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"claude\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation)\n\t// based on client type and configuration.\n\tbody = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey)\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\t// Disable thinking if tool_choice forces tool use (Anthropic API constraint)\n\tbody = disableThinkingIfToolChoiceForced(body)\n\n\t// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)\n\tif countCacheControls(body) == 0 {\n\t\tbody = ensureCacheControl(body)\n\t}\n\n\t// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).\n\tbody = enforceCacheControlLimit(body, 4)\n\n\t// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.\n\tbody = normalizeCacheControlTTL(body)\n\n\t// Extract betas from body and convert to header\n\tvar extraBetas []string\n\textraBetas, body = extractAndRemoveBetas(body)\n\tbodyForTranslation := body\n\tbodyForUpstream := body\n\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\tbodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)\n\t}\n\n\turl := fmt.Sprintf(\"%s/v1/messages?beta=true\", baseURL)\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapplyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      bodyForUpstream,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\t// Decompress error responses — pass the Content-Encoding value (may be empty)\n\t\t// and let decodeResponseBody handle both header-declared and magic-byte-detected\n\t\t// compression.  This keeps error-path behaviour consistent with the success path.\n\t\terrBody, decErr := decodeResponseBody(httpResp.Body, httpResp.Header.Get(\"Content-Encoding\"))\n\t\tif decErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, decErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to decode error response body: %v\", decErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\treturn nil, statusErr{code: httpResp.StatusCode, msg: msg}\n\t\t}\n\t\tb, readErr := io.ReadAll(errBody)\n\t\tif readErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, readErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to read error response body: %v\", readErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\tb = []byte(msg)\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := errBody.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn nil, err\n\t}\n\tdecodedBody, err := decodeResponseBody(httpResp.Body, httpResp.Header.Get(\"Content-Encoding\"))\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := decodedBody.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\n\t\t// If from == to (Claude → Claude), directly forward the SSE stream without translation\n\t\tif from == to {\n\t\t\tscanner := bufio.NewScanner(decodedBody)\n\t\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\t\tfor scanner.Scan() {\n\t\t\t\tline := scanner.Bytes()\n\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\t\tif detail, ok := parseClaudeStreamUsage(line); ok {\n\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t}\n\t\t\t\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\t\t\t\tline = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)\n\t\t\t\t}\n\t\t\t\t// Forward the line as-is to preserve SSE format\n\t\t\t\tcloned := make([]byte, len(line)+1)\n\t\t\t\tcopy(cloned, line)\n\t\t\t\tcloned[len(line)] = '\\n'\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: cloned}\n\t\t\t}\n\t\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// For other formats, use translation\n\t\tscanner := bufio.NewScanner(decodedBody)\n\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseClaudeStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\t\t\tline = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)\n\t\t\t}\n\t\t\tchunks := sdktranslator.TranslateStream(\n\t\t\t\tctx,\n\t\t\t\tto,\n\t\t\t\tfrom,\n\t\t\t\treq.Model,\n\t\t\t\topts.OriginalRequest,\n\t\t\t\tbodyForTranslation,\n\t\t\t\tbytes.Clone(line),\n\t\t\t\t&param,\n\t\t\t)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\nfunc (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := claudeCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.anthropic.com\"\n\t}\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"claude\")\n\t// Use streaming translation to preserve function calling, except for claude.\n\tstream := from != to\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tif !strings.HasPrefix(baseModel, \"claude-3-5-haiku\") {\n\t\tbody = checkSystemInstructions(body)\n\t}\n\n\t// Keep count_tokens requests compatible with Anthropic cache-control constraints too.\n\tbody = enforceCacheControlLimit(body, 4)\n\tbody = normalizeCacheControlTTL(body)\n\n\t// Extract betas from body and convert to header (for count_tokens too)\n\tvar extraBetas []string\n\textraBetas, body = extractAndRemoveBetas(body)\n\tif isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {\n\t\tbody = applyClaudeToolPrefix(body, claudeToolPrefix)\n\t}\n\n\turl := fmt.Sprintf(\"%s/v1/messages/count_tokens?beta=true\", baseURL)\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\tapplyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\tresp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t// Decompress error responses — pass the Content-Encoding value (may be empty)\n\t\t// and let decodeResponseBody handle both header-declared and magic-byte-detected\n\t\t// compression.  This keeps error-path behaviour consistent with the success path.\n\t\terrBody, decErr := decodeResponseBody(resp.Body, resp.Header.Get(\"Content-Encoding\"))\n\t\tif decErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, decErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to decode error response body: %v\", decErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\treturn cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: msg}\n\t\t}\n\t\tb, readErr := io.ReadAll(errBody)\n\t\tif readErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, readErr)\n\t\t\tmsg := fmt.Sprintf(\"failed to read error response body: %v\", readErr)\n\t\t\tlogWithRequestID(ctx).Warn(msg)\n\t\t\tb = []byte(msg)\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tif errClose := errBody.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}\n\t}\n\tdecodedBody, err := decodeResponseBody(resp.Body, resp.Header.Get(\"Content-Encoding\"))\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\tdefer func() {\n\t\tif errClose := decodedBody.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"response body close error: %v\", errClose)\n\t\t}\n\t}()\n\tdata, err := io.ReadAll(decodedBody)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\tcount := gjson.GetBytes(data, \"input_tokens\").Int()\n\tout := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)\n\treturn cliproxyexecutor.Response{Payload: []byte(out), Headers: resp.Header.Clone()}, nil\n}\n\nfunc (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"claude executor: refresh called\")\n\tif auth == nil {\n\t\treturn nil, fmt.Errorf(\"claude executor: auth is nil\")\n\t}\n\tvar refreshToken string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"refresh_token\"].(string); ok && v != \"\" {\n\t\t\trefreshToken = v\n\t\t}\n\t}\n\tif refreshToken == \"\" {\n\t\treturn auth, nil\n\t}\n\tsvc := claudeauth.NewClaudeAuth(e.cfg)\n\ttd, err := svc.RefreshTokens(ctx, refreshToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"access_token\"] = td.AccessToken\n\tif td.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = td.RefreshToken\n\t}\n\tauth.Metadata[\"email\"] = td.Email\n\tauth.Metadata[\"expired\"] = td.Expire\n\tauth.Metadata[\"type\"] = \"claude\"\n\tnow := time.Now().Format(time.RFC3339)\n\tauth.Metadata[\"last_refresh\"] = now\n\treturn auth, nil\n}\n\n// extractAndRemoveBetas extracts the \"betas\" array from the body and removes it.\n// Returns the extracted betas as a string slice and the modified body.\nfunc extractAndRemoveBetas(body []byte) ([]string, []byte) {\n\tbetasResult := gjson.GetBytes(body, \"betas\")\n\tif !betasResult.Exists() {\n\t\treturn nil, body\n\t}\n\tvar betas []string\n\tif betasResult.IsArray() {\n\t\tfor _, item := range betasResult.Array() {\n\t\t\tif s := strings.TrimSpace(item.String()); s != \"\" {\n\t\t\t\tbetas = append(betas, s)\n\t\t\t}\n\t\t}\n\t} else if s := strings.TrimSpace(betasResult.String()); s != \"\" {\n\t\tbetas = append(betas, s)\n\t}\n\tbody, _ = sjson.DeleteBytes(body, \"betas\")\n\treturn betas, body\n}\n\n// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.\n// Anthropic API does not allow thinking when tool_choice is set to \"any\" or a specific tool.\n// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations\nfunc disableThinkingIfToolChoiceForced(body []byte) []byte {\n\ttoolChoiceType := gjson.GetBytes(body, \"tool_choice.type\").String()\n\t// \"auto\" is allowed with thinking, but \"any\" or \"tool\" (specific tool) are not\n\tif toolChoiceType == \"any\" || toolChoiceType == \"tool\" {\n\t\t// Remove thinking configuration entirely to avoid API error\n\t\tbody, _ = sjson.DeleteBytes(body, \"thinking\")\n\t\t// Adaptive thinking may also set output_config.effort; remove it to avoid\n\t\t// leaking thinking controls when tool_choice forces tool use.\n\t\tbody, _ = sjson.DeleteBytes(body, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(body, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tbody, _ = sjson.DeleteBytes(body, \"output_config\")\n\t\t}\n\t}\n\treturn body\n}\n\ntype compositeReadCloser struct {\n\tio.Reader\n\tclosers []func() error\n}\n\nfunc (c *compositeReadCloser) Close() error {\n\tvar firstErr error\n\tfor i := range c.closers {\n\t\tif c.closers[i] == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := c.closers[i](); err != nil && firstErr == nil {\n\t\t\tfirstErr = err\n\t\t}\n\t}\n\treturn firstErr\n}\n\n// peekableBody wraps a bufio.Reader around the original ReadCloser so that\n// magic bytes can be inspected without consuming them from the stream.\ntype peekableBody struct {\n\t*bufio.Reader\n\tcloser io.Closer\n}\n\nfunc (p *peekableBody) Close() error {\n\treturn p.closer.Close()\n}\n\nfunc decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {\n\tif body == nil {\n\t\treturn nil, fmt.Errorf(\"response body is nil\")\n\t}\n\tif contentEncoding == \"\" {\n\t\t// No Content-Encoding header.  Attempt best-effort magic-byte detection to\n\t\t// handle misbehaving upstreams that compress without setting the header.\n\t\t// Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences;\n\t\t// br and deflate have none and are left as-is.\n\t\t// The bufio wrapper preserves unread bytes so callers always see the full\n\t\t// stream regardless of whether decompression was applied.\n\t\tpb := &peekableBody{Reader: bufio.NewReader(body), closer: body}\n\t\tmagic, peekErr := pb.Peek(4)\n\t\tif peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) {\n\t\t\tswitch {\n\t\t\tcase len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b:\n\t\t\t\tgzipReader, gzErr := gzip.NewReader(pb)\n\t\t\t\tif gzErr != nil {\n\t\t\t\t\t_ = pb.Close()\n\t\t\t\t\treturn nil, fmt.Errorf(\"magic-byte gzip: failed to create reader: %w\", gzErr)\n\t\t\t\t}\n\t\t\t\treturn &compositeReadCloser{\n\t\t\t\t\tReader: gzipReader,\n\t\t\t\t\tclosers: []func() error{\n\t\t\t\t\t\tgzipReader.Close,\n\t\t\t\t\t\tpb.Close,\n\t\t\t\t\t},\n\t\t\t\t}, nil\n\t\t\tcase len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd:\n\t\t\t\tdecoder, zdErr := zstd.NewReader(pb)\n\t\t\t\tif zdErr != nil {\n\t\t\t\t\t_ = pb.Close()\n\t\t\t\t\treturn nil, fmt.Errorf(\"magic-byte zstd: failed to create reader: %w\", zdErr)\n\t\t\t\t}\n\t\t\t\treturn &compositeReadCloser{\n\t\t\t\t\tReader: decoder,\n\t\t\t\t\tclosers: []func() error{\n\t\t\t\t\t\tfunc() error { decoder.Close(); return nil },\n\t\t\t\t\t\tpb.Close,\n\t\t\t\t\t},\n\t\t\t\t}, nil\n\t\t\t}\n\t\t}\n\t\treturn pb, nil\n\t}\n\tencodings := strings.Split(contentEncoding, \",\")\n\tfor _, raw := range encodings {\n\t\tencoding := strings.TrimSpace(strings.ToLower(raw))\n\t\tswitch encoding {\n\t\tcase \"\", \"identity\":\n\t\t\tcontinue\n\t\tcase \"gzip\":\n\t\t\tgzipReader, err := gzip.NewReader(body)\n\t\t\tif err != nil {\n\t\t\t\t_ = body.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"failed to create gzip reader: %w\", err)\n\t\t\t}\n\t\t\treturn &compositeReadCloser{\n\t\t\t\tReader: gzipReader,\n\t\t\t\tclosers: []func() error{\n\t\t\t\t\tgzipReader.Close,\n\t\t\t\t\tfunc() error { return body.Close() },\n\t\t\t\t},\n\t\t\t}, nil\n\t\tcase \"deflate\":\n\t\t\tdeflateReader := flate.NewReader(body)\n\t\t\treturn &compositeReadCloser{\n\t\t\t\tReader: deflateReader,\n\t\t\t\tclosers: []func() error{\n\t\t\t\t\tdeflateReader.Close,\n\t\t\t\t\tfunc() error { return body.Close() },\n\t\t\t\t},\n\t\t\t}, nil\n\t\tcase \"br\":\n\t\t\treturn &compositeReadCloser{\n\t\t\t\tReader: brotli.NewReader(body),\n\t\t\t\tclosers: []func() error{\n\t\t\t\t\tfunc() error { return body.Close() },\n\t\t\t\t},\n\t\t\t}, nil\n\t\tcase \"zstd\":\n\t\t\tdecoder, err := zstd.NewReader(body)\n\t\t\tif err != nil {\n\t\t\t\t_ = body.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"failed to create zstd reader: %w\", err)\n\t\t\t}\n\t\t\treturn &compositeReadCloser{\n\t\t\t\tReader: decoder,\n\t\t\t\tclosers: []func() error{\n\t\t\t\t\tfunc() error { decoder.Close(); return nil },\n\t\t\t\t\tfunc() error { return body.Close() },\n\t\t\t\t},\n\t\t\t}, nil\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\t}\n\treturn body, nil\n}\n\n// mapStainlessOS maps runtime.GOOS to Stainless SDK OS names.\nfunc mapStainlessOS() string {\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\treturn \"MacOS\"\n\tcase \"windows\":\n\t\treturn \"Windows\"\n\tcase \"linux\":\n\t\treturn \"Linux\"\n\tcase \"freebsd\":\n\t\treturn \"FreeBSD\"\n\tdefault:\n\t\treturn \"Other::\" + runtime.GOOS\n\t}\n}\n\n// mapStainlessArch maps runtime.GOARCH to Stainless SDK architecture names.\nfunc mapStainlessArch() string {\n\tswitch runtime.GOARCH {\n\tcase \"amd64\":\n\t\treturn \"x64\"\n\tcase \"arm64\":\n\t\treturn \"arm64\"\n\tcase \"386\":\n\t\treturn \"x86\"\n\tdefault:\n\t\treturn \"other::\" + runtime.GOARCH\n\t}\n}\n\nfunc applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config) {\n\thdrDefault := func(cfgVal, fallback string) string {\n\t\tif cfgVal != \"\" {\n\t\t\treturn cfgVal\n\t\t}\n\t\treturn fallback\n\t}\n\n\tvar hd config.ClaudeHeaderDefaults\n\tif cfg != nil {\n\t\thd = cfg.ClaudeHeaderDefaults\n\t}\n\n\tuseAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes[\"api_key\"]) != \"\"\n\tisAnthropicBase := r.URL != nil && strings.EqualFold(r.URL.Scheme, \"https\") && strings.EqualFold(r.URL.Host, \"api.anthropic.com\")\n\tif isAnthropicBase && useAPIKey {\n\t\tr.Header.Del(\"Authorization\")\n\t\tr.Header.Set(\"x-api-key\", apiKey)\n\t} else {\n\t\tr.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\tvar ginHeaders http.Header\n\tif ginCtx, ok := r.Context().Value(\"gin\").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {\n\t\tginHeaders = ginCtx.Request.Header\n\t}\n\n\tbaseBetas := \"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05\"\n\tif val := strings.TrimSpace(ginHeaders.Get(\"Anthropic-Beta\")); val != \"\" {\n\t\tbaseBetas = val\n\t\tif !strings.Contains(val, \"oauth\") {\n\t\t\tbaseBetas += \",oauth-2025-04-20\"\n\t\t}\n\t}\n\n\thasClaude1MHeader := false\n\tif ginHeaders != nil {\n\t\tif _, ok := ginHeaders[textproto.CanonicalMIMEHeaderKey(\"X-CPA-CLAUDE-1M\")]; ok {\n\t\t\thasClaude1MHeader = true\n\t\t}\n\t}\n\n\t// Merge extra betas from request body and request flags.\n\tif len(extraBetas) > 0 || hasClaude1MHeader {\n\t\texistingSet := make(map[string]bool)\n\t\tfor _, b := range strings.Split(baseBetas, \",\") {\n\t\t\tbetaName := strings.TrimSpace(b)\n\t\t\tif betaName != \"\" {\n\t\t\t\texistingSet[betaName] = true\n\t\t\t}\n\t\t}\n\t\tfor _, beta := range extraBetas {\n\t\t\tbeta = strings.TrimSpace(beta)\n\t\t\tif beta != \"\" && !existingSet[beta] {\n\t\t\t\tbaseBetas += \",\" + beta\n\t\t\t\texistingSet[beta] = true\n\t\t\t}\n\t\t}\n\t\tif hasClaude1MHeader && !existingSet[\"context-1m-2025-08-07\"] {\n\t\t\tbaseBetas += \",context-1m-2025-08-07\"\n\t\t}\n\t}\n\tr.Header.Set(\"Anthropic-Beta\", baseBetas)\n\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"Anthropic-Version\", \"2023-06-01\")\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"Anthropic-Dangerous-Direct-Browser-Access\", \"true\")\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-App\", \"cli\")\n\t// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Retry-Count\", \"0\")\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Runtime-Version\", hdrDefault(hd.RuntimeVersion, \"v24.3.0\"))\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Package-Version\", hdrDefault(hd.PackageVersion, \"0.74.0\"))\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Runtime\", \"node\")\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Lang\", \"js\")\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Arch\", mapStainlessArch())\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Os\", mapStainlessOS())\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"X-Stainless-Timeout\", hdrDefault(hd.Timeout, \"600\"))\n\t// For User-Agent, only forward the client's header if it's already a Claude Code client.\n\t// Non-Claude-Code clients (e.g. curl, OpenAI SDKs) get the default Claude Code User-Agent\n\t// to avoid leaking the real client identity during cloaking.\n\tclientUA := \"\"\n\tif ginHeaders != nil {\n\t\tclientUA = ginHeaders.Get(\"User-Agent\")\n\t}\n\tif isClaudeCodeClient(clientUA) {\n\t\tr.Header.Set(\"User-Agent\", clientUA)\n\t} else {\n\t\tr.Header.Set(\"User-Agent\", hdrDefault(hd.UserAgent, \"claude-cli/2.1.63 (external, cli)\"))\n\t}\n\tr.Header.Set(\"Connection\", \"keep-alive\")\n\tif stream {\n\t\tr.Header.Set(\"Accept\", \"text/event-stream\")\n\t\t// SSE streams must not be compressed: the downstream scanner reads\n\t\t// line-delimited text and cannot parse compressed bytes.  Using\n\t\t// \"identity\" tells the upstream to send an uncompressed stream.\n\t\tr.Header.Set(\"Accept-Encoding\", \"identity\")\n\t} else {\n\t\tr.Header.Set(\"Accept\", \"application/json\")\n\t\tr.Header.Set(\"Accept-Encoding\", \"gzip, deflate, br, zstd\")\n\t}\n\t// Keep OS/Arch mapping dynamic (not configurable).\n\t// They intentionally continue to derive from runtime.GOOS/runtime.GOARCH.\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(r, attrs)\n\t// Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which\n\t// may override it with a user-configured value.  Compressed SSE breaks the line\n\t// scanner regardless of user preference, so this is non-negotiable for streams.\n\tif stream {\n\t\tr.Header.Set(\"Accept-Encoding\", \"identity\")\n\t}\n}\n\nfunc claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tapiKey = a.Attributes[\"api_key\"]\n\t\tbaseURL = a.Attributes[\"base_url\"]\n\t}\n\tif apiKey == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok {\n\t\t\tapiKey = v\n\t\t}\n\t}\n\treturn\n}\n\nfunc checkSystemInstructions(payload []byte) []byte {\n\treturn checkSystemInstructionsWithMode(payload, false)\n}\n\nfunc isClaudeOAuthToken(apiKey string) bool {\n\treturn strings.Contains(apiKey, \"sk-ant-oat\")\n}\n\nfunc applyClaudeToolPrefix(body []byte, prefix string) []byte {\n\tif prefix == \"\" {\n\t\treturn body\n\t}\n\n\t// Collect built-in tool names (those with a non-empty \"type\" field) so we can\n\t// skip them consistently in both tools and message history.\n\tbuiltinTools := map[string]bool{}\n\tfor _, name := range []string{\"web_search\", \"code_execution\", \"text_editor\", \"computer\"} {\n\t\tbuiltinTools[name] = true\n\t}\n\n\tif tools := gjson.GetBytes(body, \"tools\"); tools.Exists() && tools.IsArray() {\n\t\ttools.ForEach(func(index, tool gjson.Result) bool {\n\t\t\t// Skip built-in tools (web_search, code_execution, etc.) which have\n\t\t\t// a \"type\" field and require their name to remain unchanged.\n\t\t\tif tool.Get(\"type\").Exists() && tool.Get(\"type\").String() != \"\" {\n\t\t\t\tif n := tool.Get(\"name\").String(); n != \"\" {\n\t\t\t\t\tbuiltinTools[n] = true\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tname := tool.Get(\"name\").String()\n\t\t\tif name == \"\" || strings.HasPrefix(name, prefix) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tpath := fmt.Sprintf(\"tools.%d.name\", index.Int())\n\t\t\tbody, _ = sjson.SetBytes(body, path, prefix+name)\n\t\t\treturn true\n\t\t})\n\t}\n\n\tif gjson.GetBytes(body, \"tool_choice.type\").String() == \"tool\" {\n\t\tname := gjson.GetBytes(body, \"tool_choice.name\").String()\n\t\tif name != \"\" && !strings.HasPrefix(name, prefix) && !builtinTools[name] {\n\t\t\tbody, _ = sjson.SetBytes(body, \"tool_choice.name\", prefix+name)\n\t\t}\n\t}\n\n\tif messages := gjson.GetBytes(body, \"messages\"); messages.Exists() && messages.IsArray() {\n\t\tmessages.ForEach(func(msgIndex, msg gjson.Result) bool {\n\t\t\tcontent := msg.Get(\"content\")\n\t\t\tif !content.Exists() || !content.IsArray() {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcontent.ForEach(func(contentIndex, part gjson.Result) bool {\n\t\t\t\tpartType := part.Get(\"type\").String()\n\t\t\t\tswitch partType {\n\t\t\t\tcase \"tool_use\":\n\t\t\t\t\tname := part.Get(\"name\").String()\n\t\t\t\t\tif name == \"\" || strings.HasPrefix(name, prefix) || builtinTools[name] {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tpath := fmt.Sprintf(\"messages.%d.content.%d.name\", msgIndex.Int(), contentIndex.Int())\n\t\t\t\t\tbody, _ = sjson.SetBytes(body, path, prefix+name)\n\t\t\t\tcase \"tool_reference\":\n\t\t\t\t\ttoolName := part.Get(\"tool_name\").String()\n\t\t\t\t\tif toolName == \"\" || strings.HasPrefix(toolName, prefix) || builtinTools[toolName] {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tpath := fmt.Sprintf(\"messages.%d.content.%d.tool_name\", msgIndex.Int(), contentIndex.Int())\n\t\t\t\t\tbody, _ = sjson.SetBytes(body, path, prefix+toolName)\n\t\t\t\tcase \"tool_result\":\n\t\t\t\t\t// Handle nested tool_reference blocks inside tool_result.content[]\n\t\t\t\t\tnestedContent := part.Get(\"content\")\n\t\t\t\t\tif nestedContent.Exists() && nestedContent.IsArray() {\n\t\t\t\t\t\tnestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {\n\t\t\t\t\t\t\tif nestedPart.Get(\"type\").String() == \"tool_reference\" {\n\t\t\t\t\t\t\t\tnestedToolName := nestedPart.Get(\"tool_name\").String()\n\t\t\t\t\t\t\t\tif nestedToolName != \"\" && !strings.HasPrefix(nestedToolName, prefix) && !builtinTools[nestedToolName] {\n\t\t\t\t\t\t\t\t\tnestedPath := fmt.Sprintf(\"messages.%d.content.%d.content.%d.tool_name\", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())\n\t\t\t\t\t\t\t\t\tbody, _ = sjson.SetBytes(body, nestedPath, prefix+nestedToolName)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\treturn true\n\t\t})\n\t}\n\n\treturn body\n}\n\nfunc stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {\n\tif prefix == \"\" {\n\t\treturn body\n\t}\n\tcontent := gjson.GetBytes(body, \"content\")\n\tif !content.Exists() || !content.IsArray() {\n\t\treturn body\n\t}\n\tcontent.ForEach(func(index, part gjson.Result) bool {\n\t\tpartType := part.Get(\"type\").String()\n\t\tswitch partType {\n\t\tcase \"tool_use\":\n\t\t\tname := part.Get(\"name\").String()\n\t\t\tif !strings.HasPrefix(name, prefix) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tpath := fmt.Sprintf(\"content.%d.name\", index.Int())\n\t\t\tbody, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix))\n\t\tcase \"tool_reference\":\n\t\t\ttoolName := part.Get(\"tool_name\").String()\n\t\t\tif !strings.HasPrefix(toolName, prefix) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tpath := fmt.Sprintf(\"content.%d.tool_name\", index.Int())\n\t\t\tbody, _ = sjson.SetBytes(body, path, strings.TrimPrefix(toolName, prefix))\n\t\tcase \"tool_result\":\n\t\t\t// Handle nested tool_reference blocks inside tool_result.content[]\n\t\t\tnestedContent := part.Get(\"content\")\n\t\t\tif nestedContent.Exists() && nestedContent.IsArray() {\n\t\t\t\tnestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {\n\t\t\t\t\tif nestedPart.Get(\"type\").String() == \"tool_reference\" {\n\t\t\t\t\t\tnestedToolName := nestedPart.Get(\"tool_name\").String()\n\t\t\t\t\t\tif strings.HasPrefix(nestedToolName, prefix) {\n\t\t\t\t\t\t\tnestedPath := fmt.Sprintf(\"content.%d.content.%d.tool_name\", index.Int(), nestedIndex.Int())\n\t\t\t\t\t\t\tbody, _ = sjson.SetBytes(body, nestedPath, strings.TrimPrefix(nestedToolName, prefix))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\treturn body\n}\n\nfunc stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {\n\tif prefix == \"\" {\n\t\treturn line\n\t}\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn line\n\t}\n\tcontentBlock := gjson.GetBytes(payload, \"content_block\")\n\tif !contentBlock.Exists() {\n\t\treturn line\n\t}\n\n\tblockType := contentBlock.Get(\"type\").String()\n\tvar updated []byte\n\tvar err error\n\n\tswitch blockType {\n\tcase \"tool_use\":\n\t\tname := contentBlock.Get(\"name\").String()\n\t\tif !strings.HasPrefix(name, prefix) {\n\t\t\treturn line\n\t\t}\n\t\tupdated, err = sjson.SetBytes(payload, \"content_block.name\", strings.TrimPrefix(name, prefix))\n\t\tif err != nil {\n\t\t\treturn line\n\t\t}\n\tcase \"tool_reference\":\n\t\ttoolName := contentBlock.Get(\"tool_name\").String()\n\t\tif !strings.HasPrefix(toolName, prefix) {\n\t\t\treturn line\n\t\t}\n\t\tupdated, err = sjson.SetBytes(payload, \"content_block.tool_name\", strings.TrimPrefix(toolName, prefix))\n\t\tif err != nil {\n\t\t\treturn line\n\t\t}\n\tdefault:\n\t\treturn line\n\t}\n\n\ttrimmed := bytes.TrimSpace(line)\n\tif bytes.HasPrefix(trimmed, []byte(\"data:\")) {\n\t\treturn append([]byte(\"data: \"), updated...)\n\t}\n\treturn updated\n}\n\n// getClientUserAgent extracts the client User-Agent from the gin context.\nfunc getClientUserAgent(ctx context.Context) string {\n\tif ginCtx, ok := ctx.Value(\"gin\").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {\n\t\treturn ginCtx.GetHeader(\"User-Agent\")\n\t}\n\treturn \"\"\n}\n\n// getCloakConfigFromAuth extracts cloak configuration from auth attributes.\n// Returns (cloakMode, strictMode, sensitiveWords, cacheUserID).\nfunc getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bool) {\n\tif auth == nil || auth.Attributes == nil {\n\t\treturn \"auto\", false, nil, false\n\t}\n\n\tcloakMode := auth.Attributes[\"cloak_mode\"]\n\tif cloakMode == \"\" {\n\t\tcloakMode = \"auto\"\n\t}\n\n\tstrictMode := strings.ToLower(auth.Attributes[\"cloak_strict_mode\"]) == \"true\"\n\n\tvar sensitiveWords []string\n\tif wordsStr := auth.Attributes[\"cloak_sensitive_words\"]; wordsStr != \"\" {\n\t\tsensitiveWords = strings.Split(wordsStr, \",\")\n\t\tfor i := range sensitiveWords {\n\t\t\tsensitiveWords[i] = strings.TrimSpace(sensitiveWords[i])\n\t\t}\n\t}\n\n\tcacheUserID := strings.EqualFold(strings.TrimSpace(auth.Attributes[\"cloak_cache_user_id\"]), \"true\")\n\n\treturn cloakMode, strictMode, sensitiveWords, cacheUserID\n}\n\n// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.\nfunc resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {\n\tif cfg == nil || auth == nil {\n\t\treturn nil\n\t}\n\n\tapiKey, baseURL := claudeCreds(auth)\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\n\tfor i := range cfg.ClaudeKey {\n\t\tentry := &cfg.ClaudeKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\n\t\t// Match by API key\n\t\tif strings.EqualFold(cfgKey, apiKey) {\n\t\t\t// If baseURL is specified, also check it\n\t\t\tif baseURL != \"\" && cfgBase != \"\" && !strings.EqualFold(cfgBase, baseURL) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn entry.Cloak\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// injectFakeUserID generates and injects a fake user ID into the request metadata.\n// When useCache is false, a new user ID is generated for every call.\nfunc injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {\n\tgenerateID := func() string {\n\t\tif useCache {\n\t\t\treturn cachedUserID(apiKey)\n\t\t}\n\t\treturn generateFakeUserID()\n\t}\n\n\tmetadata := gjson.GetBytes(payload, \"metadata\")\n\tif !metadata.Exists() {\n\t\tpayload, _ = sjson.SetBytes(payload, \"metadata.user_id\", generateID())\n\t\treturn payload\n\t}\n\n\texistingUserID := gjson.GetBytes(payload, \"metadata.user_id\").String()\n\tif existingUserID == \"\" || !isValidUserID(existingUserID) {\n\t\tpayload, _ = sjson.SetBytes(payload, \"metadata.user_id\", generateID())\n\t}\n\treturn payload\n}\n\n// generateBillingHeader creates the x-anthropic-billing-header text block that\n// real Claude Code prepends to every system prompt array.\n// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;\nfunc generateBillingHeader(payload []byte) string {\n\t// Generate a deterministic cch hash from the payload content (system + messages + tools).\n\t// Real Claude Code uses a 5-char hex hash that varies per request.\n\th := sha256.Sum256(payload)\n\tcch := hex.EncodeToString(h[:])[:5]\n\n\t// Build hash: 3-char hex, matches the pattern seen in real requests (e.g. \"a43\")\n\tbuildBytes := make([]byte, 2)\n\t_, _ = rand.Read(buildBytes)\n\tbuildHash := hex.EncodeToString(buildBytes)[:3]\n\n\treturn fmt.Sprintf(\"x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;\", buildHash, cch)\n}\n\n// checkSystemInstructionsWithMode injects Claude Code-style system blocks:\n//\n//\tsystem[0]: billing header (no cache_control)\n//\tsystem[1]: agent identifier (no cache_control)\n//\tsystem[2..]: user system messages (cache_control added when missing)\nfunc checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {\n\tsystem := gjson.GetBytes(payload, \"system\")\n\n\tbillingText := generateBillingHeader(payload)\n\tbillingBlock := fmt.Sprintf(`{\"type\":\"text\",\"text\":\"%s\"}`, billingText)\n\t// No cache_control on the agent block. It is a cloaking artifact with zero cache\n\t// value (the last system block is what actually triggers caching of all system content).\n\t// Including any cache_control here creates an intra-system TTL ordering violation\n\t// when the client's system blocks use ttl='1h' (prompt-caching-scope-2026-01-05 beta\n\t// forbids 1h blocks after 5m blocks, and a no-TTL block defaults to 5m).\n\tagentBlock := `{\"type\":\"text\",\"text\":\"You are a Claude agent, built on Anthropic's Claude Agent SDK.\"}`\n\n\tif strictMode {\n\t\t// Strict mode: billing header + agent identifier only\n\t\tresult := \"[\" + billingBlock + \",\" + agentBlock + \"]\"\n\t\tpayload, _ = sjson.SetRawBytes(payload, \"system\", []byte(result))\n\t\treturn payload\n\t}\n\n\t// Non-strict mode: billing header + agent identifier + user system messages\n\t// Skip if already injected\n\tfirstText := gjson.GetBytes(payload, \"system.0.text\").String()\n\tif strings.HasPrefix(firstText, \"x-anthropic-billing-header:\") {\n\t\treturn payload\n\t}\n\n\tresult := \"[\" + billingBlock + \",\" + agentBlock\n\tif system.IsArray() {\n\t\tsystem.ForEach(func(_, part gjson.Result) bool {\n\t\t\tif part.Get(\"type\").String() == \"text\" {\n\t\t\t\t// Add cache_control to user system messages if not present.\n\t\t\t\t// Do NOT add ttl — let it inherit the default (5m) to avoid\n\t\t\t\t// TTL ordering violations with the prompt-caching-scope-2026-01-05 beta.\n\t\t\t\tpartJSON := part.Raw\n\t\t\t\tif !part.Get(\"cache_control\").Exists() {\n\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"cache_control.type\", \"ephemeral\")\n\t\t\t\t}\n\t\t\t\tresult += \",\" + partJSON\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t} else if system.Type == gjson.String && system.String() != \"\" {\n\t\tpartJSON := `{\"type\":\"text\",\"cache_control\":{\"type\":\"ephemeral\"}}`\n\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", system.String())\n\t\tresult += \",\" + partJSON\n\t}\n\tresult += \"]\"\n\n\tpayload, _ = sjson.SetRawBytes(payload, \"system\", []byte(result))\n\treturn payload\n}\n\n// applyCloaking applies cloaking transformations to the payload based on config and client.\n// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.\nfunc applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {\n\tclientUserAgent := getClientUserAgent(ctx)\n\n\t// Get cloak config from ClaudeKey configuration\n\tcloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)\n\n\t// Determine cloak settings\n\tvar cloakMode string\n\tvar strictMode bool\n\tvar sensitiveWords []string\n\tvar cacheUserID bool\n\n\tif cloakCfg != nil {\n\t\tcloakMode = cloakCfg.Mode\n\t\tstrictMode = cloakCfg.StrictMode\n\t\tsensitiveWords = cloakCfg.SensitiveWords\n\t\tif cloakCfg.CacheUserID != nil {\n\t\t\tcacheUserID = *cloakCfg.CacheUserID\n\t\t}\n\t}\n\n\t// Fallback to auth attributes if no config found\n\tif cloakMode == \"\" {\n\t\tattrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)\n\t\tcloakMode = attrMode\n\t\tif !strictMode {\n\t\t\tstrictMode = attrStrict\n\t\t}\n\t\tif len(sensitiveWords) == 0 {\n\t\t\tsensitiveWords = attrWords\n\t\t}\n\t\tif cloakCfg == nil || cloakCfg.CacheUserID == nil {\n\t\t\tcacheUserID = attrCache\n\t\t}\n\t} else if cloakCfg == nil || cloakCfg.CacheUserID == nil {\n\t\t_, _, _, attrCache := getCloakConfigFromAuth(auth)\n\t\tcacheUserID = attrCache\n\t}\n\n\t// Determine if cloaking should be applied\n\tif !shouldCloak(cloakMode, clientUserAgent) {\n\t\treturn payload\n\t}\n\n\t// Skip system instructions for claude-3-5-haiku models\n\tif !strings.HasPrefix(model, \"claude-3-5-haiku\") {\n\t\tpayload = checkSystemInstructionsWithMode(payload, strictMode)\n\t}\n\n\t// Inject fake user ID\n\tpayload = injectFakeUserID(payload, apiKey, cacheUserID)\n\n\t// Apply sensitive word obfuscation\n\tif len(sensitiveWords) > 0 {\n\t\tmatcher := buildSensitiveWordMatcher(sensitiveWords)\n\t\tpayload = obfuscateSensitiveWords(payload, matcher)\n\t}\n\n\treturn payload\n}\n\n// ensureCacheControl injects cache_control breakpoints into the payload for optimal prompt caching.\n// According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.\n// This function adds cache_control to:\n// 1. The LAST tool in the tools array (caches all tool definitions)\n// 2. The LAST element in the system array (caches system prompt)\n// 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)\n//\n// Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints.\n// This enables up to 90% cost reduction on cached tokens (cache read = 0.1x base price).\n// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\nfunc ensureCacheControl(payload []byte) []byte {\n\t// 1. Inject cache_control into the LAST tool (caches all tool definitions)\n\t// Tools are cached first in the hierarchy, so this is the most important breakpoint.\n\tpayload = injectToolsCacheControl(payload)\n\n\t// 2. Inject cache_control into the LAST system prompt element\n\t// System is the second level in the cache hierarchy.\n\tpayload = injectSystemCacheControl(payload)\n\n\t// 3. Inject cache_control into messages for multi-turn conversation caching\n\t// This caches the conversation history up to the second-to-last user turn.\n\tpayload = injectMessagesCacheControl(payload)\n\n\treturn payload\n}\n\nfunc countCacheControls(payload []byte) int {\n\tcount := 0\n\n\t// Check system\n\tsystem := gjson.GetBytes(payload, \"system\")\n\tif system.IsArray() {\n\t\tsystem.ForEach(func(_, item gjson.Result) bool {\n\t\t\tif item.Get(\"cache_control\").Exists() {\n\t\t\t\tcount++\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Check tools\n\ttools := gjson.GetBytes(payload, \"tools\")\n\tif tools.IsArray() {\n\t\ttools.ForEach(func(_, item gjson.Result) bool {\n\t\t\tif item.Get(\"cache_control\").Exists() {\n\t\t\t\tcount++\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Check messages\n\tmessages := gjson.GetBytes(payload, \"messages\")\n\tif messages.IsArray() {\n\t\tmessages.ForEach(func(_, msg gjson.Result) bool {\n\t\t\tcontent := msg.Get(\"content\")\n\t\t\tif content.IsArray() {\n\t\t\t\tcontent.ForEach(func(_, item gjson.Result) bool {\n\t\t\t\t\tif item.Get(\"cache_control\").Exists() {\n\t\t\t\t\t\tcount++\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\treturn count\n}\n\nfunc parsePayloadObject(payload []byte) (map[string]any, bool) {\n\tif len(payload) == 0 {\n\t\treturn nil, false\n\t}\n\tvar root map[string]any\n\tif err := json.Unmarshal(payload, &root); err != nil {\n\t\treturn nil, false\n\t}\n\treturn root, true\n}\n\nfunc marshalPayloadObject(original []byte, root map[string]any) []byte {\n\tif root == nil {\n\t\treturn original\n\t}\n\tout, err := json.Marshal(root)\n\tif err != nil {\n\t\treturn original\n\t}\n\treturn out\n}\n\nfunc asObject(v any) (map[string]any, bool) {\n\tobj, ok := v.(map[string]any)\n\treturn obj, ok\n}\n\nfunc asArray(v any) ([]any, bool) {\n\tarr, ok := v.([]any)\n\treturn arr, ok\n}\n\nfunc countCacheControlsMap(root map[string]any) int {\n\tcount := 0\n\n\tif system, ok := asArray(root[\"system\"]); ok {\n\t\tfor _, item := range system {\n\t\t\tif obj, ok := asObject(item); ok {\n\t\t\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\t\t\tcount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif tools, ok := asArray(root[\"tools\"]); ok {\n\t\tfor _, item := range tools {\n\t\t\tif obj, ok := asObject(item); ok {\n\t\t\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\t\t\tcount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif messages, ok := asArray(root[\"messages\"]); ok {\n\t\tfor _, msg := range messages {\n\t\t\tmsgObj, ok := asObject(msg)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, ok := asArray(msgObj[\"content\"])\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, item := range content {\n\t\t\t\tif obj, ok := asObject(item); ok {\n\t\t\t\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\t\t\t\tcount++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn count\n}\n\nfunc normalizeTTLForBlock(obj map[string]any, seen5m *bool) bool {\n\tccRaw, exists := obj[\"cache_control\"]\n\tif !exists {\n\t\treturn false\n\t}\n\tcc, ok := asObject(ccRaw)\n\tif !ok {\n\t\t*seen5m = true\n\t\treturn false\n\t}\n\tttlRaw, ttlExists := cc[\"ttl\"]\n\tttl, ttlIsString := ttlRaw.(string)\n\tif !ttlExists || !ttlIsString || ttl != \"1h\" {\n\t\t*seen5m = true\n\t\treturn false\n\t}\n\tif *seen5m {\n\t\tdelete(cc, \"ttl\")\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc findLastCacheControlIndex(arr []any) int {\n\tlast := -1\n\tfor idx, item := range arr {\n\t\tobj, ok := asObject(item)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\tlast = idx\n\t\t}\n\t}\n\treturn last\n}\n\nfunc stripCacheControlExceptIndex(arr []any, preserveIdx int, excess *int) {\n\tfor idx, item := range arr {\n\t\tif *excess <= 0 {\n\t\t\treturn\n\t\t}\n\t\tobj, ok := asObject(item)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := obj[\"cache_control\"]; exists && idx != preserveIdx {\n\t\t\tdelete(obj, \"cache_control\")\n\t\t\t*excess--\n\t\t}\n\t}\n}\n\nfunc stripAllCacheControl(arr []any, excess *int) {\n\tfor _, item := range arr {\n\t\tif *excess <= 0 {\n\t\t\treturn\n\t\t}\n\t\tobj, ok := asObject(item)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\tdelete(obj, \"cache_control\")\n\t\t\t*excess--\n\t\t}\n\t}\n}\n\nfunc stripMessageCacheControl(messages []any, excess *int) {\n\tfor _, msg := range messages {\n\t\tif *excess <= 0 {\n\t\t\treturn\n\t\t}\n\t\tmsgObj, ok := asObject(msg)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tcontent, ok := asArray(msgObj[\"content\"])\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, item := range content {\n\t\t\tif *excess <= 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tobj, ok := asObject(item)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, exists := obj[\"cache_control\"]; exists {\n\t\t\t\tdelete(obj, \"cache_control\")\n\t\t\t\t*excess--\n\t\t\t}\n\t\t}\n\t}\n}\n\n// normalizeCacheControlTTL ensures cache_control TTL values don't violate the\n// prompt-caching-scope-2026-01-05 ordering constraint: a 1h-TTL block must not\n// appear after a 5m-TTL block anywhere in the evaluation order.\n//\n// Anthropic evaluates blocks in order: tools → system (index 0..N) → messages.\n// Within each section, blocks are evaluated in array order. A 5m (default) block\n// followed by a 1h block at ANY later position is an error — including within\n// the same section (e.g. system[1]=5m then system[3]=1h).\n//\n// Strategy: walk all cache_control blocks in evaluation order. Once a 5m block\n// is seen, strip ttl from ALL subsequent 1h blocks (downgrading them to 5m).\nfunc normalizeCacheControlTTL(payload []byte) []byte {\n\troot, ok := parsePayloadObject(payload)\n\tif !ok {\n\t\treturn payload\n\t}\n\n\tseen5m := false\n\tmodified := false\n\n\tif tools, ok := asArray(root[\"tools\"]); ok {\n\t\tfor _, tool := range tools {\n\t\t\tif obj, ok := asObject(tool); ok {\n\t\t\t\tif normalizeTTLForBlock(obj, &seen5m) {\n\t\t\t\t\tmodified = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif system, ok := asArray(root[\"system\"]); ok {\n\t\tfor _, item := range system {\n\t\t\tif obj, ok := asObject(item); ok {\n\t\t\t\tif normalizeTTLForBlock(obj, &seen5m) {\n\t\t\t\t\tmodified = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif messages, ok := asArray(root[\"messages\"]); ok {\n\t\tfor _, msg := range messages {\n\t\t\tmsgObj, ok := asObject(msg)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, ok := asArray(msgObj[\"content\"])\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, item := range content {\n\t\t\t\tif obj, ok := asObject(item); ok {\n\t\t\t\t\tif normalizeTTLForBlock(obj, &seen5m) {\n\t\t\t\t\t\tmodified = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !modified {\n\t\treturn payload\n\t}\n\treturn marshalPayloadObject(payload, root)\n}\n\n// enforceCacheControlLimit removes excess cache_control blocks from a payload\n// so the total does not exceed the Anthropic API limit (currently 4).\n//\n// Anthropic evaluates cache breakpoints in order: tools → system → messages.\n// The most valuable breakpoints are:\n//  1. Last tool         — caches ALL tool definitions\n//  2. Last system block — caches ALL system content\n//  3. Recent messages   — cache conversation context\n//\n// Removal priority (strip lowest-value first):\n//\n//\tPhase 1: system blocks earliest-first, preserving the last one.\n//\tPhase 2: tool blocks earliest-first, preserving the last one.\n//\tPhase 3: message content blocks earliest-first.\n//\tPhase 4: remaining system blocks (last system).\n//\tPhase 5: remaining tool blocks (last tool).\nfunc enforceCacheControlLimit(payload []byte, maxBlocks int) []byte {\n\troot, ok := parsePayloadObject(payload)\n\tif !ok {\n\t\treturn payload\n\t}\n\n\ttotal := countCacheControlsMap(root)\n\tif total <= maxBlocks {\n\t\treturn payload\n\t}\n\n\texcess := total - maxBlocks\n\n\tvar system []any\n\tif arr, ok := asArray(root[\"system\"]); ok {\n\t\tsystem = arr\n\t}\n\tvar tools []any\n\tif arr, ok := asArray(root[\"tools\"]); ok {\n\t\ttools = arr\n\t}\n\tvar messages []any\n\tif arr, ok := asArray(root[\"messages\"]); ok {\n\t\tmessages = arr\n\t}\n\n\tif len(system) > 0 {\n\t\tstripCacheControlExceptIndex(system, findLastCacheControlIndex(system), &excess)\n\t}\n\tif excess <= 0 {\n\t\treturn marshalPayloadObject(payload, root)\n\t}\n\n\tif len(tools) > 0 {\n\t\tstripCacheControlExceptIndex(tools, findLastCacheControlIndex(tools), &excess)\n\t}\n\tif excess <= 0 {\n\t\treturn marshalPayloadObject(payload, root)\n\t}\n\n\tif len(messages) > 0 {\n\t\tstripMessageCacheControl(messages, &excess)\n\t}\n\tif excess <= 0 {\n\t\treturn marshalPayloadObject(payload, root)\n\t}\n\n\tif len(system) > 0 {\n\t\tstripAllCacheControl(system, &excess)\n\t}\n\tif excess <= 0 {\n\t\treturn marshalPayloadObject(payload, root)\n\t}\n\n\tif len(tools) > 0 {\n\t\tstripAllCacheControl(tools, &excess)\n\t}\n\n\treturn marshalPayloadObject(payload, root)\n}\n\n// injectMessagesCacheControl adds cache_control to the second-to-last user turn for multi-turn caching.\n// Per Anthropic docs: \"Place cache_control on the second-to-last User message to let the model reuse the earlier cache.\"\n// This enables caching of conversation history, which is especially beneficial for long multi-turn conversations.\n// Only adds cache_control if:\n// - There are at least 2 user turns in the conversation\n// - No message content already has cache_control\nfunc injectMessagesCacheControl(payload []byte) []byte {\n\tmessages := gjson.GetBytes(payload, \"messages\")\n\tif !messages.Exists() || !messages.IsArray() {\n\t\treturn payload\n\t}\n\n\t// Check if ANY message content already has cache_control\n\thasCacheControlInMessages := false\n\tmessages.ForEach(func(_, msg gjson.Result) bool {\n\t\tcontent := msg.Get(\"content\")\n\t\tif content.IsArray() {\n\t\t\tcontent.ForEach(func(_, item gjson.Result) bool {\n\t\t\t\tif item.Get(\"cache_control\").Exists() {\n\t\t\t\t\thasCacheControlInMessages = true\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t\treturn !hasCacheControlInMessages\n\t})\n\tif hasCacheControlInMessages {\n\t\treturn payload\n\t}\n\n\t// Find all user message indices\n\tvar userMsgIndices []int\n\tmessages.ForEach(func(index gjson.Result, msg gjson.Result) bool {\n\t\tif msg.Get(\"role\").String() == \"user\" {\n\t\t\tuserMsgIndices = append(userMsgIndices, int(index.Int()))\n\t\t}\n\t\treturn true\n\t})\n\n\t// Need at least 2 user turns to cache the second-to-last\n\tif len(userMsgIndices) < 2 {\n\t\treturn payload\n\t}\n\n\t// Get the second-to-last user message index\n\tsecondToLastUserIdx := userMsgIndices[len(userMsgIndices)-2]\n\n\t// Get the content of this message\n\tcontentPath := fmt.Sprintf(\"messages.%d.content\", secondToLastUserIdx)\n\tcontent := gjson.GetBytes(payload, contentPath)\n\n\tif content.IsArray() {\n\t\t// Add cache_control to the last content block of this message\n\t\tcontentCount := int(content.Get(\"#\").Int())\n\t\tif contentCount > 0 {\n\t\t\tcacheControlPath := fmt.Sprintf(\"messages.%d.content.%d.cache_control\", secondToLastUserIdx, contentCount-1)\n\t\t\tresult, err := sjson.SetBytes(payload, cacheControlPath, map[string]string{\"type\": \"ephemeral\"})\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"failed to inject cache_control into messages: %v\", err)\n\t\t\t\treturn payload\n\t\t\t}\n\t\t\tpayload = result\n\t\t}\n\t} else if content.Type == gjson.String {\n\t\t// Convert string content to array with cache_control\n\t\ttext := content.String()\n\t\tnewContent := []map[string]interface{}{\n\t\t\t{\n\t\t\t\t\"type\": \"text\",\n\t\t\t\t\"text\": text,\n\t\t\t\t\"cache_control\": map[string]string{\n\t\t\t\t\t\"type\": \"ephemeral\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tresult, err := sjson.SetBytes(payload, contentPath, newContent)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"failed to inject cache_control into message string content: %v\", err)\n\t\t\treturn payload\n\t\t}\n\t\tpayload = result\n\t}\n\n\treturn payload\n}\n\n// injectToolsCacheControl adds cache_control to the last tool in the tools array.\n// Per Anthropic docs: \"The cache_control parameter on the last tool definition caches all tool definitions.\"\n// This only adds cache_control if NO tool in the array already has it.\nfunc injectToolsCacheControl(payload []byte) []byte {\n\ttools := gjson.GetBytes(payload, \"tools\")\n\tif !tools.Exists() || !tools.IsArray() {\n\t\treturn payload\n\t}\n\n\ttoolCount := int(tools.Get(\"#\").Int())\n\tif toolCount == 0 {\n\t\treturn payload\n\t}\n\n\t// Check if ANY tool already has cache_control - if so, don't modify tools\n\thasCacheControlInTools := false\n\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\tif tool.Get(\"cache_control\").Exists() {\n\t\t\thasCacheControlInTools = true\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t})\n\tif hasCacheControlInTools {\n\t\treturn payload\n\t}\n\n\t// Add cache_control to the last tool\n\tlastToolPath := fmt.Sprintf(\"tools.%d.cache_control\", toolCount-1)\n\tresult, err := sjson.SetBytes(payload, lastToolPath, map[string]string{\"type\": \"ephemeral\"})\n\tif err != nil {\n\t\tlog.Warnf(\"failed to inject cache_control into tools array: %v\", err)\n\t\treturn payload\n\t}\n\n\treturn result\n}\n\n// injectSystemCacheControl adds cache_control to the last element in the system prompt.\n// Converts string system prompts to array format if needed.\n// This only adds cache_control if NO system element already has it.\nfunc injectSystemCacheControl(payload []byte) []byte {\n\tsystem := gjson.GetBytes(payload, \"system\")\n\tif !system.Exists() {\n\t\treturn payload\n\t}\n\n\tif system.IsArray() {\n\t\tcount := int(system.Get(\"#\").Int())\n\t\tif count == 0 {\n\t\t\treturn payload\n\t\t}\n\n\t\t// Check if ANY system element already has cache_control\n\t\thasCacheControlInSystem := false\n\t\tsystem.ForEach(func(_, item gjson.Result) bool {\n\t\t\tif item.Get(\"cache_control\").Exists() {\n\t\t\t\thasCacheControlInSystem = true\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif hasCacheControlInSystem {\n\t\t\treturn payload\n\t\t}\n\n\t\t// Add cache_control to the last system element\n\t\tlastSystemPath := fmt.Sprintf(\"system.%d.cache_control\", count-1)\n\t\tresult, err := sjson.SetBytes(payload, lastSystemPath, map[string]string{\"type\": \"ephemeral\"})\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"failed to inject cache_control into system array: %v\", err)\n\t\t\treturn payload\n\t\t}\n\t\tpayload = result\n\t} else if system.Type == gjson.String {\n\t\t// Convert string system prompt to array with cache_control\n\t\t// \"system\": \"text\" -> \"system\": [{\"type\": \"text\", \"text\": \"text\", \"cache_control\": {\"type\": \"ephemeral\"}}]\n\t\ttext := system.String()\n\t\tnewSystem := []map[string]interface{}{\n\t\t\t{\n\t\t\t\t\"type\": \"text\",\n\t\t\t\t\"text\": text,\n\t\t\t\t\"cache_control\": map[string]string{\n\t\t\t\t\t\"type\": \"ephemeral\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tresult, err := sjson.SetBytes(payload, \"system\", newSystem)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"failed to inject cache_control into system string: %v\", err)\n\t\t\treturn payload\n\t\t}\n\t\tpayload = result\n\t}\n\n\treturn payload\n}\n"
  },
  {
    "path": "internal/runtime/executor/claude_executor_test.go",
    "content": "package executor\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/klauspost/compress/zstd\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nfunc TestApplyClaudeToolPrefix(t *testing.T) {\n\tinput := []byte(`{\"tools\":[{\"name\":\"alpha\"},{\"name\":\"proxy_bravo\"}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"charlie\"},\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"name\":\"delta\",\"id\":\"t1\",\"input\":{}}]}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"tools.0.name\").String(); got != \"proxy_alpha\" {\n\t\tt.Fatalf(\"tools.0.name = %q, want %q\", got, \"proxy_alpha\")\n\t}\n\tif got := gjson.GetBytes(out, \"tools.1.name\").String(); got != \"proxy_bravo\" {\n\t\tt.Fatalf(\"tools.1.name = %q, want %q\", got, \"proxy_bravo\")\n\t}\n\tif got := gjson.GetBytes(out, \"tool_choice.name\").String(); got != \"proxy_charlie\" {\n\t\tt.Fatalf(\"tool_choice.name = %q, want %q\", got, \"proxy_charlie\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.0.name\").String(); got != \"proxy_delta\" {\n\t\tt.Fatalf(\"messages.0.content.0.name = %q, want %q\", got, \"proxy_delta\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_WithToolReference(t *testing.T) {\n\tinput := []byte(`{\"tools\":[{\"name\":\"alpha\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"tool_reference\",\"tool_name\":\"beta\"},{\"type\":\"tool_reference\",\"tool_name\":\"proxy_gamma\"}]}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"messages.0.content.0.tool_name\").String(); got != \"proxy_beta\" {\n\t\tt.Fatalf(\"messages.0.content.0.tool_name = %q, want %q\", got, \"proxy_beta\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.1.tool_name\").String(); got != \"proxy_gamma\" {\n\t\tt.Fatalf(\"messages.0.content.1.tool_name = %q, want %q\", got, \"proxy_gamma\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_SkipsBuiltinTools(t *testing.T) {\n\tinput := []byte(`{\"tools\":[{\"type\":\"web_search_20250305\",\"name\":\"web_search\"},{\"name\":\"my_custom_tool\",\"input_schema\":{\"type\":\"object\"}}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"tools.0.name\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"built-in tool name should not be prefixed: tools.0.name = %q, want %q\", got, \"web_search\")\n\t}\n\tif got := gjson.GetBytes(out, \"tools.1.name\").String(); got != \"proxy_my_custom_tool\" {\n\t\tt.Fatalf(\"custom tool should be prefixed: tools.1.name = %q, want %q\", got, \"proxy_my_custom_tool\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_BuiltinToolSkipped(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"type\": \"web_search_20250305\", \"name\": \"web_search\", \"max_uses\": 5},\n\t\t\t{\"name\": \"Read\"}\n\t\t],\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": [\n\t\t\t\t{\"type\": \"tool_use\", \"name\": \"web_search\", \"id\": \"ws1\", \"input\": {}},\n\t\t\t\t{\"type\": \"tool_use\", \"name\": \"Read\", \"id\": \"r1\", \"input\": {}}\n\t\t\t]}\n\t\t]\n\t}`)\n\tout := applyClaudeToolPrefix(body, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"tools.0.name\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"tools.0.name = %q, want %q\", got, \"web_search\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.0.name\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"messages.0.content.0.name = %q, want %q\", got, \"web_search\")\n\t}\n\tif got := gjson.GetBytes(out, \"tools.1.name\").String(); got != \"proxy_Read\" {\n\t\tt.Fatalf(\"tools.1.name = %q, want %q\", got, \"proxy_Read\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.1.name\").String(); got != \"proxy_Read\" {\n\t\tt.Fatalf(\"messages.0.content.1.name = %q, want %q\", got, \"proxy_Read\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_KnownBuiltinInHistoryOnly(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"name\": \"Read\"}\n\t\t],\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": [\n\t\t\t\t{\"type\": \"tool_use\", \"name\": \"web_search\", \"id\": \"ws1\", \"input\": {}}\n\t\t\t]}\n\t\t]\n\t}`)\n\tout := applyClaudeToolPrefix(body, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"messages.0.content.0.name\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"messages.0.content.0.name = %q, want %q\", got, \"web_search\")\n\t}\n\tif got := gjson.GetBytes(out, \"tools.0.name\").String(); got != \"proxy_Read\" {\n\t\tt.Fatalf(\"tools.0.name = %q, want %q\", got, \"proxy_Read\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_CustomToolsPrefixed(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"tools\": [{\"name\": \"Read\"}, {\"name\": \"Write\"}],\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": [\n\t\t\t\t{\"type\": \"tool_use\", \"name\": \"Read\", \"id\": \"r1\", \"input\": {}},\n\t\t\t\t{\"type\": \"tool_use\", \"name\": \"Write\", \"id\": \"w1\", \"input\": {}}\n\t\t\t]}\n\t\t]\n\t}`)\n\tout := applyClaudeToolPrefix(body, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"tools.0.name\").String(); got != \"proxy_Read\" {\n\t\tt.Fatalf(\"tools.0.name = %q, want %q\", got, \"proxy_Read\")\n\t}\n\tif got := gjson.GetBytes(out, \"tools.1.name\").String(); got != \"proxy_Write\" {\n\t\tt.Fatalf(\"tools.1.name = %q, want %q\", got, \"proxy_Write\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.0.name\").String(); got != \"proxy_Read\" {\n\t\tt.Fatalf(\"messages.0.content.0.name = %q, want %q\", got, \"proxy_Read\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.0.content.1.name\").String(); got != \"proxy_Write\" {\n\t\tt.Fatalf(\"messages.0.content.1.name = %q, want %q\", got, \"proxy_Write\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_ToolChoiceBuiltin(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"type\": \"web_search_20250305\", \"name\": \"web_search\"},\n\t\t\t{\"name\": \"Read\"}\n\t\t],\n\t\t\"tool_choice\": {\"type\": \"tool\", \"name\": \"web_search\"}\n\t}`)\n\tout := applyClaudeToolPrefix(body, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"tool_choice.name\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"tool_choice.name = %q, want %q\", got, \"web_search\")\n\t}\n}\n\nfunc TestStripClaudeToolPrefixFromResponse(t *testing.T) {\n\tinput := []byte(`{\"content\":[{\"type\":\"tool_use\",\"name\":\"proxy_alpha\",\"id\":\"t1\",\"input\":{}},{\"type\":\"tool_use\",\"name\":\"bravo\",\"id\":\"t2\",\"input\":{}}]}`)\n\tout := stripClaudeToolPrefixFromResponse(input, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"content.0.name\").String(); got != \"alpha\" {\n\t\tt.Fatalf(\"content.0.name = %q, want %q\", got, \"alpha\")\n\t}\n\tif got := gjson.GetBytes(out, \"content.1.name\").String(); got != \"bravo\" {\n\t\tt.Fatalf(\"content.1.name = %q, want %q\", got, \"bravo\")\n\t}\n}\n\nfunc TestStripClaudeToolPrefixFromResponse_WithToolReference(t *testing.T) {\n\tinput := []byte(`{\"content\":[{\"type\":\"tool_reference\",\"tool_name\":\"proxy_alpha\"},{\"type\":\"tool_reference\",\"tool_name\":\"bravo\"}]}`)\n\tout := stripClaudeToolPrefixFromResponse(input, \"proxy_\")\n\n\tif got := gjson.GetBytes(out, \"content.0.tool_name\").String(); got != \"alpha\" {\n\t\tt.Fatalf(\"content.0.tool_name = %q, want %q\", got, \"alpha\")\n\t}\n\tif got := gjson.GetBytes(out, \"content.1.tool_name\").String(); got != \"bravo\" {\n\t\tt.Fatalf(\"content.1.tool_name = %q, want %q\", got, \"bravo\")\n\t}\n}\n\nfunc TestStripClaudeToolPrefixFromStreamLine(t *testing.T) {\n\tline := []byte(`data: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"tool_use\",\"name\":\"proxy_alpha\",\"id\":\"t1\"},\"index\":0}`)\n\tout := stripClaudeToolPrefixFromStreamLine(line, \"proxy_\")\n\n\tpayload := bytes.TrimSpace(out)\n\tif bytes.HasPrefix(payload, []byte(\"data:\")) {\n\t\tpayload = bytes.TrimSpace(payload[len(\"data:\"):])\n\t}\n\tif got := gjson.GetBytes(payload, \"content_block.name\").String(); got != \"alpha\" {\n\t\tt.Fatalf(\"content_block.name = %q, want %q\", got, \"alpha\")\n\t}\n}\n\nfunc TestStripClaudeToolPrefixFromStreamLine_WithToolReference(t *testing.T) {\n\tline := []byte(`data: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"tool_reference\",\"tool_name\":\"proxy_beta\"},\"index\":0}`)\n\tout := stripClaudeToolPrefixFromStreamLine(line, \"proxy_\")\n\n\tpayload := bytes.TrimSpace(out)\n\tif bytes.HasPrefix(payload, []byte(\"data:\")) {\n\t\tpayload = bytes.TrimSpace(payload[len(\"data:\"):])\n\t}\n\tif got := gjson.GetBytes(payload, \"content_block.tool_name\").String(); got != \"beta\" {\n\t\tt.Fatalf(\"content_block.tool_name = %q, want %q\", got, \"beta\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_NestedToolReference(t *testing.T) {\n\tinput := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_123\",\"content\":[{\"type\":\"tool_reference\",\"tool_name\":\"mcp__nia__manage_resource\"}]}]}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\tgot := gjson.GetBytes(out, \"messages.0.content.0.content.0.tool_name\").String()\n\tif got != \"proxy_mcp__nia__manage_resource\" {\n\t\tt.Fatalf(\"nested tool_reference tool_name = %q, want %q\", got, \"proxy_mcp__nia__manage_resource\")\n\t}\n}\n\nfunc TestClaudeExecutor_ReusesUserIDAcrossModelsWhenCacheEnabled(t *testing.T) {\n\tresetUserIDCache()\n\n\tvar userIDs []string\n\tvar requestModels []string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\tuserID := gjson.GetBytes(body, \"metadata.user_id\").String()\n\t\tmodel := gjson.GetBytes(body, \"model\").String()\n\t\tuserIDs = append(userIDs, userID)\n\t\trequestModels = append(requestModels, model)\n\t\tt.Logf(\"HTTP Server received request: model=%s, user_id=%s, url=%s\", model, userID, r.URL.String())\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\":\"msg_1\",\"type\":\"message\",\"model\":\"claude-3-5-sonnet\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}`))\n\t}))\n\tdefer server.Close()\n\n\tt.Logf(\"End-to-end test: Fake HTTP server started at %s\", server.URL)\n\n\tcacheEnabled := true\n\texecutor := NewClaudeExecutor(&config.Config{\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{\n\t\t\t\tAPIKey:  \"key-123\",\n\t\t\t\tBaseURL: server.URL,\n\t\t\t\tCloak: &config.CloakConfig{\n\t\t\t\t\tCacheUserID: &cacheEnabled,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\tmodels := []string{\"claude-3-5-sonnet\", \"claude-3-5-haiku\"}\n\tfor _, model := range models {\n\t\tt.Logf(\"Sending request for model: %s\", model)\n\t\tmodelPayload, _ := sjson.SetBytes(payload, \"model\", model)\n\t\tif _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\t\tModel:   model,\n\t\t\tPayload: modelPayload,\n\t\t}, cliproxyexecutor.Options{\n\t\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t\t}); err != nil {\n\t\t\tt.Fatalf(\"Execute(%s) error: %v\", model, err)\n\t\t}\n\t}\n\n\tif len(userIDs) != 2 {\n\t\tt.Fatalf(\"expected 2 requests, got %d\", len(userIDs))\n\t}\n\tif userIDs[0] == \"\" || userIDs[1] == \"\" {\n\t\tt.Fatal(\"expected user_id to be populated\")\n\t}\n\tt.Logf(\"user_id[0] (model=%s): %s\", requestModels[0], userIDs[0])\n\tt.Logf(\"user_id[1] (model=%s): %s\", requestModels[1], userIDs[1])\n\tif userIDs[0] != userIDs[1] {\n\t\tt.Fatalf(\"expected user_id to be reused across models, got %q and %q\", userIDs[0], userIDs[1])\n\t}\n\tif !isValidUserID(userIDs[0]) {\n\t\tt.Fatalf(\"user_id %q is not valid\", userIDs[0])\n\t}\n\tt.Logf(\"✓ End-to-end test passed: Same user_id (%s) was used for both models\", userIDs[0])\n}\n\nfunc TestClaudeExecutor_GeneratesNewUserIDByDefault(t *testing.T) {\n\tresetUserIDCache()\n\n\tvar userIDs []string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\tuserIDs = append(userIDs, gjson.GetBytes(body, \"metadata.user_id\").String())\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\":\"msg_1\",\"type\":\"message\",\"model\":\"claude-3-5-sonnet\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}`))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\tfor i := 0; i < 2; i++ {\n\t\tif _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\t\tModel:   \"claude-3-5-sonnet\",\n\t\t\tPayload: payload,\n\t\t}, cliproxyexecutor.Options{\n\t\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t\t}); err != nil {\n\t\t\tt.Fatalf(\"Execute call %d error: %v\", i, err)\n\t\t}\n\t}\n\n\tif len(userIDs) != 2 {\n\t\tt.Fatalf(\"expected 2 requests, got %d\", len(userIDs))\n\t}\n\tif userIDs[0] == \"\" || userIDs[1] == \"\" {\n\t\tt.Fatal(\"expected user_id to be populated\")\n\t}\n\tif userIDs[0] == userIDs[1] {\n\t\tt.Fatalf(\"expected user_id to change when caching is not enabled, got identical values %q\", userIDs[0])\n\t}\n\tif !isValidUserID(userIDs[0]) || !isValidUserID(userIDs[1]) {\n\t\tt.Fatalf(\"user_ids should be valid, got %q and %q\", userIDs[0], userIDs[1])\n\t}\n}\n\nfunc TestStripClaudeToolPrefixFromResponse_NestedToolReference(t *testing.T) {\n\tinput := []byte(`{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_123\",\"content\":[{\"type\":\"tool_reference\",\"tool_name\":\"proxy_mcp__nia__manage_resource\"}]}]}`)\n\tout := stripClaudeToolPrefixFromResponse(input, \"proxy_\")\n\tgot := gjson.GetBytes(out, \"content.0.content.0.tool_name\").String()\n\tif got != \"mcp__nia__manage_resource\" {\n\t\tt.Fatalf(\"nested tool_reference tool_name = %q, want %q\", got, \"mcp__nia__manage_resource\")\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_NestedToolReferenceWithStringContent(t *testing.T) {\n\t// tool_result.content can be a string - should not be processed\n\tinput := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_123\",\"content\":\"plain string result\"}]}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\tgot := gjson.GetBytes(out, \"messages.0.content.0.content\").String()\n\tif got != \"plain string result\" {\n\t\tt.Fatalf(\"string content should remain unchanged = %q\", got)\n\t}\n}\n\nfunc TestApplyClaudeToolPrefix_SkipsBuiltinToolReference(t *testing.T) {\n\tinput := []byte(`{\"tools\":[{\"type\":\"web_search_20250305\",\"name\":\"web_search\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":[{\"type\":\"tool_reference\",\"tool_name\":\"web_search\"}]}]}]}`)\n\tout := applyClaudeToolPrefix(input, \"proxy_\")\n\tgot := gjson.GetBytes(out, \"messages.0.content.0.content.0.tool_name\").String()\n\tif got != \"web_search\" {\n\t\tt.Fatalf(\"built-in tool_reference should not be prefixed, got %q\", got)\n\t}\n}\n\nfunc TestNormalizeCacheControlTTL_DowngradesLaterOneHourBlocks(t *testing.T) {\n\tpayload := []byte(`{\n\t\t\"tools\": [{\"name\":\"t1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}],\n\t\t\"system\": [{\"type\":\"text\",\"text\":\"s1\",\"cache_control\":{\"type\":\"ephemeral\"}}],\n\t\t\"messages\": [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"u1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}]}]\n\t}`)\n\n\tout := normalizeCacheControlTTL(payload)\n\n\tif got := gjson.GetBytes(out, \"tools.0.cache_control.ttl\").String(); got != \"1h\" {\n\t\tt.Fatalf(\"tools.0.cache_control.ttl = %q, want %q\", got, \"1h\")\n\t}\n\tif gjson.GetBytes(out, \"messages.0.content.0.cache_control.ttl\").Exists() {\n\t\tt.Fatalf(\"messages.0.content.0.cache_control.ttl should be removed after a default-5m block\")\n\t}\n}\n\nfunc TestNormalizeCacheControlTTL_PreservesOriginalBytesWhenNoChange(t *testing.T) {\n\t// Payload where no TTL normalization is needed (all blocks use 1h with no\n\t// preceding 5m block). The text intentionally contains HTML chars (<, >, &)\n\t// that json.Marshal would escape to \\u003c etc., altering byte identity.\n\tpayload := []byte(`{\"tools\":[{\"name\":\"t1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}],\"system\":[{\"type\":\"text\",\"text\":\"<system-reminder>foo & bar</system-reminder>\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}]}`)\n\n\tout := normalizeCacheControlTTL(payload)\n\n\tif !bytes.Equal(out, payload) {\n\t\tt.Fatalf(\"normalizeCacheControlTTL altered bytes when no change was needed.\\noriginal: %s\\ngot:      %s\", payload, out)\n\t}\n}\n\nfunc TestEnforceCacheControlLimit_StripsNonLastToolBeforeMessages(t *testing.T) {\n\tpayload := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"name\":\"t1\",\"cache_control\":{\"type\":\"ephemeral\"}},\n\t\t\t{\"name\":\"t2\",\"cache_control\":{\"type\":\"ephemeral\"}}\n\t\t],\n\t\t\"system\": [{\"type\":\"text\",\"text\":\"s1\",\"cache_control\":{\"type\":\"ephemeral\"}}],\n\t\t\"messages\": [\n\t\t\t{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"u1\",\"cache_control\":{\"type\":\"ephemeral\"}}]},\n\t\t\t{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"u2\",\"cache_control\":{\"type\":\"ephemeral\"}}]}\n\t\t]\n\t}`)\n\n\tout := enforceCacheControlLimit(payload, 4)\n\n\tif got := countCacheControls(out); got != 4 {\n\t\tt.Fatalf(\"cache_control count = %d, want 4\", got)\n\t}\n\tif gjson.GetBytes(out, \"tools.0.cache_control\").Exists() {\n\t\tt.Fatalf(\"tools.0.cache_control should be removed first (non-last tool)\")\n\t}\n\tif !gjson.GetBytes(out, \"tools.1.cache_control\").Exists() {\n\t\tt.Fatalf(\"tools.1.cache_control (last tool) should be preserved\")\n\t}\n\tif !gjson.GetBytes(out, \"messages.0.content.0.cache_control\").Exists() || !gjson.GetBytes(out, \"messages.1.content.0.cache_control\").Exists() {\n\t\tt.Fatalf(\"message cache_control blocks should be preserved when non-last tool removal is enough\")\n\t}\n}\n\nfunc TestEnforceCacheControlLimit_ToolOnlyPayloadStillRespectsLimit(t *testing.T) {\n\tpayload := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"name\":\"t1\",\"cache_control\":{\"type\":\"ephemeral\"}},\n\t\t\t{\"name\":\"t2\",\"cache_control\":{\"type\":\"ephemeral\"}},\n\t\t\t{\"name\":\"t3\",\"cache_control\":{\"type\":\"ephemeral\"}},\n\t\t\t{\"name\":\"t4\",\"cache_control\":{\"type\":\"ephemeral\"}},\n\t\t\t{\"name\":\"t5\",\"cache_control\":{\"type\":\"ephemeral\"}}\n\t\t]\n\t}`)\n\n\tout := enforceCacheControlLimit(payload, 4)\n\n\tif got := countCacheControls(out); got != 4 {\n\t\tt.Fatalf(\"cache_control count = %d, want 4\", got)\n\t}\n\tif gjson.GetBytes(out, \"tools.0.cache_control\").Exists() {\n\t\tt.Fatalf(\"tools.0.cache_control should be removed to satisfy max=4\")\n\t}\n\tif !gjson.GetBytes(out, \"tools.4.cache_control\").Exists() {\n\t\tt.Fatalf(\"last tool cache_control should be preserved when possible\")\n\t}\n}\n\nfunc TestClaudeExecutor_CountTokens_AppliesCacheControlGuards(t *testing.T) {\n\tvar seenBody []byte\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\tseenBody = bytes.Clone(body)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"input_tokens\":42}`))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\n\tpayload := []byte(`{\n\t\t\"tools\": [\n\t\t\t{\"name\":\"t1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}},\n\t\t\t{\"name\":\"t2\",\"cache_control\":{\"type\":\"ephemeral\"}}\n\t\t],\n\t\t\"system\": [\n\t\t\t{\"type\":\"text\",\"text\":\"s1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}},\n\t\t\t{\"type\":\"text\",\"text\":\"s2\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}\n\t\t],\n\t\t\"messages\": [\n\t\t\t{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"u1\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}]},\n\t\t\t{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"u2\",\"cache_control\":{\"type\":\"ephemeral\",\"ttl\":\"1h\"}}]}\n\t\t]\n\t}`)\n\n\t_, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-haiku-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString(\"claude\")})\n\tif err != nil {\n\t\tt.Fatalf(\"CountTokens error: %v\", err)\n\t}\n\n\tif len(seenBody) == 0 {\n\t\tt.Fatal(\"expected count_tokens request body to be captured\")\n\t}\n\tif got := countCacheControls(seenBody); got > 4 {\n\t\tt.Fatalf(\"count_tokens body has %d cache_control blocks, want <= 4\", got)\n\t}\n\tif hasTTLOrderingViolation(seenBody) {\n\t\tt.Fatalf(\"count_tokens body still has ttl ordering violations: %s\", string(seenBody))\n\t}\n}\n\nfunc hasTTLOrderingViolation(payload []byte) bool {\n\tseen5m := false\n\tviolates := false\n\n\tcheckCC := func(cc gjson.Result) {\n\t\tif !cc.Exists() || violates {\n\t\t\treturn\n\t\t}\n\t\tttl := cc.Get(\"ttl\").String()\n\t\tif ttl != \"1h\" {\n\t\t\tseen5m = true\n\t\t\treturn\n\t\t}\n\t\tif seen5m {\n\t\t\tviolates = true\n\t\t}\n\t}\n\n\ttools := gjson.GetBytes(payload, \"tools\")\n\tif tools.IsArray() {\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tcheckCC(tool.Get(\"cache_control\"))\n\t\t\treturn !violates\n\t\t})\n\t}\n\n\tsystem := gjson.GetBytes(payload, \"system\")\n\tif system.IsArray() {\n\t\tsystem.ForEach(func(_, item gjson.Result) bool {\n\t\t\tcheckCC(item.Get(\"cache_control\"))\n\t\t\treturn !violates\n\t\t})\n\t}\n\n\tmessages := gjson.GetBytes(payload, \"messages\")\n\tif messages.IsArray() {\n\t\tmessages.ForEach(func(_, msg gjson.Result) bool {\n\t\t\tcontent := msg.Get(\"content\")\n\t\t\tif content.IsArray() {\n\t\t\t\tcontent.ForEach(func(_, item gjson.Result) bool {\n\t\t\t\t\tcheckCC(item.Get(\"cache_control\"))\n\t\t\t\t\treturn !violates\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn !violates\n\t\t})\n\t}\n\n\treturn violates\n}\n\nfunc TestClaudeExecutor_Execute_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) {\n\ttestClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error {\n\t\t_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\t\tPayload: payload,\n\t\t}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString(\"claude\")})\n\t\treturn err\n\t})\n}\n\nfunc TestClaudeExecutor_ExecuteStream_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) {\n\ttestClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error {\n\t\t_, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\t\tPayload: payload,\n\t\t}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString(\"claude\")})\n\t\treturn err\n\t})\n}\n\nfunc TestClaudeExecutor_CountTokens_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) {\n\ttestClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error {\n\t\t_, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{\n\t\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\t\tPayload: payload,\n\t\t}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString(\"claude\")})\n\t\treturn err\n\t})\n}\n\nfunc testClaudeExecutorInvalidCompressedErrorBody(\n\tt *testing.T,\n\tinvoke func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error,\n) {\n\tt.Helper()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_, _ = w.Write([]byte(\"not-a-valid-gzip-stream\"))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\terr := invoke(executor, auth, payload)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"failed to decode error response body\") {\n\t\tt.Fatalf(\"expected decode failure message, got: %v\", err)\n\t}\n\tif statusProvider, ok := err.(interface{ StatusCode() int }); !ok || statusProvider.StatusCode() != http.StatusBadRequest {\n\t\tt.Fatalf(\"expected status code 400, got: %v\", err)\n\t}\n}\n\n// TestClaudeExecutor_ExecuteStream_SetsIdentityAcceptEncoding verifies that streaming\n// requests use Accept-Encoding: identity so the upstream cannot respond with a\n// compressed SSE body that would silently break the line scanner.\nfunc TestClaudeExecutor_ExecuteStream_SetsIdentityAcceptEncoding(t *testing.T) {\n\tvar gotEncoding, gotAccept string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotEncoding = r.Header.Get(\"Accept-Encoding\")\n\t\tgotAccept = r.Header.Get(\"Accept\")\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\tresult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"ExecuteStream error: %v\", err)\n\t}\n\tfor chunk := range result.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"unexpected chunk error: %v\", chunk.Err)\n\t\t}\n\t}\n\n\tif gotEncoding != \"identity\" {\n\t\tt.Errorf(\"Accept-Encoding = %q, want %q\", gotEncoding, \"identity\")\n\t}\n\tif gotAccept != \"text/event-stream\" {\n\t\tt.Errorf(\"Accept = %q, want %q\", gotAccept, \"text/event-stream\")\n\t}\n}\n\n// TestClaudeExecutor_Execute_SetsCompressedAcceptEncoding verifies that non-streaming\n// requests keep the full accept-encoding to allow response compression (which\n// decodeResponseBody handles correctly).\nfunc TestClaudeExecutor_Execute_SetsCompressedAcceptEncoding(t *testing.T) {\n\tvar gotEncoding, gotAccept string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotEncoding = r.Header.Get(\"Accept-Encoding\")\n\t\tgotAccept = r.Header.Get(\"Accept\")\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\":\"msg_1\",\"type\":\"message\",\"model\":\"claude-3-5-sonnet-20241022\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}`))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\t_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Execute error: %v\", err)\n\t}\n\n\tif gotEncoding != \"gzip, deflate, br, zstd\" {\n\t\tt.Errorf(\"Accept-Encoding = %q, want %q\", gotEncoding, \"gzip, deflate, br, zstd\")\n\t}\n\tif gotAccept != \"application/json\" {\n\t\tt.Errorf(\"Accept = %q, want %q\", gotAccept, \"application/json\")\n\t}\n}\n\n// TestClaudeExecutor_ExecuteStream_GzipSuccessBodyDecoded verifies that a streaming\n// HTTP 200 response with Content-Encoding: gzip is correctly decompressed before\n// the line scanner runs, so SSE chunks are not silently dropped.\nfunc TestClaudeExecutor_ExecuteStream_GzipSuccessBodyDecoded(t *testing.T) {\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\t_, _ = gz.Write([]byte(\"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\"))\n\t_ = gz.Close()\n\tcompressedBody := buf.Bytes()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\t_, _ = w.Write(compressedBody)\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\tresult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"ExecuteStream error: %v\", err)\n\t}\n\n\tvar combined strings.Builder\n\tfor chunk := range result.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"chunk error: %v\", chunk.Err)\n\t\t}\n\t\tcombined.Write(chunk.Payload)\n\t}\n\n\tif combined.Len() == 0 {\n\t\tt.Fatal(\"expected at least one chunk from gzip-encoded SSE body, got none (body was not decompressed)\")\n\t}\n\tif !strings.Contains(combined.String(), \"message_stop\") {\n\t\tt.Errorf(\"expected SSE content in chunks, got: %q\", combined.String())\n\t}\n}\n\n// TestDecodeResponseBody_MagicByteGzipNoHeader verifies that decodeResponseBody\n// detects gzip-compressed content via magic bytes even when Content-Encoding is absent.\nfunc TestDecodeResponseBody_MagicByteGzipNoHeader(t *testing.T) {\n\tconst plaintext = \"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\"\n\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\t_, _ = gz.Write([]byte(plaintext))\n\t_ = gz.Close()\n\n\trc := io.NopCloser(&buf)\n\tdecoded, err := decodeResponseBody(rc, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"decodeResponseBody error: %v\", err)\n\t}\n\tdefer decoded.Close()\n\n\tgot, err := io.ReadAll(decoded)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadAll error: %v\", err)\n\t}\n\tif string(got) != plaintext {\n\t\tt.Errorf(\"decoded = %q, want %q\", got, plaintext)\n\t}\n}\n\n// TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns\n// plain text untouched when Content-Encoding is absent and no magic bytes match.\nfunc TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {\n\tconst plaintext = \"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\"\n\trc := io.NopCloser(strings.NewReader(plaintext))\n\tdecoded, err := decodeResponseBody(rc, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"decodeResponseBody error: %v\", err)\n\t}\n\tdefer decoded.Close()\n\n\tgot, err := io.ReadAll(decoded)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadAll error: %v\", err)\n\t}\n\tif string(got) != plaintext {\n\t\tt.Errorf(\"decoded = %q, want %q\", got, plaintext)\n\t}\n}\n\n// TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader verifies the full\n// pipeline: when the upstream returns a gzip-compressed SSE body WITHOUT setting\n// Content-Encoding (a misbehaving upstream), the magic-byte sniff in\n// decodeResponseBody still decompresses it, so chunks reach the caller.\nfunc TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader(t *testing.T) {\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\t_, _ = gz.Write([]byte(\"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\"))\n\t_ = gz.Close()\n\tcompressedBody := buf.Bytes()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t// Intentionally omit Content-Encoding to simulate misbehaving upstream.\n\t\t_, _ = w.Write(compressedBody)\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\tresult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"ExecuteStream error: %v\", err)\n\t}\n\n\tvar combined strings.Builder\n\tfor chunk := range result.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"chunk error: %v\", chunk.Err)\n\t\t}\n\t\tcombined.Write(chunk.Payload)\n\t}\n\n\tif combined.Len() == 0 {\n\t\tt.Fatal(\"expected chunks from gzip body without Content-Encoding header, got none (magic-byte sniff failed)\")\n\t}\n\tif !strings.Contains(combined.String(), \"message_stop\") {\n\t\tt.Errorf(\"unexpected chunk content: %q\", combined.String())\n\t}\n}\n\n// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies\n// that injecting Accept-Encoding via auth.Attributes cannot override the stream\n// path's enforced identity encoding.\nfunc TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) {\n\tvar gotEncoding string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotEncoding = r.Header.Get(\"Accept-Encoding\")\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\t// Inject Accept-Encoding via the custom header attribute mechanism.\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":                \"key-123\",\n\t\t\"base_url\":               server.URL,\n\t\t\"header:Accept-Encoding\": \"gzip, deflate, br, zstd\",\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\tresult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"ExecuteStream error: %v\", err)\n\t}\n\tfor chunk := range result.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"unexpected chunk error: %v\", chunk.Err)\n\t\t}\n\t}\n\n\tif gotEncoding != \"identity\" {\n\t\tt.Errorf(\"Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override\", gotEncoding)\n\t}\n}\n\n// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody\n// detects zstd-compressed content via magic bytes (28 b5 2f fd) even when\n// Content-Encoding is absent.\nfunc TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) {\n\tconst plaintext = \"data: {\\\"type\\\":\\\"message_stop\\\"}\\n\"\n\n\tvar buf bytes.Buffer\n\tenc, err := zstd.NewWriter(&buf)\n\tif err != nil {\n\t\tt.Fatalf(\"zstd.NewWriter: %v\", err)\n\t}\n\t_, _ = enc.Write([]byte(plaintext))\n\t_ = enc.Close()\n\n\trc := io.NopCloser(&buf)\n\tdecoded, err := decodeResponseBody(rc, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"decodeResponseBody error: %v\", err)\n\t}\n\tdefer decoded.Close()\n\n\tgot, err := io.ReadAll(decoded)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadAll error: %v\", err)\n\t}\n\tif string(got) != plaintext {\n\t\tt.Errorf(\"decoded = %q, want %q\", got, plaintext)\n\t}\n}\n\n// TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the\n// error path (4xx) correctly decompresses a gzip body even when the upstream omits\n// the Content-Encoding header.  This closes the gap left by PR #1771, which only\n// fixed header-declared compression on the error path.\nfunc TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader(t *testing.T) {\n\tconst errJSON = `{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"test error\"}}`\n\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\t_, _ = gz.Write([]byte(errJSON))\n\t_ = gz.Close()\n\tcompressedBody := buf.Bytes()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t// Intentionally omit Content-Encoding to simulate misbehaving upstream.\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_, _ = w.Write(compressedBody)\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\t_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for 400 response, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"test error\") {\n\t\tt.Errorf(\"error message should contain decompressed JSON, got: %q\", err.Error())\n\t}\n}\n\n// TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader verifies\n// the same for the streaming executor: 4xx gzip body without Content-Encoding is\n// decoded and the error message is readable.\nfunc TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *testing.T) {\n\tconst errJSON = `{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"stream test error\"}}`\n\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\t_, _ = gz.Write([]byte(errJSON))\n\t_ = gz.Close()\n\tcompressedBody := buf.Bytes()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t// Intentionally omit Content-Encoding to simulate misbehaving upstream.\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_, _ = w.Write(compressedBody)\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewClaudeExecutor(&config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"api_key\":  \"key-123\",\n\t\t\"base_url\": server.URL,\n\t}}\n\tpayload := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}`)\n\n\t_, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"claude-3-5-sonnet-20241022\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"claude\"),\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for 400 response, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"stream test error\") {\n\t\tt.Errorf(\"error message should contain decompressed JSON, got: %q\", err.Error())\n\t}\n}\n\n// Test case 1: String system prompt is preserved and converted to a content block\nfunc TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {\n\tpayload := []byte(`{\"system\":\"You are a helpful assistant.\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\n\tout := checkSystemInstructionsWithMode(payload, false)\n\n\tsystem := gjson.GetBytes(out, \"system\")\n\tif !system.IsArray() {\n\t\tt.Fatalf(\"system should be an array, got %s\", system.Type)\n\t}\n\n\tblocks := system.Array()\n\tif len(blocks) != 3 {\n\t\tt.Fatalf(\"expected 3 system blocks, got %d\", len(blocks))\n\t}\n\n\tif !strings.HasPrefix(blocks[0].Get(\"text\").String(), \"x-anthropic-billing-header:\") {\n\t\tt.Fatalf(\"blocks[0] should be billing header, got %q\", blocks[0].Get(\"text\").String())\n\t}\n\tif blocks[1].Get(\"text\").String() != \"You are a Claude agent, built on Anthropic's Claude Agent SDK.\" {\n\t\tt.Fatalf(\"blocks[1] should be agent block, got %q\", blocks[1].Get(\"text\").String())\n\t}\n\tif blocks[2].Get(\"text\").String() != \"You are a helpful assistant.\" {\n\t\tt.Fatalf(\"blocks[2] should be user system prompt, got %q\", blocks[2].Get(\"text\").String())\n\t}\n\tif blocks[2].Get(\"cache_control.type\").String() != \"ephemeral\" {\n\t\tt.Fatalf(\"blocks[2] should have cache_control.type=ephemeral\")\n\t}\n}\n\n// Test case 2: Strict mode drops the string system prompt\nfunc TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) {\n\tpayload := []byte(`{\"system\":\"You are a helpful assistant.\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\n\tout := checkSystemInstructionsWithMode(payload, true)\n\n\tblocks := gjson.GetBytes(out, \"system\").Array()\n\tif len(blocks) != 2 {\n\t\tt.Fatalf(\"strict mode should produce 2 blocks, got %d\", len(blocks))\n\t}\n}\n\n// Test case 3: Empty string system prompt does not produce a spurious block\nfunc TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) {\n\tpayload := []byte(`{\"system\":\"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\n\tout := checkSystemInstructionsWithMode(payload, false)\n\n\tblocks := gjson.GetBytes(out, \"system\").Array()\n\tif len(blocks) != 2 {\n\t\tt.Fatalf(\"empty string system should produce 2 blocks, got %d\", len(blocks))\n\t}\n}\n\n// Test case 4: Array system prompt is unaffected by the string handling\nfunc TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {\n\tpayload := []byte(`{\"system\":[{\"type\":\"text\",\"text\":\"Be concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\n\tout := checkSystemInstructionsWithMode(payload, false)\n\n\tblocks := gjson.GetBytes(out, \"system\").Array()\n\tif len(blocks) != 3 {\n\t\tt.Fatalf(\"expected 3 system blocks, got %d\", len(blocks))\n\t}\n\tif blocks[2].Get(\"text\").String() != \"Be concise.\" {\n\t\tt.Fatalf(\"blocks[2] should be user system prompt, got %q\", blocks[2].Get(\"text\").String())\n\t}\n}\n\n// Test case 5: Special characters in string system prompt survive conversion\nfunc TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {\n\tpayload := []byte(`{\"system\":\"Use <xml> tags & \\\"quotes\\\" in output.\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\n\tout := checkSystemInstructionsWithMode(payload, false)\n\n\tblocks := gjson.GetBytes(out, \"system\").Array()\n\tif len(blocks) != 3 {\n\t\tt.Fatalf(\"expected 3 system blocks, got %d\", len(blocks))\n\t}\n\tif blocks[2].Get(\"text\").String() != `Use <xml> tags & \"quotes\" in output.` {\n\t\tt.Fatalf(\"blocks[2] text mangled, got %q\", blocks[2].Get(\"text\").String())\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/cloak_obfuscate.go",
    "content": "package executor\n\nimport (\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// zeroWidthSpace is the Unicode zero-width space character used for obfuscation.\nconst zeroWidthSpace = \"\\u200B\"\n\n// SensitiveWordMatcher holds the compiled regex for matching sensitive words.\ntype SensitiveWordMatcher struct {\n\tregex *regexp.Regexp\n}\n\n// buildSensitiveWordMatcher compiles a regex from the word list.\n// Words are sorted by length (longest first) for proper matching.\nfunc buildSensitiveWordMatcher(words []string) *SensitiveWordMatcher {\n\tif len(words) == 0 {\n\t\treturn nil\n\t}\n\n\t// Filter and normalize words\n\tvar validWords []string\n\tfor _, w := range words {\n\t\tw = strings.TrimSpace(w)\n\t\tif utf8.RuneCountInString(w) >= 2 && !strings.Contains(w, zeroWidthSpace) {\n\t\t\tvalidWords = append(validWords, w)\n\t\t}\n\t}\n\n\tif len(validWords) == 0 {\n\t\treturn nil\n\t}\n\n\t// Sort by length (longest first) for proper matching\n\tsort.Slice(validWords, func(i, j int) bool {\n\t\treturn len(validWords[i]) > len(validWords[j])\n\t})\n\n\t// Escape and join\n\tescaped := make([]string, len(validWords))\n\tfor i, w := range validWords {\n\t\tescaped[i] = regexp.QuoteMeta(w)\n\t}\n\n\tpattern := \"(?i)\" + strings.Join(escaped, \"|\")\n\tre, err := regexp.Compile(pattern)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\treturn &SensitiveWordMatcher{regex: re}\n}\n\n// obfuscateWord inserts a zero-width space after the first grapheme.\nfunc obfuscateWord(word string) string {\n\tif strings.Contains(word, zeroWidthSpace) {\n\t\treturn word\n\t}\n\n\t// Get first rune\n\tr, size := utf8.DecodeRuneInString(word)\n\tif r == utf8.RuneError || size >= len(word) {\n\t\treturn word\n\t}\n\n\treturn string(r) + zeroWidthSpace + word[size:]\n}\n\n// obfuscateText replaces all sensitive words in the text.\nfunc (m *SensitiveWordMatcher) obfuscateText(text string) string {\n\tif m == nil || m.regex == nil {\n\t\treturn text\n\t}\n\treturn m.regex.ReplaceAllStringFunc(text, obfuscateWord)\n}\n\n// obfuscateSensitiveWords processes the payload and obfuscates sensitive words\n// in system blocks and message content.\nfunc obfuscateSensitiveWords(payload []byte, matcher *SensitiveWordMatcher) []byte {\n\tif matcher == nil || matcher.regex == nil {\n\t\treturn payload\n\t}\n\n\t// Obfuscate in system blocks\n\tpayload = obfuscateSystemBlocks(payload, matcher)\n\n\t// Obfuscate in messages\n\tpayload = obfuscateMessages(payload, matcher)\n\n\treturn payload\n}\n\n// obfuscateSystemBlocks obfuscates sensitive words in system blocks.\nfunc obfuscateSystemBlocks(payload []byte, matcher *SensitiveWordMatcher) []byte {\n\tsystem := gjson.GetBytes(payload, \"system\")\n\tif !system.Exists() {\n\t\treturn payload\n\t}\n\n\tif system.IsArray() {\n\t\tmodified := false\n\t\tsystem.ForEach(func(key, value gjson.Result) bool {\n\t\t\tif value.Get(\"type\").String() == \"text\" {\n\t\t\t\ttext := value.Get(\"text\").String()\n\t\t\t\tobfuscated := matcher.obfuscateText(text)\n\t\t\t\tif obfuscated != text {\n\t\t\t\t\tpath := \"system.\" + key.String() + \".text\"\n\t\t\t\t\tpayload, _ = sjson.SetBytes(payload, path, obfuscated)\n\t\t\t\t\tmodified = true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif modified {\n\t\t\treturn payload\n\t\t}\n\t} else if system.Type == gjson.String {\n\t\ttext := system.String()\n\t\tobfuscated := matcher.obfuscateText(text)\n\t\tif obfuscated != text {\n\t\t\tpayload, _ = sjson.SetBytes(payload, \"system\", obfuscated)\n\t\t}\n\t}\n\n\treturn payload\n}\n\n// obfuscateMessages obfuscates sensitive words in message content.\nfunc obfuscateMessages(payload []byte, matcher *SensitiveWordMatcher) []byte {\n\tmessages := gjson.GetBytes(payload, \"messages\")\n\tif !messages.Exists() || !messages.IsArray() {\n\t\treturn payload\n\t}\n\n\tmessages.ForEach(func(msgKey, msg gjson.Result) bool {\n\t\tcontent := msg.Get(\"content\")\n\t\tif !content.Exists() {\n\t\t\treturn true\n\t\t}\n\n\t\tmsgPath := \"messages.\" + msgKey.String()\n\n\t\tif content.Type == gjson.String {\n\t\t\t// Simple string content\n\t\t\ttext := content.String()\n\t\t\tobfuscated := matcher.obfuscateText(text)\n\t\t\tif obfuscated != text {\n\t\t\t\tpayload, _ = sjson.SetBytes(payload, msgPath+\".content\", obfuscated)\n\t\t\t}\n\t\t} else if content.IsArray() {\n\t\t\t// Array of content blocks\n\t\t\tcontent.ForEach(func(blockKey, block gjson.Result) bool {\n\t\t\t\tif block.Get(\"type\").String() == \"text\" {\n\t\t\t\t\ttext := block.Get(\"text\").String()\n\t\t\t\t\tobfuscated := matcher.obfuscateText(text)\n\t\t\t\t\tif obfuscated != text {\n\t\t\t\t\t\tpath := msgPath + \".content.\" + blockKey.String() + \".text\"\n\t\t\t\t\t\tpayload, _ = sjson.SetBytes(payload, path, obfuscated)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\n\t\treturn true\n\t})\n\n\treturn payload\n}\n"
  },
  {
    "path": "internal/runtime/executor/cloak_utils.go",
    "content": "package executor\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n)\n\n// userIDPattern matches Claude Code format: user_[64-hex]_account_[uuid]_session_[uuid]\nvar userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)\n\n// generateFakeUserID generates a fake user ID in Claude Code format.\n// Format: user_[64-hex-chars]_account_[UUID-v4]_session_[UUID-v4]\nfunc generateFakeUserID() string {\n\thexBytes := make([]byte, 32)\n\t_, _ = rand.Read(hexBytes)\n\thexPart := hex.EncodeToString(hexBytes)\n\taccountUUID := uuid.New().String()\n\tsessionUUID := uuid.New().String()\n\treturn \"user_\" + hexPart + \"_account_\" + accountUUID + \"_session_\" + sessionUUID\n}\n\n// isValidUserID checks if a user ID matches Claude Code format.\nfunc isValidUserID(userID string) bool {\n\treturn userIDPattern.MatchString(userID)\n}\n\n// shouldCloak determines if request should be cloaked based on config and client User-Agent.\n// Returns true if cloaking should be applied.\nfunc shouldCloak(cloakMode string, userAgent string) bool {\n\tswitch strings.ToLower(cloakMode) {\n\tcase \"always\":\n\t\treturn true\n\tcase \"never\":\n\t\treturn false\n\tdefault: // \"auto\" or empty\n\t\t// If client is Claude Code, don't cloak\n\t\treturn !strings.HasPrefix(userAgent, \"claude-cli\")\n\t}\n}\n\n// isClaudeCodeClient checks if the User-Agent indicates a Claude Code client.\nfunc isClaudeCodeClient(userAgent string) bool {\n\treturn strings.HasPrefix(userAgent, \"claude-cli\")\n}\n"
  },
  {
    "path": "internal/runtime/executor/codex_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tcodexauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n\t\"github.com/tiktoken-go/tokenizer\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n)\n\nconst (\n\tcodexClientVersion = \"0.101.0\"\n\tcodexUserAgent     = \"codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464\"\n)\n\nvar dataTag = []byte(\"data:\")\n\n// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).\n// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.\ntype CodexExecutor struct {\n\tcfg *config.Config\n}\n\nfunc NewCodexExecutor(cfg *config.Config) *CodexExecutor { return &CodexExecutor{cfg: cfg} }\n\nfunc (e *CodexExecutor) Identifier() string { return \"codex\" }\n\n// PrepareRequest injects Codex credentials into the outgoing HTTP request.\nfunc (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\tapiKey, _ := codexCreds(auth)\n\tif strings.TrimSpace(apiKey) != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(req, attrs)\n\treturn nil\n}\n\n// HttpRequest injects Codex credentials into the request and executes it.\nfunc (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"codex executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\nfunc (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn e.executeCompact(ctx, auth, req, opts)\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := codexCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://chatgpt.com/backend-api/codex\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"codex\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\tbody, _ = sjson.SetBytes(body, \"stream\", true)\n\tbody, _ = sjson.DeleteBytes(body, \"previous_response_id\")\n\tbody, _ = sjson.DeleteBytes(body, \"prompt_cache_retention\")\n\tbody, _ = sjson.DeleteBytes(body, \"safety_identifier\")\n\tif !gjson.GetBytes(body, \"instructions\").Exists() {\n\t\tbody, _ = sjson.SetBytes(body, \"instructions\", \"\")\n\t}\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/responses\"\n\thttpReq, err := e.cacheHelper(ctx, from, url, req, body)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"codex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = newCodexStatusErr(httpResp.StatusCode, b)\n\t\treturn resp, err\n\t}\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\n\tlines := bytes.Split(data, []byte(\"\\n\"))\n\tfor _, line := range lines {\n\t\tif !bytes.HasPrefix(line, dataTag) {\n\t\t\tcontinue\n\t\t}\n\n\t\tline = bytes.TrimSpace(line[5:])\n\t\tif gjson.GetBytes(line, \"type\").String() != \"response.completed\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif detail, ok := parseCodexUsage(line); ok {\n\t\t\treporter.publish(ctx, detail)\n\t\t}\n\n\t\tvar param any\n\t\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, line, &param)\n\t\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\t\treturn resp, nil\n\t}\n\terr = statusErr{code: 408, msg: \"stream error: stream disconnected before completion: stream closed before response.completed\"}\n\treturn resp, err\n}\n\nfunc (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := codexCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://chatgpt.com/backend-api/codex\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai-response\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\tbody, _ = sjson.DeleteBytes(body, \"stream\")\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/responses/compact\"\n\thttpReq, err := e.cacheHelper(ctx, from, url, req, body)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"codex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = newCodexStatusErr(httpResp.StatusCode, b)\n\t\treturn resp, err\n\t}\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseOpenAIUsage(data))\n\treporter.ensurePublished(ctx)\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\nfunc (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusBadRequest, msg: \"streaming not supported for /responses/compact\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := codexCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://chatgpt.com/backend-api/codex\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"codex\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.DeleteBytes(body, \"previous_response_id\")\n\tbody, _ = sjson.DeleteBytes(body, \"prompt_cache_retention\")\n\tbody, _ = sjson.DeleteBytes(body, \"safety_identifier\")\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\tif !gjson.GetBytes(body, \"instructions\").Exists() {\n\t\tbody, _ = sjson.SetBytes(body, \"instructions\", \"\")\n\t}\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/responses\"\n\thttpReq, err := e.cacheHelper(ctx, from, url, req, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapplyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tdata, readErr := io.ReadAll(httpResp.Body)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"codex executor: close response body error: %v\", errClose)\n\t\t}\n\t\tif readErr != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, readErr)\n\t\t\treturn nil, readErr\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), data))\n\t\terr = newCodexStatusErr(httpResp.StatusCode, data)\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"codex executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\n\t\t\tif bytes.HasPrefix(line, dataTag) {\n\t\t\t\tdata := bytes.TrimSpace(line[5:])\n\t\t\t\tif gjson.GetBytes(data, \"type\").String() == \"response.completed\" {\n\t\t\t\t\tif detail, ok := parseCodexUsage(data); ok {\n\t\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\nfunc (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"codex\")\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err := thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\tbody, _ = sjson.DeleteBytes(body, \"previous_response_id\")\n\tbody, _ = sjson.DeleteBytes(body, \"prompt_cache_retention\")\n\tbody, _ = sjson.DeleteBytes(body, \"safety_identifier\")\n\tbody, _ = sjson.SetBytes(body, \"stream\", false)\n\tif !gjson.GetBytes(body, \"instructions\").Exists() {\n\t\tbody, _ = sjson.SetBytes(body, \"instructions\", \"\")\n\t}\n\n\tenc, err := tokenizerForCodexModel(baseModel)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"codex executor: tokenizer init failed: %w\", err)\n\t}\n\n\tcount, err := countCodexInputTokens(enc, body)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"codex executor: token counting failed: %w\", err)\n\t}\n\n\tusageJSON := fmt.Sprintf(`{\"response\":{\"usage\":{\"input_tokens\":%d,\"output_tokens\":0,\"total_tokens\":%d}}}`, count, count)\n\ttranslated := sdktranslator.TranslateTokenCount(ctx, to, from, count, []byte(usageJSON))\n\treturn cliproxyexecutor.Response{Payload: []byte(translated)}, nil\n}\n\nfunc tokenizerForCodexModel(model string) (tokenizer.Codec, error) {\n\tsanitized := strings.ToLower(strings.TrimSpace(model))\n\tswitch {\n\tcase sanitized == \"\":\n\t\treturn tokenizer.Get(tokenizer.Cl100kBase)\n\tcase strings.HasPrefix(sanitized, \"gpt-5\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT5)\n\tcase strings.HasPrefix(sanitized, \"gpt-4.1\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT41)\n\tcase strings.HasPrefix(sanitized, \"gpt-4o\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT4o)\n\tcase strings.HasPrefix(sanitized, \"gpt-4\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT4)\n\tcase strings.HasPrefix(sanitized, \"gpt-3.5\"), strings.HasPrefix(sanitized, \"gpt-3\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT35Turbo)\n\tdefault:\n\t\treturn tokenizer.Get(tokenizer.Cl100kBase)\n\t}\n}\n\nfunc countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {\n\tif enc == nil {\n\t\treturn 0, fmt.Errorf(\"encoder is nil\")\n\t}\n\tif len(body) == 0 {\n\t\treturn 0, nil\n\t}\n\n\troot := gjson.ParseBytes(body)\n\tvar segments []string\n\n\tif inst := strings.TrimSpace(root.Get(\"instructions\").String()); inst != \"\" {\n\t\tsegments = append(segments, inst)\n\t}\n\n\tinputItems := root.Get(\"input\")\n\tif inputItems.IsArray() {\n\t\tarr := inputItems.Array()\n\t\tfor i := range arr {\n\t\t\titem := arr[i]\n\t\t\tswitch item.Get(\"type\").String() {\n\t\t\tcase \"message\":\n\t\t\t\tcontent := item.Get(\"content\")\n\t\t\t\tif content.IsArray() {\n\t\t\t\t\tparts := content.Array()\n\t\t\t\t\tfor j := range parts {\n\t\t\t\t\t\tpart := parts[j]\n\t\t\t\t\t\tif text := strings.TrimSpace(part.Get(\"text\").String()); text != \"\" {\n\t\t\t\t\t\t\tsegments = append(segments, text)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"function_call\":\n\t\t\t\tif name := strings.TrimSpace(item.Get(\"name\").String()); name != \"\" {\n\t\t\t\t\tsegments = append(segments, name)\n\t\t\t\t}\n\t\t\t\tif args := strings.TrimSpace(item.Get(\"arguments\").String()); args != \"\" {\n\t\t\t\t\tsegments = append(segments, args)\n\t\t\t\t}\n\t\t\tcase \"function_call_output\":\n\t\t\t\tif out := strings.TrimSpace(item.Get(\"output\").String()); out != \"\" {\n\t\t\t\t\tsegments = append(segments, out)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tif text := strings.TrimSpace(item.Get(\"text\").String()); text != \"\" {\n\t\t\t\t\tsegments = append(segments, text)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\ttools := root.Get(\"tools\")\n\tif tools.IsArray() {\n\t\ttarr := tools.Array()\n\t\tfor i := range tarr {\n\t\t\ttool := tarr[i]\n\t\t\tif name := strings.TrimSpace(tool.Get(\"name\").String()); name != \"\" {\n\t\t\t\tsegments = append(segments, name)\n\t\t\t}\n\t\t\tif desc := strings.TrimSpace(tool.Get(\"description\").String()); desc != \"\" {\n\t\t\t\tsegments = append(segments, desc)\n\t\t\t}\n\t\t\tif params := tool.Get(\"parameters\"); params.Exists() {\n\t\t\t\tval := params.Raw\n\t\t\t\tif params.Type == gjson.String {\n\t\t\t\t\tval = params.String()\n\t\t\t\t}\n\t\t\t\tif trimmed := strings.TrimSpace(val); trimmed != \"\" {\n\t\t\t\t\tsegments = append(segments, trimmed)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\ttextFormat := root.Get(\"text.format\")\n\tif textFormat.Exists() {\n\t\tif name := strings.TrimSpace(textFormat.Get(\"name\").String()); name != \"\" {\n\t\t\tsegments = append(segments, name)\n\t\t}\n\t\tif schema := textFormat.Get(\"schema\"); schema.Exists() {\n\t\t\tval := schema.Raw\n\t\t\tif schema.Type == gjson.String {\n\t\t\t\tval = schema.String()\n\t\t\t}\n\t\t\tif trimmed := strings.TrimSpace(val); trimmed != \"\" {\n\t\t\t\tsegments = append(segments, trimmed)\n\t\t\t}\n\t\t}\n\t}\n\n\ttext := strings.Join(segments, \"\\n\")\n\tif text == \"\" {\n\t\treturn 0, nil\n\t}\n\n\tcount, err := enc.Count(text)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn int64(count), nil\n}\n\nfunc (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"codex executor: refresh called\")\n\tif auth == nil {\n\t\treturn nil, statusErr{code: 500, msg: \"codex executor: auth is nil\"}\n\t}\n\tvar refreshToken string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"refresh_token\"].(string); ok && v != \"\" {\n\t\t\trefreshToken = v\n\t\t}\n\t}\n\tif refreshToken == \"\" {\n\t\treturn auth, nil\n\t}\n\tsvc := codexauth.NewCodexAuth(e.cfg)\n\ttd, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"id_token\"] = td.IDToken\n\tauth.Metadata[\"access_token\"] = td.AccessToken\n\tif td.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = td.RefreshToken\n\t}\n\tif td.AccountID != \"\" {\n\t\tauth.Metadata[\"account_id\"] = td.AccountID\n\t}\n\tauth.Metadata[\"email\"] = td.Email\n\t// Use unified key in files\n\tauth.Metadata[\"expired\"] = td.Expire\n\tauth.Metadata[\"type\"] = \"codex\"\n\tnow := time.Now().Format(time.RFC3339)\n\tauth.Metadata[\"last_refresh\"] = now\n\treturn auth, nil\n}\n\nfunc (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {\n\tvar cache codexCache\n\tif from == \"claude\" {\n\t\tuserIDResult := gjson.GetBytes(req.Payload, \"metadata.user_id\")\n\t\tif userIDResult.Exists() {\n\t\t\tkey := fmt.Sprintf(\"%s-%s\", req.Model, userIDResult.String())\n\t\t\tvar ok bool\n\t\t\tif cache, ok = getCodexCache(key); !ok {\n\t\t\t\tcache = codexCache{\n\t\t\t\t\tID:     uuid.New().String(),\n\t\t\t\t\tExpire: time.Now().Add(1 * time.Hour),\n\t\t\t\t}\n\t\t\t\tsetCodexCache(key, cache)\n\t\t\t}\n\t\t}\n\t} else if from == \"openai-response\" {\n\t\tpromptCacheKey := gjson.GetBytes(req.Payload, \"prompt_cache_key\")\n\t\tif promptCacheKey.Exists() {\n\t\t\tcache.ID = promptCacheKey.String()\n\t\t}\n\t} else if from == \"openai\" {\n\t\tif apiKey := strings.TrimSpace(apiKeyFromContext(ctx)); apiKey != \"\" {\n\t\t\tcache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte(\"cli-proxy-api:codex:prompt-cache:\"+apiKey)).String()\n\t\t}\n\t}\n\n\tif cache.ID != \"\" {\n\t\trawJSON, _ = sjson.SetBytes(rawJSON, \"prompt_cache_key\", cache.ID)\n\t}\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif cache.ID != \"\" {\n\t\thttpReq.Header.Set(\"Conversation_id\", cache.ID)\n\t\thttpReq.Header.Set(\"Session_id\", cache.ID)\n\t}\n\treturn httpReq, nil\n}\n\nfunc applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\tr.Header.Set(\"Authorization\", \"Bearer \"+token)\n\n\tvar ginHeaders http.Header\n\tif ginCtx, ok := r.Context().Value(\"gin\").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {\n\t\tginHeaders = ginCtx.Request.Header\n\t}\n\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"Version\", codexClientVersion)\n\tmisc.EnsureHeader(r.Header, ginHeaders, \"Session_id\", uuid.NewString())\n\tcfgUserAgent, _ := codexHeaderDefaults(cfg, auth)\n\tensureHeaderWithConfigPrecedence(r.Header, ginHeaders, \"User-Agent\", cfgUserAgent, codexUserAgent)\n\n\tif stream {\n\t\tr.Header.Set(\"Accept\", \"text/event-stream\")\n\t} else {\n\t\tr.Header.Set(\"Accept\", \"application/json\")\n\t}\n\tr.Header.Set(\"Connection\", \"Keep-Alive\")\n\n\tisAPIKey := false\n\tif auth != nil && auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"api_key\"]); v != \"\" {\n\t\t\tisAPIKey = true\n\t\t}\n\t}\n\tif !isAPIKey {\n\t\tr.Header.Set(\"Originator\", \"codex_cli_rs\")\n\t\tif auth != nil && auth.Metadata != nil {\n\t\t\tif accountID, ok := auth.Metadata[\"account_id\"].(string); ok {\n\t\t\t\tr.Header.Set(\"Chatgpt-Account-Id\", accountID)\n\t\t\t}\n\t\t}\n\t}\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(r, attrs)\n}\n\nfunc newCodexStatusErr(statusCode int, body []byte) statusErr {\n\terr := statusErr{code: statusCode, msg: string(body)}\n\tif retryAfter := parseCodexRetryAfter(statusCode, body, time.Now()); retryAfter != nil {\n\t\terr.retryAfter = retryAfter\n\t}\n\treturn err\n}\n\nfunc parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {\n\tif statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {\n\t\treturn nil\n\t}\n\tif strings.TrimSpace(gjson.GetBytes(errorBody, \"error.type\").String()) != \"usage_limit_reached\" {\n\t\treturn nil\n\t}\n\tif resetsAt := gjson.GetBytes(errorBody, \"error.resets_at\").Int(); resetsAt > 0 {\n\t\tresetAtTime := time.Unix(resetsAt, 0)\n\t\tif resetAtTime.After(now) {\n\t\t\tretryAfter := resetAtTime.Sub(now)\n\t\t\treturn &retryAfter\n\t\t}\n\t}\n\tif resetsInSeconds := gjson.GetBytes(errorBody, \"error.resets_in_seconds\").Int(); resetsInSeconds > 0 {\n\t\tretryAfter := time.Duration(resetsInSeconds) * time.Second\n\t\treturn &retryAfter\n\t}\n\treturn nil\n}\n\nfunc codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tapiKey = a.Attributes[\"api_key\"]\n\t\tbaseURL = a.Attributes[\"base_url\"]\n\t}\n\tif apiKey == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok {\n\t\t\tapiKey = v\n\t\t}\n\t}\n\treturn\n}\n\nfunc (e *CodexExecutor) resolveCodexConfig(auth *cliproxyauth.Auth) *config.CodexKey {\n\tif auth == nil || e.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range e.cfg.CodexKey {\n\t\tentry := &e.cfg.CodexKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && attrBase != \"\" {\n\t\t\tif strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range e.cfg.CodexKey {\n\t\t\tentry := &e.cfg.CodexKey[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/runtime/executor/codex_executor_cache_test.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFromAPIKey(t *testing.T) {\n\trecorder := httptest.NewRecorder()\n\tginCtx, _ := gin.CreateTestContext(recorder)\n\tginCtx.Set(\"apiKey\", \"test-api-key\")\n\n\tctx := context.WithValue(context.Background(), \"gin\", ginCtx)\n\texecutor := &CodexExecutor{}\n\trawJSON := []byte(`{\"model\":\"gpt-5.3-codex\",\"stream\":true}`)\n\treq := cliproxyexecutor.Request{\n\t\tModel:   \"gpt-5.3-codex\",\n\t\tPayload: []byte(`{\"model\":\"gpt-5.3-codex\"}`),\n\t}\n\turl := \"https://example.com/responses\"\n\n\thttpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString(\"openai\"), url, req, rawJSON)\n\tif err != nil {\n\t\tt.Fatalf(\"cacheHelper error: %v\", err)\n\t}\n\n\tbody, errRead := io.ReadAll(httpReq.Body)\n\tif errRead != nil {\n\t\tt.Fatalf(\"read request body: %v\", errRead)\n\t}\n\n\texpectedKey := uuid.NewSHA1(uuid.NameSpaceOID, []byte(\"cli-proxy-api:codex:prompt-cache:test-api-key\")).String()\n\tgotKey := gjson.GetBytes(body, \"prompt_cache_key\").String()\n\tif gotKey != expectedKey {\n\t\tt.Fatalf(\"prompt_cache_key = %q, want %q\", gotKey, expectedKey)\n\t}\n\tif gotConversation := httpReq.Header.Get(\"Conversation_id\"); gotConversation != expectedKey {\n\t\tt.Fatalf(\"Conversation_id = %q, want %q\", gotConversation, expectedKey)\n\t}\n\tif gotSession := httpReq.Header.Get(\"Session_id\"); gotSession != expectedKey {\n\t\tt.Fatalf(\"Session_id = %q, want %q\", gotSession, expectedKey)\n\t}\n\n\thttpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString(\"openai\"), url, req, rawJSON)\n\tif err != nil {\n\t\tt.Fatalf(\"cacheHelper error (second call): %v\", err)\n\t}\n\tbody2, errRead2 := io.ReadAll(httpReq2.Body)\n\tif errRead2 != nil {\n\t\tt.Fatalf(\"read request body (second call): %v\", errRead2)\n\t}\n\tgotKey2 := gjson.GetBytes(body2, \"prompt_cache_key\").String()\n\tif gotKey2 != expectedKey {\n\t\tt.Fatalf(\"prompt_cache_key (second call) = %q, want %q\", gotKey2, expectedKey)\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/codex_executor_retry_test.go",
    "content": "package executor\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseCodexRetryAfter(t *testing.T) {\n\tnow := time.Unix(1_700_000_000, 0)\n\n\tt.Run(\"resets_in_seconds\", func(t *testing.T) {\n\t\tbody := []byte(`{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":123}}`)\n\t\tretryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)\n\t\tif retryAfter == nil {\n\t\t\tt.Fatalf(\"expected retryAfter, got nil\")\n\t\t}\n\t\tif *retryAfter != 123*time.Second {\n\t\t\tt.Fatalf(\"retryAfter = %v, want %v\", *retryAfter, 123*time.Second)\n\t\t}\n\t})\n\n\tt.Run(\"prefers resets_at\", func(t *testing.T) {\n\t\tresetAt := now.Add(5 * time.Minute).Unix()\n\t\tbody := []byte(`{\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":` + itoa(resetAt) + `,\"resets_in_seconds\":1}}`)\n\t\tretryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)\n\t\tif retryAfter == nil {\n\t\t\tt.Fatalf(\"expected retryAfter, got nil\")\n\t\t}\n\t\tif *retryAfter != 5*time.Minute {\n\t\t\tt.Fatalf(\"retryAfter = %v, want %v\", *retryAfter, 5*time.Minute)\n\t\t}\n\t})\n\n\tt.Run(\"fallback when resets_at is past\", func(t *testing.T) {\n\t\tresetAt := now.Add(-1 * time.Minute).Unix()\n\t\tbody := []byte(`{\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":` + itoa(resetAt) + `,\"resets_in_seconds\":77}}`)\n\t\tretryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)\n\t\tif retryAfter == nil {\n\t\t\tt.Fatalf(\"expected retryAfter, got nil\")\n\t\t}\n\t\tif *retryAfter != 77*time.Second {\n\t\t\tt.Fatalf(\"retryAfter = %v, want %v\", *retryAfter, 77*time.Second)\n\t\t}\n\t})\n\n\tt.Run(\"non-429 status code\", func(t *testing.T) {\n\t\tbody := []byte(`{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":30}}`)\n\t\tif got := parseCodexRetryAfter(http.StatusBadRequest, body, now); got != nil {\n\t\t\tt.Fatalf(\"expected nil for non-429, got %v\", *got)\n\t\t}\n\t})\n\n\tt.Run(\"non usage_limit_reached error type\", func(t *testing.T) {\n\t\tbody := []byte(`{\"error\":{\"type\":\"server_error\",\"resets_in_seconds\":30}}`)\n\t\tif got := parseCodexRetryAfter(http.StatusTooManyRequests, body, now); got != nil {\n\t\t\tt.Fatalf(\"expected nil for non-usage_limit_reached, got %v\", *got)\n\t\t}\n\t})\n}\n\nfunc itoa(v int64) string {\n\treturn strconv.FormatInt(v, 10)\n}\n"
  },
  {
    "path": "internal/runtime/executor/codex_websockets_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// This file implements a Codex executor that uses the Responses API WebSocket transport.\npackage executor\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n\t\"golang.org/x/net/proxy\"\n)\n\nconst (\n\tcodexResponsesWebsocketBetaHeaderValue = \"responses_websockets=2026-02-06\"\n\tcodexResponsesWebsocketIdleTimeout     = 5 * time.Minute\n\tcodexResponsesWebsocketHandshakeTO     = 30 * time.Second\n)\n\n// CodexWebsocketsExecutor executes Codex Responses requests using a WebSocket transport.\n//\n// It preserves the existing CodexExecutor HTTP implementation as a fallback for endpoints\n// not available over WebSocket (e.g. /responses/compact) and for websocket upgrade failures.\ntype CodexWebsocketsExecutor struct {\n\t*CodexExecutor\n\n\tsessMu   sync.Mutex\n\tsessions map[string]*codexWebsocketSession\n}\n\ntype codexWebsocketSession struct {\n\tsessionID string\n\n\treqMu sync.Mutex\n\n\tconnMu sync.Mutex\n\tconn   *websocket.Conn\n\twsURL  string\n\tauthID string\n\n\twriteMu sync.Mutex\n\n\tactiveMu     sync.Mutex\n\tactiveCh     chan codexWebsocketRead\n\tactiveDone   <-chan struct{}\n\tactiveCancel context.CancelFunc\n\n\treaderConn *websocket.Conn\n}\n\nfunc NewCodexWebsocketsExecutor(cfg *config.Config) *CodexWebsocketsExecutor {\n\treturn &CodexWebsocketsExecutor{\n\t\tCodexExecutor: NewCodexExecutor(cfg),\n\t\tsessions:      make(map[string]*codexWebsocketSession),\n\t}\n}\n\ntype codexWebsocketRead struct {\n\tconn    *websocket.Conn\n\tmsgType int\n\tpayload []byte\n\terr     error\n}\n\nfunc (s *codexWebsocketSession) setActive(ch chan codexWebsocketRead) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.activeMu.Lock()\n\tif s.activeCancel != nil {\n\t\ts.activeCancel()\n\t\ts.activeCancel = nil\n\t\ts.activeDone = nil\n\t}\n\ts.activeCh = ch\n\tif ch != nil {\n\t\tactiveCtx, activeCancel := context.WithCancel(context.Background())\n\t\ts.activeDone = activeCtx.Done()\n\t\ts.activeCancel = activeCancel\n\t}\n\ts.activeMu.Unlock()\n}\n\nfunc (s *codexWebsocketSession) clearActive(ch chan codexWebsocketRead) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.activeMu.Lock()\n\tif s.activeCh == ch {\n\t\ts.activeCh = nil\n\t\tif s.activeCancel != nil {\n\t\t\ts.activeCancel()\n\t\t}\n\t\ts.activeCancel = nil\n\t\ts.activeDone = nil\n\t}\n\ts.activeMu.Unlock()\n}\n\nfunc (s *codexWebsocketSession) writeMessage(conn *websocket.Conn, msgType int, payload []byte) error {\n\tif s == nil {\n\t\treturn fmt.Errorf(\"codex websockets executor: session is nil\")\n\t}\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"codex websockets executor: websocket conn is nil\")\n\t}\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\treturn conn.WriteMessage(msgType, payload)\n}\n\nfunc (s *codexWebsocketSession) configureConn(conn *websocket.Conn) {\n\tif s == nil || conn == nil {\n\t\treturn\n\t}\n\tconn.SetPingHandler(func(appData string) error {\n\t\ts.writeMu.Lock()\n\t\tdefer s.writeMu.Unlock()\n\t\t// Reply pongs from the same write lock to avoid concurrent writes.\n\t\treturn conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(10*time.Second))\n\t})\n}\n\nfunc (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn e.CodexExecutor.executeCompact(ctx, auth, req, opts)\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\tapiKey, baseURL := codexCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://chatgpt.com/backend-api/codex\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"codex\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\tbody, _ = sjson.SetBytes(body, \"stream\", true)\n\tbody, _ = sjson.DeleteBytes(body, \"previous_response_id\")\n\tbody, _ = sjson.DeleteBytes(body, \"prompt_cache_retention\")\n\tbody, _ = sjson.DeleteBytes(body, \"safety_identifier\")\n\tif !gjson.GetBytes(body, \"instructions\").Exists() {\n\t\tbody, _ = sjson.SetBytes(body, \"instructions\", \"\")\n\t}\n\n\thttpURL := strings.TrimSuffix(baseURL, \"/\") + \"/responses\"\n\twsURL, err := buildCodexResponsesWebsocketURL(httpURL)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tbody, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)\n\twsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\n\texecutionSessionID := executionSessionIDFromOptions(opts)\n\tvar sess *codexWebsocketSession\n\tif executionSessionID != \"\" {\n\t\tsess = e.getOrCreateSession(executionSessionID)\n\t\tsess.reqMu.Lock()\n\t\tdefer sess.reqMu.Unlock()\n\t}\n\n\twsReqBody := buildCodexWebsocketRequestBody(body)\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       wsURL,\n\t\tMethod:    \"WEBSOCKET\",\n\t\tHeaders:   wsHeaders.Clone(),\n\t\tBody:      wsReqBody,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\tconn, respHS, errDial := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)\n\tif respHS != nil {\n\t\trecordAPIResponseMetadata(ctx, e.cfg, respHS.StatusCode, respHS.Header.Clone())\n\t}\n\tif errDial != nil {\n\t\tbodyErr := websocketHandshakeBody(respHS)\n\t\tif len(bodyErr) > 0 {\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, bodyErr)\n\t\t}\n\t\tif respHS != nil && respHS.StatusCode == http.StatusUpgradeRequired {\n\t\t\treturn e.CodexExecutor.Execute(ctx, auth, req, opts)\n\t\t}\n\t\tif respHS != nil && respHS.StatusCode > 0 {\n\t\t\treturn resp, statusErr{code: respHS.StatusCode, msg: string(bodyErr)}\n\t\t}\n\t\trecordAPIResponseError(ctx, e.cfg, errDial)\n\t\treturn resp, errDial\n\t}\n\tcloseHTTPResponseBody(respHS, \"codex websockets executor: close handshake response body error\")\n\tif sess == nil {\n\t\tlogCodexWebsocketConnected(executionSessionID, authID, wsURL)\n\t\tdefer func() {\n\t\t\treason := \"completed\"\n\t\t\tif err != nil {\n\t\t\t\treason = \"error\"\n\t\t\t}\n\t\t\tlogCodexWebsocketDisconnected(executionSessionID, authID, wsURL, reason, err)\n\t\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t}\n\n\tvar readCh chan codexWebsocketRead\n\tif sess != nil {\n\t\treadCh = make(chan codexWebsocketRead, 4096)\n\t\tsess.setActive(readCh)\n\t\tdefer sess.clearActive(readCh)\n\t}\n\n\tif errSend := writeCodexWebsocketMessage(sess, conn, wsReqBody); errSend != nil {\n\t\tif sess != nil {\n\t\t\te.invalidateUpstreamConn(sess, conn, \"send_error\", errSend)\n\n\t\t\t// Retry once with a fresh websocket connection. This is mainly to handle\n\t\t\t// upstream closing the socket between sequential requests within the same\n\t\t\t// execution session.\n\t\t\tconnRetry, _, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)\n\t\t\tif errDialRetry == nil && connRetry != nil {\n\t\t\t\twsReqBodyRetry := buildCodexWebsocketRequestBody(body)\n\t\t\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\t\t\tURL:       wsURL,\n\t\t\t\t\tMethod:    \"WEBSOCKET\",\n\t\t\t\t\tHeaders:   wsHeaders.Clone(),\n\t\t\t\t\tBody:      wsReqBodyRetry,\n\t\t\t\t\tProvider:  e.Identifier(),\n\t\t\t\t\tAuthID:    authID,\n\t\t\t\t\tAuthLabel: authLabel,\n\t\t\t\t\tAuthType:  authType,\n\t\t\t\t\tAuthValue: authValue,\n\t\t\t\t})\n\t\t\t\tif errSendRetry := writeCodexWebsocketMessage(sess, connRetry, wsReqBodyRetry); errSendRetry == nil {\n\t\t\t\t\tconn = connRetry\n\t\t\t\t\twsReqBody = wsReqBodyRetry\n\t\t\t\t} else {\n\t\t\t\t\te.invalidateUpstreamConn(sess, connRetry, \"send_error\", errSendRetry)\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errSendRetry)\n\t\t\t\t\treturn resp, errSendRetry\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errDialRetry)\n\t\t\t\treturn resp, errDialRetry\n\t\t\t}\n\t\t} else {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errSend)\n\t\t\treturn resp, errSend\n\t\t}\n\t}\n\n\tfor {\n\t\tif ctx != nil && ctx.Err() != nil {\n\t\t\treturn resp, ctx.Err()\n\t\t}\n\t\tmsgType, payload, errRead := readCodexWebsocketMessage(ctx, sess, conn, readCh)\n\t\tif errRead != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\treturn resp, errRead\n\t\t}\n\t\tif msgType != websocket.TextMessage {\n\t\t\tif msgType == websocket.BinaryMessage {\n\t\t\t\terr = fmt.Errorf(\"codex websockets executor: unexpected binary message\")\n\t\t\t\tif sess != nil {\n\t\t\t\t\te.invalidateUpstreamConn(sess, conn, \"unexpected_binary\", err)\n\t\t\t\t}\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\t\t\treturn resp, err\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tpayload = bytes.TrimSpace(payload)\n\t\tif len(payload) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, payload)\n\n\t\tif wsErr, ok := parseCodexWebsocketError(payload); ok {\n\t\t\tif sess != nil {\n\t\t\t\te.invalidateUpstreamConn(sess, conn, \"upstream_error\", wsErr)\n\t\t\t}\n\t\t\trecordAPIResponseError(ctx, e.cfg, wsErr)\n\t\t\treturn resp, wsErr\n\t\t}\n\n\t\tpayload = normalizeCodexWebsocketCompletion(payload)\n\t\teventType := gjson.GetBytes(payload, \"type\").String()\n\t\tif eventType == \"response.completed\" {\n\t\t\tif detail, ok := parseCodexUsage(payload); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tvar param any\n\t\t\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, payload, &param)\n\t\t\tresp = cliproxyexecutor.Response{Payload: []byte(out)}\n\t\t\treturn resp, nil\n\t\t}\n\t}\n}\n\nfunc (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tlog.Debugf(\"Executing Codex Websockets stream request with auth ID: %s, model: %s\", auth.ID, req.Model)\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusBadRequest, msg: \"streaming not supported for /responses/compact\"}\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\tapiKey, baseURL := codexCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://chatgpt.com/backend-api/codex\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"codex\")\n\tbody := req.Payload\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, body, requestedModel)\n\n\thttpURL := strings.TrimSuffix(baseURL, \"/\") + \"/responses\"\n\twsURL, err := buildCodexResponsesWebsocketURL(httpURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)\n\twsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)\n\n\tvar authID, authLabel, authType, authValue string\n\tauthID = auth.ID\n\tauthLabel = auth.Label\n\tauthType, authValue = auth.AccountInfo()\n\n\texecutionSessionID := executionSessionIDFromOptions(opts)\n\tvar sess *codexWebsocketSession\n\tif executionSessionID != \"\" {\n\t\tsess = e.getOrCreateSession(executionSessionID)\n\t\tif sess != nil {\n\t\t\tsess.reqMu.Lock()\n\t\t}\n\t}\n\n\twsReqBody := buildCodexWebsocketRequestBody(body)\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       wsURL,\n\t\tMethod:    \"WEBSOCKET\",\n\t\tHeaders:   wsHeaders.Clone(),\n\t\tBody:      wsReqBody,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\tconn, respHS, errDial := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)\n\tvar upstreamHeaders http.Header\n\tif respHS != nil {\n\t\tupstreamHeaders = respHS.Header.Clone()\n\t\trecordAPIResponseMetadata(ctx, e.cfg, respHS.StatusCode, respHS.Header.Clone())\n\t}\n\tif errDial != nil {\n\t\tbodyErr := websocketHandshakeBody(respHS)\n\t\tif len(bodyErr) > 0 {\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, bodyErr)\n\t\t}\n\t\tif respHS != nil && respHS.StatusCode == http.StatusUpgradeRequired {\n\t\t\treturn e.CodexExecutor.ExecuteStream(ctx, auth, req, opts)\n\t\t}\n\t\tif respHS != nil && respHS.StatusCode > 0 {\n\t\t\treturn nil, statusErr{code: respHS.StatusCode, msg: string(bodyErr)}\n\t\t}\n\t\trecordAPIResponseError(ctx, e.cfg, errDial)\n\t\tif sess != nil {\n\t\t\tsess.reqMu.Unlock()\n\t\t}\n\t\treturn nil, errDial\n\t}\n\tcloseHTTPResponseBody(respHS, \"codex websockets executor: close handshake response body error\")\n\n\tif sess == nil {\n\t\tlogCodexWebsocketConnected(executionSessionID, authID, wsURL)\n\t}\n\n\tvar readCh chan codexWebsocketRead\n\tif sess != nil {\n\t\treadCh = make(chan codexWebsocketRead, 4096)\n\t\tsess.setActive(readCh)\n\t}\n\n\tif errSend := writeCodexWebsocketMessage(sess, conn, wsReqBody); errSend != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errSend)\n\t\tif sess != nil {\n\t\t\te.invalidateUpstreamConn(sess, conn, \"send_error\", errSend)\n\n\t\t\t// Retry once with a new websocket connection for the same execution session.\n\t\t\tconnRetry, _, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)\n\t\t\tif errDialRetry != nil || connRetry == nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errDialRetry)\n\t\t\t\tsess.clearActive(readCh)\n\t\t\t\tsess.reqMu.Unlock()\n\t\t\t\treturn nil, errDialRetry\n\t\t\t}\n\t\t\twsReqBodyRetry := buildCodexWebsocketRequestBody(body)\n\t\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\t\tURL:       wsURL,\n\t\t\t\tMethod:    \"WEBSOCKET\",\n\t\t\t\tHeaders:   wsHeaders.Clone(),\n\t\t\t\tBody:      wsReqBodyRetry,\n\t\t\t\tProvider:  e.Identifier(),\n\t\t\t\tAuthID:    authID,\n\t\t\t\tAuthLabel: authLabel,\n\t\t\t\tAuthType:  authType,\n\t\t\t\tAuthValue: authValue,\n\t\t\t})\n\t\t\tif errSendRetry := writeCodexWebsocketMessage(sess, connRetry, wsReqBodyRetry); errSendRetry != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errSendRetry)\n\t\t\t\te.invalidateUpstreamConn(sess, connRetry, \"send_error\", errSendRetry)\n\t\t\t\tsess.clearActive(readCh)\n\t\t\t\tsess.reqMu.Unlock()\n\t\t\t\treturn nil, errSendRetry\n\t\t\t}\n\t\t\tconn = connRetry\n\t\t\twsReqBody = wsReqBodyRetry\n\t\t} else {\n\t\t\tlogCodexWebsocketDisconnected(executionSessionID, authID, wsURL, \"send_error\", errSend)\n\t\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t\t\t}\n\t\t\treturn nil, errSend\n\t\t}\n\t}\n\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tterminateReason := \"completed\"\n\t\tvar terminateErr error\n\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif sess != nil {\n\t\t\t\tsess.clearActive(readCh)\n\t\t\t\tsess.reqMu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogCodexWebsocketDisconnected(executionSessionID, authID, wsURL, terminateReason, terminateErr)\n\t\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\n\t\tsend := func(chunk cliproxyexecutor.StreamChunk) bool {\n\t\t\tif ctx == nil {\n\t\t\t\tout <- chunk\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase out <- chunk:\n\t\t\t\treturn true\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\n\t\tvar param any\n\t\tfor {\n\t\t\tif ctx != nil && ctx.Err() != nil {\n\t\t\t\tterminateReason = \"context_done\"\n\t\t\t\tterminateErr = ctx.Err()\n\t\t\t\t_ = send(cliproxyexecutor.StreamChunk{Err: ctx.Err()})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tmsgType, payload, errRead := readCodexWebsocketMessage(ctx, sess, conn, readCh)\n\t\t\tif errRead != nil {\n\t\t\t\tif sess != nil && ctx != nil && ctx.Err() != nil {\n\t\t\t\t\tterminateReason = \"context_done\"\n\t\t\t\t\tterminateErr = ctx.Err()\n\t\t\t\t\t_ = send(cliproxyexecutor.StreamChunk{Err: ctx.Err()})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tterminateReason = \"read_error\"\n\t\t\t\tterminateErr = errRead\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\t_ = send(cliproxyexecutor.StreamChunk{Err: errRead})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif msgType != websocket.TextMessage {\n\t\t\t\tif msgType == websocket.BinaryMessage {\n\t\t\t\t\terr = fmt.Errorf(\"codex websockets executor: unexpected binary message\")\n\t\t\t\t\tterminateReason = \"unexpected_binary\"\n\t\t\t\t\tterminateErr = err\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\t\tif sess != nil {\n\t\t\t\t\t\te.invalidateUpstreamConn(sess, conn, \"unexpected_binary\", err)\n\t\t\t\t\t}\n\t\t\t\t\t_ = send(cliproxyexecutor.StreamChunk{Err: err})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpayload = bytes.TrimSpace(payload)\n\t\t\tif len(payload) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, payload)\n\n\t\t\tif wsErr, ok := parseCodexWebsocketError(payload); ok {\n\t\t\t\tterminateReason = \"upstream_error\"\n\t\t\t\tterminateErr = wsErr\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, wsErr)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\tif sess != nil {\n\t\t\t\t\te.invalidateUpstreamConn(sess, conn, \"upstream_error\", wsErr)\n\t\t\t\t}\n\t\t\t\t_ = send(cliproxyexecutor.StreamChunk{Err: wsErr})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tpayload = normalizeCodexWebsocketCompletion(payload)\n\t\t\teventType := gjson.GetBytes(payload, \"type\").String()\n\t\t\tif eventType == \"response.completed\" || eventType == \"response.done\" {\n\t\t\t\tif detail, ok := parseCodexUsage(payload); ok {\n\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tline := encodeCodexWebsocketAsSSE(payload)\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, body, body, line, &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tif !send(cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}) {\n\t\t\t\t\tterminateReason = \"context_done\"\n\t\t\t\t\tterminateErr = ctx.Err()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tif eventType == \"response.completed\" || eventType == \"response.done\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn &cliproxyexecutor.StreamResult{Headers: upstreamHeaders, Chunks: out}, nil\n}\n\nfunc (e *CodexWebsocketsExecutor) dialCodexWebsocket(ctx context.Context, auth *cliproxyauth.Auth, wsURL string, headers http.Header) (*websocket.Conn, *http.Response, error) {\n\tdialer := newProxyAwareWebsocketDialer(e.cfg, auth)\n\tdialer.HandshakeTimeout = codexResponsesWebsocketHandshakeTO\n\tdialer.EnableCompression = true\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tconn, resp, err := dialer.DialContext(ctx, wsURL, headers)\n\tif conn != nil {\n\t\t// Avoid gorilla/websocket flate tail validation issues on some upstreams/Go versions.\n\t\t// Negotiating permessage-deflate is fine; we just don't compress outbound messages.\n\t\tconn.EnableWriteCompression(false)\n\t}\n\treturn conn, resp, err\n}\n\nfunc writeCodexWebsocketMessage(sess *codexWebsocketSession, conn *websocket.Conn, payload []byte) error {\n\tif sess != nil {\n\t\treturn sess.writeMessage(conn, websocket.TextMessage, payload)\n\t}\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"codex websockets executor: websocket conn is nil\")\n\t}\n\treturn conn.WriteMessage(websocket.TextMessage, payload)\n}\n\nfunc buildCodexWebsocketRequestBody(body []byte) []byte {\n\tif len(body) == 0 {\n\t\treturn nil\n\t}\n\n\t// Match codex-rs websocket v2 semantics: every request is `response.create`.\n\t// Incremental follow-up turns continue on the same websocket using\n\t// `previous_response_id` + incremental `input`, not `response.append`.\n\twsReqBody, errSet := sjson.SetBytes(bytes.Clone(body), \"type\", \"response.create\")\n\tif errSet == nil && len(wsReqBody) > 0 {\n\t\treturn wsReqBody\n\t}\n\tfallback := bytes.Clone(body)\n\tfallback, _ = sjson.SetBytes(fallback, \"type\", \"response.create\")\n\treturn fallback\n}\n\nfunc readCodexWebsocketMessage(ctx context.Context, sess *codexWebsocketSession, conn *websocket.Conn, readCh chan codexWebsocketRead) (int, []byte, error) {\n\tif sess == nil {\n\t\tif conn == nil {\n\t\t\treturn 0, nil, fmt.Errorf(\"codex websockets executor: websocket conn is nil\")\n\t\t}\n\t\t_ = conn.SetReadDeadline(time.Now().Add(codexResponsesWebsocketIdleTimeout))\n\t\tmsgType, payload, errRead := conn.ReadMessage()\n\t\treturn msgType, payload, errRead\n\t}\n\tif conn == nil {\n\t\treturn 0, nil, fmt.Errorf(\"codex websockets executor: websocket conn is nil\")\n\t}\n\tif readCh == nil {\n\t\treturn 0, nil, fmt.Errorf(\"codex websockets executor: session read channel is nil\")\n\t}\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn 0, nil, ctx.Err()\n\t\tcase ev, ok := <-readCh:\n\t\t\tif !ok {\n\t\t\t\treturn 0, nil, fmt.Errorf(\"codex websockets executor: session read channel closed\")\n\t\t\t}\n\t\t\tif ev.conn != conn {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif ev.err != nil {\n\t\t\t\treturn 0, nil, ev.err\n\t\t\t}\n\t\t\treturn ev.msgType, ev.payload, nil\n\t\t}\n\t}\n}\n\nfunc newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *websocket.Dialer {\n\tdialer := &websocket.Dialer{\n\t\tProxy:             http.ProxyFromEnvironment,\n\t\tHandshakeTimeout:  codexResponsesWebsocketHandshakeTO,\n\t\tEnableCompression: true,\n\t\tNetDialContext: (&net.Dialer{\n\t\t\tTimeout:   30 * time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t}).DialContext,\n\t}\n\n\tproxyURL := \"\"\n\tif auth != nil {\n\t\tproxyURL = strings.TrimSpace(auth.ProxyURL)\n\t}\n\tif proxyURL == \"\" && cfg != nil {\n\t\tproxyURL = strings.TrimSpace(cfg.ProxyURL)\n\t}\n\tif proxyURL == \"\" {\n\t\treturn dialer\n\t}\n\n\tsetting, errParse := proxyutil.Parse(proxyURL)\n\tif errParse != nil {\n\t\tlog.Errorf(\"codex websockets executor: %v\", errParse)\n\t\treturn dialer\n\t}\n\n\tswitch setting.Mode {\n\tcase proxyutil.ModeDirect:\n\t\tdialer.Proxy = nil\n\t\treturn dialer\n\tcase proxyutil.ModeProxy:\n\tdefault:\n\t\treturn dialer\n\t}\n\n\tswitch setting.URL.Scheme {\n\tcase \"socks5\":\n\t\tvar proxyAuth *proxy.Auth\n\t\tif setting.URL.User != nil {\n\t\t\tusername := setting.URL.User.Username()\n\t\t\tpassword, _ := setting.URL.User.Password()\n\t\t\tproxyAuth = &proxy.Auth{User: username, Password: password}\n\t\t}\n\t\tsocksDialer, errSOCKS5 := proxy.SOCKS5(\"tcp\", setting.URL.Host, proxyAuth, proxy.Direct)\n\t\tif errSOCKS5 != nil {\n\t\t\tlog.Errorf(\"codex websockets executor: create SOCKS5 dialer failed: %v\", errSOCKS5)\n\t\t\treturn dialer\n\t\t}\n\t\tdialer.Proxy = nil\n\t\tdialer.NetDialContext = func(_ context.Context, network, addr string) (net.Conn, error) {\n\t\t\treturn socksDialer.Dial(network, addr)\n\t\t}\n\tcase \"http\", \"https\":\n\t\tdialer.Proxy = http.ProxyURL(setting.URL)\n\tdefault:\n\t\tlog.Errorf(\"codex websockets executor: unsupported proxy scheme: %s\", setting.URL.Scheme)\n\t}\n\n\treturn dialer\n}\n\nfunc buildCodexResponsesWebsocketURL(httpURL string) (string, error) {\n\tparsed, err := url.Parse(strings.TrimSpace(httpURL))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tswitch strings.ToLower(parsed.Scheme) {\n\tcase \"http\":\n\t\tparsed.Scheme = \"ws\"\n\tcase \"https\":\n\t\tparsed.Scheme = \"wss\"\n\t}\n\treturn parsed.String(), nil\n}\n\nfunc applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header) {\n\theaders := http.Header{}\n\tif len(rawJSON) == 0 {\n\t\treturn rawJSON, headers\n\t}\n\n\tvar cache codexCache\n\tif from == \"claude\" {\n\t\tuserIDResult := gjson.GetBytes(req.Payload, \"metadata.user_id\")\n\t\tif userIDResult.Exists() {\n\t\t\tkey := fmt.Sprintf(\"%s-%s\", req.Model, userIDResult.String())\n\t\t\tif cached, ok := getCodexCache(key); ok {\n\t\t\t\tcache = cached\n\t\t\t} else {\n\t\t\t\tcache = codexCache{\n\t\t\t\t\tID:     uuid.New().String(),\n\t\t\t\t\tExpire: time.Now().Add(1 * time.Hour),\n\t\t\t\t}\n\t\t\t\tsetCodexCache(key, cache)\n\t\t\t}\n\t\t}\n\t} else if from == \"openai-response\" {\n\t\tif promptCacheKey := gjson.GetBytes(req.Payload, \"prompt_cache_key\"); promptCacheKey.Exists() {\n\t\t\tcache.ID = promptCacheKey.String()\n\t\t}\n\t}\n\n\tif cache.ID != \"\" {\n\t\trawJSON, _ = sjson.SetBytes(rawJSON, \"prompt_cache_key\", cache.ID)\n\t\theaders.Set(\"Conversation_id\", cache.ID)\n\t\theaders.Set(\"Session_id\", cache.ID)\n\t}\n\n\treturn rawJSON, headers\n}\n\nfunc applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {\n\tif headers == nil {\n\t\theaders = http.Header{}\n\t}\n\tif strings.TrimSpace(token) != \"\" {\n\t\theaders.Set(\"Authorization\", \"Bearer \"+token)\n\t}\n\n\tvar ginHeaders http.Header\n\tif ginCtx := ginContextFrom(ctx); ginCtx != nil && ginCtx.Request != nil {\n\t\tginHeaders = ginCtx.Request.Header\n\t}\n\n\tcfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth)\n\tensureHeaderWithPriority(headers, ginHeaders, \"x-codex-beta-features\", cfgBetaFeatures, \"\")\n\tmisc.EnsureHeader(headers, ginHeaders, \"x-codex-turn-state\", \"\")\n\tmisc.EnsureHeader(headers, ginHeaders, \"x-codex-turn-metadata\", \"\")\n\tmisc.EnsureHeader(headers, ginHeaders, \"x-responsesapi-include-timing-metrics\", \"\")\n\n\tmisc.EnsureHeader(headers, ginHeaders, \"Version\", codexClientVersion)\n\tbetaHeader := strings.TrimSpace(headers.Get(\"OpenAI-Beta\"))\n\tif betaHeader == \"\" && ginHeaders != nil {\n\t\tbetaHeader = strings.TrimSpace(ginHeaders.Get(\"OpenAI-Beta\"))\n\t}\n\tif betaHeader == \"\" || !strings.Contains(betaHeader, \"responses_websockets=\") {\n\t\tbetaHeader = codexResponsesWebsocketBetaHeaderValue\n\t}\n\theaders.Set(\"OpenAI-Beta\", betaHeader)\n\tmisc.EnsureHeader(headers, ginHeaders, \"Session_id\", uuid.NewString())\n\tensureHeaderWithConfigPrecedence(headers, ginHeaders, \"User-Agent\", cfgUserAgent, codexUserAgent)\n\n\tisAPIKey := false\n\tif auth != nil && auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"api_key\"]); v != \"\" {\n\t\t\tisAPIKey = true\n\t\t}\n\t}\n\tif !isAPIKey {\n\t\theaders.Set(\"Originator\", \"codex_cli_rs\")\n\t\tif auth != nil && auth.Metadata != nil {\n\t\t\tif accountID, ok := auth.Metadata[\"account_id\"].(string); ok {\n\t\t\t\tif trimmed := strings.TrimSpace(accountID); trimmed != \"\" {\n\t\t\t\t\theaders.Set(\"Chatgpt-Account-Id\", trimmed)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)\n\n\treturn headers\n}\n\nfunc codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) {\n\tif cfg == nil || auth == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"api_key\"]); v != \"\" {\n\t\t\treturn \"\", \"\"\n\t\t}\n\t}\n\treturn strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent), strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures)\n}\n\nfunc ensureHeaderWithPriority(target http.Header, source http.Header, key, configValue, fallbackValue string) {\n\tif target == nil {\n\t\treturn\n\t}\n\tif strings.TrimSpace(target.Get(key)) != \"\" {\n\t\treturn\n\t}\n\tif source != nil {\n\t\tif val := strings.TrimSpace(source.Get(key)); val != \"\" {\n\t\t\ttarget.Set(key, val)\n\t\t\treturn\n\t\t}\n\t}\n\tif val := strings.TrimSpace(configValue); val != \"\" {\n\t\ttarget.Set(key, val)\n\t\treturn\n\t}\n\tif val := strings.TrimSpace(fallbackValue); val != \"\" {\n\t\ttarget.Set(key, val)\n\t}\n}\n\nfunc ensureHeaderWithConfigPrecedence(target http.Header, source http.Header, key, configValue, fallbackValue string) {\n\tif target == nil {\n\t\treturn\n\t}\n\tif strings.TrimSpace(target.Get(key)) != \"\" {\n\t\treturn\n\t}\n\tif val := strings.TrimSpace(configValue); val != \"\" {\n\t\ttarget.Set(key, val)\n\t\treturn\n\t}\n\tif source != nil {\n\t\tif val := strings.TrimSpace(source.Get(key)); val != \"\" {\n\t\t\ttarget.Set(key, val)\n\t\t\treturn\n\t\t}\n\t}\n\tif val := strings.TrimSpace(fallbackValue); val != \"\" {\n\t\ttarget.Set(key, val)\n\t}\n}\n\ntype statusErrWithHeaders struct {\n\tstatusErr\n\theaders http.Header\n}\n\nfunc (e statusErrWithHeaders) Headers() http.Header {\n\tif e.headers == nil {\n\t\treturn nil\n\t}\n\treturn e.headers.Clone()\n}\n\nfunc parseCodexWebsocketError(payload []byte) (error, bool) {\n\tif len(payload) == 0 {\n\t\treturn nil, false\n\t}\n\tif strings.TrimSpace(gjson.GetBytes(payload, \"type\").String()) != \"error\" {\n\t\treturn nil, false\n\t}\n\tstatus := int(gjson.GetBytes(payload, \"status\").Int())\n\tif status == 0 {\n\t\tstatus = int(gjson.GetBytes(payload, \"status_code\").Int())\n\t}\n\tif status <= 0 {\n\t\treturn nil, false\n\t}\n\n\tout := []byte(`{}`)\n\tif errNode := gjson.GetBytes(payload, \"error\"); errNode.Exists() {\n\t\traw := errNode.Raw\n\t\tif errNode.Type == gjson.String {\n\t\t\traw = errNode.Raw\n\t\t}\n\t\tout, _ = sjson.SetRawBytes(out, \"error\", []byte(raw))\n\t} else {\n\t\tout, _ = sjson.SetBytes(out, \"error.type\", \"server_error\")\n\t\tout, _ = sjson.SetBytes(out, \"error.message\", http.StatusText(status))\n\t}\n\n\theaders := parseCodexWebsocketErrorHeaders(payload)\n\treturn statusErrWithHeaders{\n\t\tstatusErr: statusErr{code: status, msg: string(out)},\n\t\theaders:   headers,\n\t}, true\n}\n\nfunc parseCodexWebsocketErrorHeaders(payload []byte) http.Header {\n\theadersNode := gjson.GetBytes(payload, \"headers\")\n\tif !headersNode.Exists() || !headersNode.IsObject() {\n\t\treturn nil\n\t}\n\tmapped := make(http.Header)\n\theadersNode.ForEach(func(key, value gjson.Result) bool {\n\t\tname := strings.TrimSpace(key.String())\n\t\tif name == \"\" {\n\t\t\treturn true\n\t\t}\n\t\tswitch value.Type {\n\t\tcase gjson.String:\n\t\t\tif v := strings.TrimSpace(value.String()); v != \"\" {\n\t\t\t\tmapped.Set(name, v)\n\t\t\t}\n\t\tcase gjson.Number, gjson.True, gjson.False:\n\t\t\tif v := strings.TrimSpace(value.Raw); v != \"\" {\n\t\t\t\tmapped.Set(name, v)\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t\treturn true\n\t})\n\tif len(mapped) == 0 {\n\t\treturn nil\n\t}\n\treturn mapped\n}\n\nfunc normalizeCodexWebsocketCompletion(payload []byte) []byte {\n\tif strings.TrimSpace(gjson.GetBytes(payload, \"type\").String()) == \"response.done\" {\n\t\tupdated, err := sjson.SetBytes(payload, \"type\", \"response.completed\")\n\t\tif err == nil && len(updated) > 0 {\n\t\t\treturn updated\n\t\t}\n\t}\n\treturn payload\n}\n\nfunc encodeCodexWebsocketAsSSE(payload []byte) []byte {\n\tif len(payload) == 0 {\n\t\treturn nil\n\t}\n\tline := make([]byte, 0, len(\"data: \")+len(payload))\n\tline = append(line, []byte(\"data: \")...)\n\tline = append(line, payload...)\n\treturn line\n}\n\nfunc websocketHandshakeBody(resp *http.Response) []byte {\n\tif resp == nil || resp.Body == nil {\n\t\treturn nil\n\t}\n\tbody, _ := io.ReadAll(resp.Body)\n\tcloseHTTPResponseBody(resp, \"codex websockets executor: close handshake response body error\")\n\tif len(body) == 0 {\n\t\treturn nil\n\t}\n\treturn body\n}\n\nfunc closeHTTPResponseBody(resp *http.Response, logPrefix string) {\n\tif resp == nil || resp.Body == nil {\n\t\treturn\n\t}\n\tif errClose := resp.Body.Close(); errClose != nil {\n\t\tlog.Errorf(\"%s: %v\", logPrefix, errClose)\n\t}\n}\n\nfunc executionSessionIDFromOptions(opts cliproxyexecutor.Options) string {\n\tif len(opts.Metadata) == 0 {\n\t\treturn \"\"\n\t}\n\traw, ok := opts.Metadata[cliproxyexecutor.ExecutionSessionMetadataKey]\n\tif !ok || raw == nil {\n\t\treturn \"\"\n\t}\n\tswitch v := raw.(type) {\n\tcase string:\n\t\treturn strings.TrimSpace(v)\n\tcase []byte:\n\t\treturn strings.TrimSpace(string(v))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (e *CodexWebsocketsExecutor) getOrCreateSession(sessionID string) *codexWebsocketSession {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn nil\n\t}\n\te.sessMu.Lock()\n\tdefer e.sessMu.Unlock()\n\tif e.sessions == nil {\n\t\te.sessions = make(map[string]*codexWebsocketSession)\n\t}\n\tif sess, ok := e.sessions[sessionID]; ok && sess != nil {\n\t\treturn sess\n\t}\n\tsess := &codexWebsocketSession{sessionID: sessionID}\n\te.sessions[sessionID] = sess\n\treturn sess\n}\n\nfunc (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID string, wsURL string, headers http.Header) (*websocket.Conn, *http.Response, error) {\n\tif sess == nil {\n\t\treturn e.dialCodexWebsocket(ctx, auth, wsURL, headers)\n\t}\n\n\tsess.connMu.Lock()\n\tconn := sess.conn\n\treaderConn := sess.readerConn\n\tsess.connMu.Unlock()\n\tif conn != nil {\n\t\tif readerConn != conn {\n\t\t\tsess.connMu.Lock()\n\t\t\tsess.readerConn = conn\n\t\t\tsess.connMu.Unlock()\n\t\t\tsess.configureConn(conn)\n\t\t\tgo e.readUpstreamLoop(sess, conn)\n\t\t}\n\t\treturn conn, nil, nil\n\t}\n\n\tconn, resp, errDial := e.dialCodexWebsocket(ctx, auth, wsURL, headers)\n\tif errDial != nil {\n\t\treturn nil, resp, errDial\n\t}\n\n\tsess.connMu.Lock()\n\tif sess.conn != nil {\n\t\tprevious := sess.conn\n\t\tsess.connMu.Unlock()\n\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t\t}\n\t\treturn previous, nil, nil\n\t}\n\tsess.conn = conn\n\tsess.wsURL = wsURL\n\tsess.authID = authID\n\tsess.readerConn = conn\n\tsess.connMu.Unlock()\n\n\tsess.configureConn(conn)\n\tgo e.readUpstreamLoop(sess, conn)\n\tlogCodexWebsocketConnected(sess.sessionID, authID, wsURL)\n\treturn conn, resp, nil\n}\n\nfunc (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession, conn *websocket.Conn) {\n\tif e == nil || sess == nil || conn == nil {\n\t\treturn\n\t}\n\tfor {\n\t\t_ = conn.SetReadDeadline(time.Now().Add(codexResponsesWebsocketIdleTimeout))\n\t\tmsgType, payload, errRead := conn.ReadMessage()\n\t\tif errRead != nil {\n\t\t\tsess.activeMu.Lock()\n\t\t\tch := sess.activeCh\n\t\t\tdone := sess.activeDone\n\t\t\tsess.activeMu.Unlock()\n\t\t\tif ch != nil {\n\t\t\t\tselect {\n\t\t\t\tcase ch <- codexWebsocketRead{conn: conn, err: errRead}:\n\t\t\t\tcase <-done:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\tsess.clearActive(ch)\n\t\t\t\tclose(ch)\n\t\t\t}\n\t\t\te.invalidateUpstreamConn(sess, conn, \"upstream_disconnected\", errRead)\n\t\t\treturn\n\t\t}\n\n\t\tif msgType != websocket.TextMessage {\n\t\t\tif msgType == websocket.BinaryMessage {\n\t\t\t\terrBinary := fmt.Errorf(\"codex websockets executor: unexpected binary message\")\n\t\t\t\tsess.activeMu.Lock()\n\t\t\t\tch := sess.activeCh\n\t\t\t\tdone := sess.activeDone\n\t\t\t\tsess.activeMu.Unlock()\n\t\t\t\tif ch != nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase ch <- codexWebsocketRead{conn: conn, err: errBinary}:\n\t\t\t\t\tcase <-done:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t\tsess.clearActive(ch)\n\t\t\t\t\tclose(ch)\n\t\t\t\t}\n\t\t\t\te.invalidateUpstreamConn(sess, conn, \"unexpected_binary\", errBinary)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tsess.activeMu.Lock()\n\t\tch := sess.activeCh\n\t\tdone := sess.activeDone\n\t\tsess.activeMu.Unlock()\n\t\tif ch == nil {\n\t\t\tcontinue\n\t\t}\n\t\tselect {\n\t\tcase ch <- codexWebsocketRead{conn: conn, msgType: msgType, payload: payload}:\n\t\tcase <-done:\n\t\t}\n\t}\n}\n\nfunc (e *CodexWebsocketsExecutor) invalidateUpstreamConn(sess *codexWebsocketSession, conn *websocket.Conn, reason string, err error) {\n\tif sess == nil || conn == nil {\n\t\treturn\n\t}\n\n\tsess.connMu.Lock()\n\tcurrent := sess.conn\n\tauthID := sess.authID\n\twsURL := sess.wsURL\n\tsessionID := sess.sessionID\n\tif current == nil || current != conn {\n\t\tsess.connMu.Unlock()\n\t\treturn\n\t}\n\tsess.conn = nil\n\tif sess.readerConn == conn {\n\t\tsess.readerConn = nil\n\t}\n\tsess.connMu.Unlock()\n\n\tlogCodexWebsocketDisconnected(sessionID, authID, wsURL, reason, err)\n\tif errClose := conn.Close(); errClose != nil {\n\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t}\n}\n\nfunc (e *CodexWebsocketsExecutor) CloseExecutionSession(sessionID string) {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif e == nil {\n\t\treturn\n\t}\n\tif sessionID == \"\" {\n\t\treturn\n\t}\n\tif sessionID == cliproxyauth.CloseAllExecutionSessionsID {\n\t\te.closeAllExecutionSessions(\"executor_replaced\")\n\t\treturn\n\t}\n\n\te.sessMu.Lock()\n\tsess := e.sessions[sessionID]\n\tdelete(e.sessions, sessionID)\n\te.sessMu.Unlock()\n\n\te.closeExecutionSession(sess, \"session_closed\")\n}\n\nfunc (e *CodexWebsocketsExecutor) closeAllExecutionSessions(reason string) {\n\tif e == nil {\n\t\treturn\n\t}\n\n\te.sessMu.Lock()\n\tsessions := make([]*codexWebsocketSession, 0, len(e.sessions))\n\tfor sessionID, sess := range e.sessions {\n\t\tdelete(e.sessions, sessionID)\n\t\tif sess != nil {\n\t\t\tsessions = append(sessions, sess)\n\t\t}\n\t}\n\te.sessMu.Unlock()\n\n\tfor i := range sessions {\n\t\te.closeExecutionSession(sessions[i], reason)\n\t}\n}\n\nfunc (e *CodexWebsocketsExecutor) closeExecutionSession(sess *codexWebsocketSession, reason string) {\n\tif sess == nil {\n\t\treturn\n\t}\n\treason = strings.TrimSpace(reason)\n\tif reason == \"\" {\n\t\treason = \"session_closed\"\n\t}\n\n\tsess.connMu.Lock()\n\tconn := sess.conn\n\tauthID := sess.authID\n\twsURL := sess.wsURL\n\tsess.conn = nil\n\tif sess.readerConn == conn {\n\t\tsess.readerConn = nil\n\t}\n\tsessionID := sess.sessionID\n\tsess.connMu.Unlock()\n\n\tif conn == nil {\n\t\treturn\n\t}\n\tlogCodexWebsocketDisconnected(sessionID, authID, wsURL, reason, nil)\n\tif errClose := conn.Close(); errClose != nil {\n\t\tlog.Errorf(\"codex websockets executor: close websocket error: %v\", errClose)\n\t}\n}\n\nfunc logCodexWebsocketConnected(sessionID string, authID string, wsURL string) {\n\tlog.Infof(\"codex websockets: upstream connected session=%s auth=%s url=%s\", strings.TrimSpace(sessionID), strings.TrimSpace(authID), strings.TrimSpace(wsURL))\n}\n\nfunc logCodexWebsocketDisconnected(sessionID string, authID string, wsURL string, reason string, err error) {\n\tif err != nil {\n\t\tlog.Infof(\"codex websockets: upstream disconnected session=%s auth=%s url=%s reason=%s err=%v\", strings.TrimSpace(sessionID), strings.TrimSpace(authID), strings.TrimSpace(wsURL), strings.TrimSpace(reason), err)\n\t\treturn\n\t}\n\tlog.Infof(\"codex websockets: upstream disconnected session=%s auth=%s url=%s reason=%s\", strings.TrimSpace(sessionID), strings.TrimSpace(authID), strings.TrimSpace(wsURL), strings.TrimSpace(reason))\n}\n\n// CodexAutoExecutor routes Codex requests to the websocket transport only when:\n//  1. The downstream transport is websocket, and\n//  2. The selected auth enables websockets.\n//\n// For non-websocket downstream requests, it always uses the legacy HTTP implementation.\ntype CodexAutoExecutor struct {\n\thttpExec *CodexExecutor\n\twsExec   *CodexWebsocketsExecutor\n}\n\nfunc NewCodexAutoExecutor(cfg *config.Config) *CodexAutoExecutor {\n\treturn &CodexAutoExecutor{\n\t\thttpExec: NewCodexExecutor(cfg),\n\t\twsExec:   NewCodexWebsocketsExecutor(cfg),\n\t}\n}\n\nfunc (e *CodexAutoExecutor) Identifier() string { return \"codex\" }\n\nfunc (e *CodexAutoExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif e == nil || e.httpExec == nil {\n\t\treturn nil\n\t}\n\treturn e.httpExec.PrepareRequest(req, auth)\n}\n\nfunc (e *CodexAutoExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif e == nil || e.httpExec == nil {\n\t\treturn nil, fmt.Errorf(\"codex auto executor: http executor is nil\")\n\t}\n\treturn e.httpExec.HttpRequest(ctx, auth, req)\n}\n\nfunc (e *CodexAutoExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tif e == nil || e.httpExec == nil || e.wsExec == nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"codex auto executor: executor is nil\")\n\t}\n\tif cliproxyexecutor.DownstreamWebsocket(ctx) && codexWebsocketsEnabled(auth) {\n\t\treturn e.wsExec.Execute(ctx, auth, req, opts)\n\t}\n\treturn e.httpExec.Execute(ctx, auth, req, opts)\n}\n\nfunc (e *CodexAutoExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\tif e == nil || e.httpExec == nil || e.wsExec == nil {\n\t\treturn nil, fmt.Errorf(\"codex auto executor: executor is nil\")\n\t}\n\tif cliproxyexecutor.DownstreamWebsocket(ctx) && codexWebsocketsEnabled(auth) {\n\t\treturn e.wsExec.ExecuteStream(ctx, auth, req, opts)\n\t}\n\treturn e.httpExec.ExecuteStream(ctx, auth, req, opts)\n}\n\nfunc (e *CodexAutoExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tif e == nil || e.httpExec == nil {\n\t\treturn nil, fmt.Errorf(\"codex auto executor: http executor is nil\")\n\t}\n\treturn e.httpExec.Refresh(ctx, auth)\n}\n\nfunc (e *CodexAutoExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tif e == nil || e.httpExec == nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"codex auto executor: http executor is nil\")\n\t}\n\treturn e.httpExec.CountTokens(ctx, auth, req, opts)\n}\n\nfunc (e *CodexAutoExecutor) CloseExecutionSession(sessionID string) {\n\tif e == nil || e.wsExec == nil {\n\t\treturn\n\t}\n\te.wsExec.CloseExecutionSession(sessionID)\n}\n\nfunc codexWebsocketsEnabled(auth *cliproxyauth.Auth) bool {\n\tif auth == nil {\n\t\treturn false\n\t}\n\tif len(auth.Attributes) > 0 {\n\t\tif raw := strings.TrimSpace(auth.Attributes[\"websockets\"]); raw != \"\" {\n\t\t\tparsed, errParse := strconv.ParseBool(raw)\n\t\t\tif errParse == nil {\n\t\t\t\treturn parsed\n\t\t\t}\n\t\t}\n\t}\n\tif len(auth.Metadata) == 0 {\n\t\treturn false\n\t}\n\traw, ok := auth.Metadata[\"websockets\"]\n\tif !ok || raw == nil {\n\t\treturn false\n\t}\n\tswitch v := raw.(type) {\n\tcase bool:\n\t\treturn v\n\tcase string:\n\t\tparsed, errParse := strconv.ParseBool(strings.TrimSpace(v))\n\t\tif errParse == nil {\n\t\t\treturn parsed\n\t\t}\n\tdefault:\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/runtime/executor/codex_websockets_executor_test.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) {\n\tbody := []byte(`{\"model\":\"gpt-5-codex\",\"previous_response_id\":\"resp-1\",\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\n\twsReqBody := buildCodexWebsocketRequestBody(body)\n\n\tif got := gjson.GetBytes(wsReqBody, \"type\").String(); got != \"response.create\" {\n\t\tt.Fatalf(\"type = %s, want response.create\", got)\n\t}\n\tif got := gjson.GetBytes(wsReqBody, \"previous_response_id\").String(); got != \"resp-1\" {\n\t\tt.Fatalf(\"previous_response_id = %s, want resp-1\", got)\n\t}\n\tif gjson.GetBytes(wsReqBody, \"input.0.id\").String() != \"msg-1\" {\n\t\tt.Fatalf(\"input item id mismatch\")\n\t}\n\tif got := gjson.GetBytes(wsReqBody, \"type\").String(); got == \"response.append\" {\n\t\tt.Fatalf(\"unexpected websocket request type: %s\", got)\n\t}\n}\n\nfunc TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {\n\theaders := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, \"\", nil)\n\n\tif got := headers.Get(\"OpenAI-Beta\"); got != codexResponsesWebsocketBetaHeaderValue {\n\t\tt.Fatalf(\"OpenAI-Beta = %s, want %s\", got, codexResponsesWebsocketBetaHeaderValue)\n\t}\n\tif got := headers.Get(\"User-Agent\"); got != codexUserAgent {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", got, codexUserAgent)\n\t}\n\tif got := headers.Get(\"x-codex-beta-features\"); got != \"\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %q, want empty\", got)\n\t}\n}\n\nfunc TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {\n\tcfg := &config.Config{\n\t\tCodexHeaderDefaults: config.CodexHeaderDefaults{\n\t\t\tUserAgent:    \"my-codex-client/1.0\",\n\t\t\tBetaFeatures: \"feature-a,feature-b\",\n\t\t},\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tProvider: \"codex\",\n\t\tMetadata: map[string]any{\"email\": \"user@example.com\"},\n\t}\n\n\theaders := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, \"\", cfg)\n\n\tif got := headers.Get(\"User-Agent\"); got != \"my-codex-client/1.0\" {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", got, \"my-codex-client/1.0\")\n\t}\n\tif got := headers.Get(\"x-codex-beta-features\"); got != \"feature-a,feature-b\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %s, want %s\", got, \"feature-a,feature-b\")\n\t}\n\tif got := headers.Get(\"OpenAI-Beta\"); got != codexResponsesWebsocketBetaHeaderValue {\n\t\tt.Fatalf(\"OpenAI-Beta = %s, want %s\", got, codexResponsesWebsocketBetaHeaderValue)\n\t}\n}\n\nfunc TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t *testing.T) {\n\tcfg := &config.Config{\n\t\tCodexHeaderDefaults: config.CodexHeaderDefaults{\n\t\t\tUserAgent:    \"config-ua\",\n\t\t\tBetaFeatures: \"config-beta\",\n\t\t},\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tProvider: \"codex\",\n\t\tMetadata: map[string]any{\"email\": \"user@example.com\"},\n\t}\n\tctx := contextWithGinHeaders(map[string]string{\n\t\t\"User-Agent\":            \"client-ua\",\n\t\t\"X-Codex-Beta-Features\": \"client-beta\",\n\t})\n\theaders := http.Header{}\n\theaders.Set(\"User-Agent\", \"existing-ua\")\n\theaders.Set(\"X-Codex-Beta-Features\", \"existing-beta\")\n\n\tgot := applyCodexWebsocketHeaders(ctx, headers, auth, \"\", cfg)\n\n\tif gotVal := got.Get(\"User-Agent\"); gotVal != \"existing-ua\" {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", gotVal, \"existing-ua\")\n\t}\n\tif gotVal := got.Get(\"x-codex-beta-features\"); gotVal != \"existing-beta\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %s, want %s\", gotVal, \"existing-beta\")\n\t}\n}\n\nfunc TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testing.T) {\n\tcfg := &config.Config{\n\t\tCodexHeaderDefaults: config.CodexHeaderDefaults{\n\t\t\tUserAgent:    \"config-ua\",\n\t\t\tBetaFeatures: \"config-beta\",\n\t\t},\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tProvider: \"codex\",\n\t\tMetadata: map[string]any{\"email\": \"user@example.com\"},\n\t}\n\tctx := contextWithGinHeaders(map[string]string{\n\t\t\"User-Agent\":            \"client-ua\",\n\t\t\"X-Codex-Beta-Features\": \"client-beta\",\n\t})\n\n\theaders := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, \"\", cfg)\n\n\tif got := headers.Get(\"User-Agent\"); got != \"config-ua\" {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", got, \"config-ua\")\n\t}\n\tif got := headers.Get(\"x-codex-beta-features\"); got != \"client-beta\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %s, want %s\", got, \"client-beta\")\n\t}\n}\n\nfunc TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) {\n\tcfg := &config.Config{\n\t\tCodexHeaderDefaults: config.CodexHeaderDefaults{\n\t\t\tUserAgent:    \"config-ua\",\n\t\t\tBetaFeatures: \"config-beta\",\n\t\t},\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tProvider:   \"codex\",\n\t\tAttributes: map[string]string{\"api_key\": \"sk-test\"},\n\t}\n\n\theaders := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, \"sk-test\", cfg)\n\n\tif got := headers.Get(\"User-Agent\"); got != codexUserAgent {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", got, codexUserAgent)\n\t}\n\tif got := headers.Get(\"x-codex-beta-features\"); got != \"\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %q, want empty\", got)\n\t}\n}\n\nfunc TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {\n\treq, err := http.NewRequest(http.MethodPost, \"https://example.com/responses\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"NewRequest() error = %v\", err)\n\t}\n\tcfg := &config.Config{\n\t\tCodexHeaderDefaults: config.CodexHeaderDefaults{\n\t\t\tUserAgent:    \"config-ua\",\n\t\t\tBetaFeatures: \"config-beta\",\n\t\t},\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tProvider: \"codex\",\n\t\tMetadata: map[string]any{\"email\": \"user@example.com\"},\n\t}\n\treq = req.WithContext(contextWithGinHeaders(map[string]string{\n\t\t\"User-Agent\": \"client-ua\",\n\t}))\n\n\tapplyCodexHeaders(req, auth, \"oauth-token\", true, cfg)\n\n\tif got := req.Header.Get(\"User-Agent\"); got != \"config-ua\" {\n\t\tt.Fatalf(\"User-Agent = %s, want %s\", got, \"config-ua\")\n\t}\n\tif got := req.Header.Get(\"x-codex-beta-features\"); got != \"\" {\n\t\tt.Fatalf(\"x-codex-beta-features = %q, want empty\", got)\n\t}\n}\n\nfunc contextWithGinHeaders(headers map[string]string) context.Context {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tginCtx, _ := gin.CreateTestContext(recorder)\n\tginCtx.Request = httptest.NewRequest(http.MethodPost, \"/\", nil)\n\tginCtx.Request.Header = make(http.Header, len(headers))\n\tfor key, value := range headers {\n\t\tginCtx.Request.Header.Set(key, value)\n\t}\n\treturn context.WithValue(context.Background(), \"gin\", ginCtx)\n}\n\nfunc TestNewProxyAwareWebsocketDialerDirectDisablesProxy(t *testing.T) {\n\tt.Parallel()\n\n\tdialer := newProxyAwareWebsocketDialer(\n\t\t&config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: \"http://global-proxy.example.com:8080\"}},\n\t\t&cliproxyauth.Auth{ProxyURL: \"direct\"},\n\t)\n\n\tif dialer.Proxy != nil {\n\t\tt.Fatal(\"expected websocket proxy function to be nil for direct mode\")\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/gemini_cli_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// This file implements the Gemini CLI executor that talks to Cloud Code Assist endpoints\n// using OAuth credentials from auth metadata.\npackage executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/google\"\n)\n\nconst (\n\tcodeAssistEndpoint      = \"https://cloudcode-pa.googleapis.com\"\n\tcodeAssistVersion       = \"v1internal\"\n\tgeminiOAuthClientID     = \"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com\"\n\tgeminiOAuthClientSecret = \"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\"\n)\n\nvar geminiOAuthScopes = []string{\n\t\"https://www.googleapis.com/auth/cloud-platform\",\n\t\"https://www.googleapis.com/auth/userinfo.email\",\n\t\"https://www.googleapis.com/auth/userinfo.profile\",\n}\n\n// GeminiCLIExecutor talks to the Cloud Code Assist endpoint using OAuth credentials from auth metadata.\ntype GeminiCLIExecutor struct {\n\tcfg *config.Config\n}\n\n// NewGeminiCLIExecutor creates a new Gemini CLI executor instance.\n//\n// Parameters:\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *GeminiCLIExecutor: A new Gemini CLI executor instance\nfunc NewGeminiCLIExecutor(cfg *config.Config) *GeminiCLIExecutor {\n\treturn &GeminiCLIExecutor{cfg: cfg}\n}\n\n// Identifier returns the executor identifier.\nfunc (e *GeminiCLIExecutor) Identifier() string { return \"gemini-cli\" }\n\n// PrepareRequest injects Gemini CLI credentials into the outgoing HTTP request.\nfunc (e *GeminiCLIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\ttokenSource, _, errSource := prepareGeminiCLITokenSource(req.Context(), e.cfg, auth)\n\tif errSource != nil {\n\t\treturn errSource\n\t}\n\ttok, errTok := tokenSource.Token()\n\tif errTok != nil {\n\t\treturn errTok\n\t}\n\tif strings.TrimSpace(tok.AccessToken) == \"\" {\n\t\treturn statusErr{code: http.StatusUnauthorized, msg: \"missing access token\"}\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+tok.AccessToken)\n\tapplyGeminiCLIHeaders(req, \"unknown\")\n\treturn nil\n}\n\n// HttpRequest injects Gemini CLI credentials into the request and executes it.\nfunc (e *GeminiCLIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"gemini-cli executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming request to the Gemini CLI API.\nfunc (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini-cli\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbasePayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbasePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tbasePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbasePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, \"gemini\", \"request\", basePayload, originalTranslated, requestedModel)\n\n\taction := \"generateContent\"\n\tif req.Metadata != nil {\n\t\tif a, _ := req.Metadata[\"action\"].(string); a == \"countTokens\" {\n\t\t\taction = \"countTokens\"\n\t\t}\n\t}\n\n\tprojectID := resolveGeminiProjectID(auth)\n\tmodels := cliPreviewFallbackOrder(baseModel)\n\tif len(models) == 0 || models[0] != baseModel {\n\t\tmodels = append([]string{baseModel}, models...)\n\t}\n\n\thttpClient := newHTTPClient(ctx, e.cfg, auth, 0)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\n\tvar authID, authLabel, authType, authValue string\n\tauthID = auth.ID\n\tauthLabel = auth.Label\n\tauthType, authValue = auth.AccountInfo()\n\n\tvar lastStatus int\n\tvar lastBody []byte\n\n\tfor idx, attemptModel := range models {\n\t\tpayload := append([]byte(nil), basePayload...)\n\t\tif action == \"countTokens\" {\n\t\t\tpayload = deleteJSONField(payload, \"project\")\n\t\t\tpayload = deleteJSONField(payload, \"model\")\n\t\t} else {\n\t\t\tpayload = setJSONField(payload, \"project\", projectID)\n\t\t\tpayload = setJSONField(payload, \"model\", attemptModel)\n\t\t}\n\n\t\ttok, errTok := tokenSource.Token()\n\t\tif errTok != nil {\n\t\t\terr = errTok\n\t\t\treturn resp, err\n\t\t}\n\t\tupdateGeminiCLITokenMetadata(auth, baseTokenData, tok)\n\n\t\turl := fmt.Sprintf(\"%s/%s:%s\", codeAssistEndpoint, codeAssistVersion, action)\n\t\tif opts.Alt != \"\" && action != \"countTokens\" {\n\t\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t\t}\n\n\t\treqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))\n\t\tif errReq != nil {\n\t\t\terr = errReq\n\t\t\treturn resp, err\n\t\t}\n\t\treqHTTP.Header.Set(\"Content-Type\", \"application/json\")\n\t\treqHTTP.Header.Set(\"Authorization\", \"Bearer \"+tok.AccessToken)\n\t\tapplyGeminiCLIHeaders(reqHTTP, attemptModel)\n\t\treqHTTP.Header.Set(\"Accept\", \"application/json\")\n\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\tURL:       url,\n\t\t\tMethod:    http.MethodPost,\n\t\t\tHeaders:   reqHTTP.Header.Clone(),\n\t\t\tBody:      payload,\n\t\t\tProvider:  e.Identifier(),\n\t\t\tAuthID:    authID,\n\t\t\tAuthLabel: authLabel,\n\t\t\tAuthType:  authType,\n\t\t\tAuthValue: authValue,\n\t\t})\n\n\t\thttpResp, errDo := httpClient.Do(reqHTTP)\n\t\tif errDo != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\terr = errDo\n\t\t\treturn resp, err\n\t\t}\n\n\t\tdata, errRead := io.ReadAll(httpResp.Body)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"gemini cli executor: close response body error: %v\", errClose)\n\t\t}\n\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\tif errRead != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\terr = errRead\n\t\t\treturn resp, err\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\tif httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {\n\t\t\treporter.publish(ctx, parseGeminiCLIUsage(data))\n\t\t\tvar param any\n\t\t\tout := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, opts.OriginalRequest, payload, data, &param)\n\t\t\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tlastStatus = httpResp.StatusCode\n\t\tlastBody = append([]byte(nil), data...)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), data))\n\t\tif httpResp.StatusCode == 429 {\n\t\t\tif idx+1 < len(models) {\n\t\t\t\tlog.Debugf(\"gemini cli executor: rate limited, retrying with next model: %s\", models[idx+1])\n\t\t\t} else {\n\t\t\t\tlog.Debug(\"gemini cli executor: rate limited, no additional fallback model\")\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\terr = newGeminiStatusErr(httpResp.StatusCode, data)\n\t\treturn resp, err\n\t}\n\n\tif len(lastBody) > 0 {\n\t\tappendAPIResponseChunk(ctx, e.cfg, lastBody)\n\t}\n\tif lastStatus == 0 {\n\t\tlastStatus = 429\n\t}\n\terr = newGeminiStatusErr(lastStatus, lastBody)\n\treturn resp, err\n}\n\n// ExecuteStream performs a streaming request to the Gemini CLI API.\nfunc (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini-cli\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbasePayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\tbasePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbasePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbasePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, \"gemini\", \"request\", basePayload, originalTranslated, requestedModel)\n\n\tprojectID := resolveGeminiProjectID(auth)\n\n\tmodels := cliPreviewFallbackOrder(baseModel)\n\tif len(models) == 0 || models[0] != baseModel {\n\t\tmodels = append([]string{baseModel}, models...)\n\t}\n\n\thttpClient := newHTTPClient(ctx, e.cfg, auth, 0)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\n\tvar authID, authLabel, authType, authValue string\n\tauthID = auth.ID\n\tauthLabel = auth.Label\n\tauthType, authValue = auth.AccountInfo()\n\n\tvar lastStatus int\n\tvar lastBody []byte\n\n\tfor idx, attemptModel := range models {\n\t\tpayload := append([]byte(nil), basePayload...)\n\t\tpayload = setJSONField(payload, \"project\", projectID)\n\t\tpayload = setJSONField(payload, \"model\", attemptModel)\n\n\t\ttok, errTok := tokenSource.Token()\n\t\tif errTok != nil {\n\t\t\terr = errTok\n\t\t\treturn nil, err\n\t\t}\n\t\tupdateGeminiCLITokenMetadata(auth, baseTokenData, tok)\n\n\t\turl := fmt.Sprintf(\"%s/%s:%s\", codeAssistEndpoint, codeAssistVersion, \"streamGenerateContent\")\n\t\tif opts.Alt == \"\" {\n\t\t\turl = url + \"?alt=sse\"\n\t\t} else {\n\t\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t\t}\n\n\t\treqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))\n\t\tif errReq != nil {\n\t\t\terr = errReq\n\t\t\treturn nil, err\n\t\t}\n\t\treqHTTP.Header.Set(\"Content-Type\", \"application/json\")\n\t\treqHTTP.Header.Set(\"Authorization\", \"Bearer \"+tok.AccessToken)\n\t\tapplyGeminiCLIHeaders(reqHTTP, attemptModel)\n\t\treqHTTP.Header.Set(\"Accept\", \"text/event-stream\")\n\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\tURL:       url,\n\t\t\tMethod:    http.MethodPost,\n\t\t\tHeaders:   reqHTTP.Header.Clone(),\n\t\t\tBody:      payload,\n\t\t\tProvider:  e.Identifier(),\n\t\t\tAuthID:    authID,\n\t\t\tAuthLabel: authLabel,\n\t\t\tAuthType:  authType,\n\t\t\tAuthValue: authValue,\n\t\t})\n\n\t\thttpResp, errDo := httpClient.Do(reqHTTP)\n\t\tif errDo != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\terr = errDo\n\t\t\treturn nil, err\n\t\t}\n\t\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\t\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\t\tdata, errRead := io.ReadAll(httpResp.Body)\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"gemini cli executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t\tif errRead != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\terr = errRead\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\t\tlastStatus = httpResp.StatusCode\n\t\t\tlastBody = append([]byte(nil), data...)\n\t\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), data))\n\t\t\tif httpResp.StatusCode == 429 {\n\t\t\t\tif idx+1 < len(models) {\n\t\t\t\t\tlog.Debugf(\"gemini cli executor: rate limited, retrying with next model: %s\", models[idx+1])\n\t\t\t\t} else {\n\t\t\t\t\tlog.Debug(\"gemini cli executor: rate limited, no additional fallback model\")\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\terr = newGeminiStatusErr(httpResp.StatusCode, data)\n\t\t\treturn nil, err\n\t\t}\n\n\t\tout := make(chan cliproxyexecutor.StreamChunk)\n\t\tgo func(resp *http.Response, reqBody []byte, attemptModel string) {\n\t\t\tdefer close(out)\n\t\t\tdefer func() {\n\t\t\t\tif errClose := resp.Body.Close(); errClose != nil {\n\t\t\t\t\tlog.Errorf(\"gemini cli executor: close response body error: %v\", errClose)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tif opts.Alt == \"\" {\n\t\t\t\tscanner := bufio.NewScanner(resp.Body)\n\t\t\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\t\t\tvar param any\n\t\t\t\tfor scanner.Scan() {\n\t\t\t\t\tline := scanner.Bytes()\n\t\t\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\t\t\tif detail, ok := parseGeminiCLIStreamUsage(line); ok {\n\t\t\t\t\t\treporter.publish(ctx, detail)\n\t\t\t\t\t}\n\t\t\t\t\tif bytes.HasPrefix(line, dataTag) {\n\t\t\t\t\t\tsegments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), &param)\n\t\t\t\t\t\tfor i := range segments {\n\t\t\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tsegments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte(\"[DONE]\"), &param)\n\t\t\t\tfor i := range segments {\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}\n\t\t\t\t}\n\t\t\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdata, errRead := io.ReadAll(resp.Body)\n\t\t\tif errRead != nil {\n\t\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\t\treporter.publishFailure(ctx)\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errRead}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\t\treporter.publish(ctx, parseGeminiCLIUsage(data))\n\t\t\tvar param any\n\t\t\tsegments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, &param)\n\t\t\tfor i := range segments {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}\n\t\t\t}\n\n\t\t\tsegments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte(\"[DONE]\"), &param)\n\t\t\tfor i := range segments {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}\n\t\t\t}\n\t\t}(httpResp, append([]byte(nil), payload...), attemptModel)\n\n\t\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n\t}\n\n\tif len(lastBody) > 0 {\n\t\tappendAPIResponseChunk(ctx, e.cfg, lastBody)\n\t}\n\tif lastStatus == 0 {\n\t\tlastStatus = 429\n\t}\n\terr = newGeminiStatusErr(lastStatus, lastBody)\n\treturn nil, err\n}\n\n// CountTokens counts tokens for the given request using the Gemini CLI API.\nfunc (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini-cli\")\n\n\tmodels := cliPreviewFallbackOrder(baseModel)\n\tif len(models) == 0 || models[0] != baseModel {\n\t\tmodels = append([]string{baseModel}, models...)\n\t}\n\n\thttpClient := newHTTPClient(ctx, e.cfg, auth, 0)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\n\tvar lastStatus int\n\tvar lastBody []byte\n\n\t// The loop variable attemptModel is only used as the concrete model id sent to the upstream\n\t// Gemini CLI endpoint when iterating fallback variants.\n\tfor range models {\n\t\tpayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\t\tpayload, err = thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())\n\t\tif err != nil {\n\t\t\treturn cliproxyexecutor.Response{}, err\n\t\t}\n\n\t\tpayload = deleteJSONField(payload, \"project\")\n\t\tpayload = deleteJSONField(payload, \"model\")\n\t\tpayload = deleteJSONField(payload, \"request.safetySettings\")\n\t\tpayload = fixGeminiCLIImageAspectRatio(baseModel, payload)\n\n\t\ttok, errTok := tokenSource.Token()\n\t\tif errTok != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errTok\n\t\t}\n\t\tupdateGeminiCLITokenMetadata(auth, baseTokenData, tok)\n\n\t\turl := fmt.Sprintf(\"%s/%s:%s\", codeAssistEndpoint, codeAssistVersion, \"countTokens\")\n\t\tif opts.Alt != \"\" {\n\t\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t\t}\n\n\t\treqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))\n\t\tif errReq != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errReq\n\t\t}\n\t\treqHTTP.Header.Set(\"Content-Type\", \"application/json\")\n\t\treqHTTP.Header.Set(\"Authorization\", \"Bearer \"+tok.AccessToken)\n\t\tapplyGeminiCLIHeaders(reqHTTP, baseModel)\n\t\treqHTTP.Header.Set(\"Accept\", \"application/json\")\n\t\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\t\tURL:       url,\n\t\t\tMethod:    http.MethodPost,\n\t\t\tHeaders:   reqHTTP.Header.Clone(),\n\t\t\tBody:      payload,\n\t\t\tProvider:  e.Identifier(),\n\t\t\tAuthID:    authID,\n\t\t\tAuthLabel: authLabel,\n\t\t\tAuthType:  authType,\n\t\t\tAuthValue: authValue,\n\t\t})\n\n\t\tresp, errDo := httpClient.Do(reqHTTP)\n\t\tif errDo != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\t\treturn cliproxyexecutor.Response{}, errDo\n\t\t}\n\t\tdata, errRead := io.ReadAll(resp.Body)\n\t\t_ = resp.Body.Close()\n\t\trecordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())\n\t\tif errRead != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\t\treturn cliproxyexecutor.Response{}, errRead\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\tif resp.StatusCode >= 200 && resp.StatusCode < 300 {\n\t\t\tcount := gjson.GetBytes(data, \"totalTokens\").Int()\n\t\t\ttranslated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)\n\t\t\treturn cliproxyexecutor.Response{Payload: []byte(translated), Headers: resp.Header.Clone()}, nil\n\t\t}\n\t\tlastStatus = resp.StatusCode\n\t\tlastBody = append([]byte(nil), data...)\n\t\tif resp.StatusCode == 429 {\n\t\t\tlog.Debugf(\"gemini cli executor: rate limited, retrying with next model\")\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\n\tif lastStatus == 0 {\n\t\tlastStatus = 429\n\t}\n\treturn cliproxyexecutor.Response{}, newGeminiStatusErr(lastStatus, lastBody)\n}\n\n// Refresh refreshes the authentication credentials (no-op for Gemini CLI).\nfunc (e *GeminiCLIExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {\n\tmetadata := geminiOAuthMetadata(auth)\n\tif auth == nil || metadata == nil {\n\t\treturn nil, nil, fmt.Errorf(\"gemini-cli auth metadata missing\")\n\t}\n\n\tvar base map[string]any\n\tif tokenRaw, ok := metadata[\"token\"].(map[string]any); ok && tokenRaw != nil {\n\t\tbase = cloneMap(tokenRaw)\n\t} else {\n\t\tbase = make(map[string]any)\n\t}\n\n\tvar token oauth2.Token\n\tif len(base) > 0 {\n\t\tif raw, err := json.Marshal(base); err == nil {\n\t\t\t_ = json.Unmarshal(raw, &token)\n\t\t}\n\t}\n\n\tif token.AccessToken == \"\" {\n\t\ttoken.AccessToken = stringValue(metadata, \"access_token\")\n\t}\n\tif token.RefreshToken == \"\" {\n\t\ttoken.RefreshToken = stringValue(metadata, \"refresh_token\")\n\t}\n\tif token.TokenType == \"\" {\n\t\ttoken.TokenType = stringValue(metadata, \"token_type\")\n\t}\n\tif token.Expiry.IsZero() {\n\t\tif expiry := stringValue(metadata, \"expiry\"); expiry != \"\" {\n\t\t\tif ts, err := time.Parse(time.RFC3339, expiry); err == nil {\n\t\t\t\ttoken.Expiry = ts\n\t\t\t}\n\t\t}\n\t}\n\n\tconf := &oauth2.Config{\n\t\tClientID:     geminiOAuthClientID,\n\t\tClientSecret: geminiOAuthClientSecret,\n\t\tScopes:       geminiOAuthScopes,\n\t\tEndpoint:     google.Endpoint,\n\t}\n\n\tctxToken := ctx\n\tif httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {\n\t\tctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)\n\t}\n\n\tsrc := conf.TokenSource(ctxToken, &token)\n\tcurrentToken, err := src.Token()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tupdateGeminiCLITokenMetadata(auth, base, currentToken)\n\treturn oauth2.ReuseTokenSource(currentToken, src), base, nil\n}\n\nfunc updateGeminiCLITokenMetadata(auth *cliproxyauth.Auth, base map[string]any, tok *oauth2.Token) {\n\tif auth == nil || tok == nil {\n\t\treturn\n\t}\n\tmerged := buildGeminiTokenMap(base, tok)\n\tfields := buildGeminiTokenFields(tok, merged)\n\tshared := geminicli.ResolveSharedCredential(auth.Runtime)\n\tif shared != nil {\n\t\tsnapshot := shared.MergeMetadata(fields)\n\t\tif !geminicli.IsVirtual(auth.Runtime) {\n\t\t\tauth.Metadata = snapshot\n\t\t}\n\t\treturn\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tfor k, v := range fields {\n\t\tauth.Metadata[k] = v\n\t}\n}\n\nfunc buildGeminiTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {\n\tmerged := cloneMap(base)\n\tif merged == nil {\n\t\tmerged = make(map[string]any)\n\t}\n\tif raw, err := json.Marshal(tok); err == nil {\n\t\tvar tokenMap map[string]any\n\t\tif err = json.Unmarshal(raw, &tokenMap); err == nil {\n\t\t\tfor k, v := range tokenMap {\n\t\t\t\tmerged[k] = v\n\t\t\t}\n\t\t}\n\t}\n\treturn merged\n}\n\nfunc buildGeminiTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any {\n\tfields := make(map[string]any, 5)\n\tif tok.AccessToken != \"\" {\n\t\tfields[\"access_token\"] = tok.AccessToken\n\t}\n\tif tok.TokenType != \"\" {\n\t\tfields[\"token_type\"] = tok.TokenType\n\t}\n\tif tok.RefreshToken != \"\" {\n\t\tfields[\"refresh_token\"] = tok.RefreshToken\n\t}\n\tif !tok.Expiry.IsZero() {\n\t\tfields[\"expiry\"] = tok.Expiry.Format(time.RFC3339)\n\t}\n\tif len(merged) > 0 {\n\t\tfields[\"token\"] = cloneMap(merged)\n\t}\n\treturn fields\n}\n\nfunc resolveGeminiProjectID(auth *cliproxyauth.Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif runtime := auth.Runtime; runtime != nil {\n\t\tif virtual, ok := runtime.(*geminicli.VirtualCredential); ok && virtual != nil {\n\t\t\treturn strings.TrimSpace(virtual.ProjectID)\n\t\t}\n\t}\n\treturn strings.TrimSpace(stringValue(auth.Metadata, \"project_id\"))\n}\n\nfunc geminiOAuthMetadata(auth *cliproxyauth.Auth) map[string]any {\n\tif auth == nil {\n\t\treturn nil\n\t}\n\tif shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil {\n\t\tif snapshot := shared.MetadataSnapshot(); len(snapshot) > 0 {\n\t\t\treturn snapshot\n\t\t}\n\t}\n\treturn auth.Metadata\n}\n\nfunc newHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {\n\treturn newProxyAwareHTTPClient(ctx, cfg, auth, timeout)\n}\n\nfunc cloneMap(in map[string]any) map[string]any {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := make(map[string]any, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc stringValue(m map[string]any, key string) string {\n\tif m == nil {\n\t\treturn \"\"\n\t}\n\tif v, ok := m[key]; ok {\n\t\tswitch typed := v.(type) {\n\t\tcase string:\n\t\t\treturn typed\n\t\tcase fmt.Stringer:\n\t\t\treturn typed.String()\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// applyGeminiCLIHeaders sets required headers for the Gemini CLI upstream.\n// User-Agent is always forced to the GeminiCLI format regardless of the client's value,\n// so that upstream identifies the request as a native GeminiCLI client.\nfunc applyGeminiCLIHeaders(r *http.Request, model string) {\n\tr.Header.Set(\"User-Agent\", misc.GeminiCLIUserAgent(model))\n\tr.Header.Set(\"X-Goog-Api-Client\", misc.GeminiCLIApiClientHeader)\n}\n\n// cliPreviewFallbackOrder returns preview model candidates for a base model.\nfunc cliPreviewFallbackOrder(model string) []string {\n\tswitch model {\n\tcase \"gemini-2.5-pro\":\n\t\treturn []string{\n\t\t\t// \"gemini-2.5-pro-preview-05-06\",\n\t\t\t// \"gemini-2.5-pro-preview-06-05\",\n\t\t}\n\tcase \"gemini-2.5-flash\":\n\t\treturn []string{\n\t\t\t// \"gemini-2.5-flash-preview-04-17\",\n\t\t\t// \"gemini-2.5-flash-preview-05-20\",\n\t\t}\n\tcase \"gemini-2.5-flash-lite\":\n\t\treturn []string{\n\t\t\t// \"gemini-2.5-flash-lite-preview-06-17\",\n\t\t}\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// setJSONField sets a top-level JSON field on a byte slice payload via sjson.\nfunc setJSONField(body []byte, key, value string) []byte {\n\tif key == \"\" {\n\t\treturn body\n\t}\n\tupdated, err := sjson.SetBytes(body, key, value)\n\tif err != nil {\n\t\treturn body\n\t}\n\treturn updated\n}\n\n// deleteJSONField removes a top-level key if present (best-effort) via sjson.\nfunc deleteJSONField(body []byte, key string) []byte {\n\tif key == \"\" || len(body) == 0 {\n\t\treturn body\n\t}\n\tupdated, err := sjson.DeleteBytes(body, key)\n\tif err != nil {\n\t\treturn body\n\t}\n\treturn updated\n}\n\nfunc fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte {\n\tif modelName == \"gemini-2.5-flash-image-preview\" {\n\t\taspectRatioResult := gjson.GetBytes(rawJSON, \"request.generationConfig.imageConfig.aspectRatio\")\n\t\tif aspectRatioResult.Exists() {\n\t\t\tcontents := gjson.GetBytes(rawJSON, \"request.contents\")\n\t\t\tcontentArray := contents.Array()\n\t\t\tif len(contentArray) > 0 {\n\t\t\t\thasInlineData := false\n\t\t\tloopContent:\n\t\t\t\tfor i := 0; i < len(contentArray); i++ {\n\t\t\t\t\tparts := contentArray[i].Get(\"parts\").Array()\n\t\t\t\t\tfor j := 0; j < len(parts); j++ {\n\t\t\t\t\t\tif parts[j].Get(\"inlineData\").Exists() {\n\t\t\t\t\t\t\thasInlineData = true\n\t\t\t\t\t\t\tbreak loopContent\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif !hasInlineData {\n\t\t\t\t\temptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())\n\t\t\t\t\temptyImagePart := `{\"inlineData\":{\"mime_type\":\"image/png\",\"data\":\"\"}}`\n\t\t\t\t\temptyImagePart, _ = sjson.Set(emptyImagePart, \"inlineData.data\", emptyImageBase64ed)\n\t\t\t\t\tnewPartsJson := `[]`\n\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", `{\"text\": \"Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear.\"}`)\n\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", emptyImagePart)\n\n\t\t\t\t\tparts := contentArray[0].Get(\"parts\").Array()\n\t\t\t\t\tfor j := 0; j < len(parts); j++ {\n\t\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", parts[j].Raw)\n\t\t\t\t\t}\n\n\t\t\t\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"request.contents.0.parts\", []byte(newPartsJson))\n\t\t\t\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"request.generationConfig.responseModalities\", []byte(`[\"IMAGE\", \"TEXT\"]`))\n\t\t\t\t}\n\t\t\t}\n\t\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"request.generationConfig.imageConfig\")\n\t\t}\n\t}\n\treturn rawJSON\n}\n\nfunc newGeminiStatusErr(statusCode int, body []byte) statusErr {\n\terr := statusErr{code: statusCode, msg: string(body)}\n\tif statusCode == http.StatusTooManyRequests {\n\t\tif retryAfter, parseErr := parseRetryDelay(body); parseErr == nil && retryAfter != nil {\n\t\t\terr.retryAfter = retryAfter\n\t\t}\n\t}\n\treturn err\n}\n\n// parseRetryDelay extracts the retry delay from a Google API 429 error response.\n// The error response contains a RetryInfo.retryDelay field in the format \"0.847655010s\".\n// Returns the parsed duration or an error if it cannot be determined.\nfunc parseRetryDelay(errorBody []byte) (*time.Duration, error) {\n\t// Try to parse the retryDelay from the error response\n\t// Format: error.details[].retryDelay where @type == \"type.googleapis.com/google.rpc.RetryInfo\"\n\tdetails := gjson.GetBytes(errorBody, \"error.details\")\n\tif details.Exists() && details.IsArray() {\n\t\tfor _, detail := range details.Array() {\n\t\t\ttypeVal := detail.Get(\"@type\").String()\n\t\t\tif typeVal == \"type.googleapis.com/google.rpc.RetryInfo\" {\n\t\t\t\tretryDelay := detail.Get(\"retryDelay\").String()\n\t\t\t\tif retryDelay != \"\" {\n\t\t\t\t\t// Parse duration string like \"0.847655010s\"\n\t\t\t\t\tduration, err := time.ParseDuration(retryDelay)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"failed to parse duration\")\n\t\t\t\t\t}\n\t\t\t\t\treturn &duration, nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Fallback: try ErrorInfo.metadata.quotaResetDelay (e.g., \"373.801628ms\")\n\t\tfor _, detail := range details.Array() {\n\t\t\ttypeVal := detail.Get(\"@type\").String()\n\t\t\tif typeVal == \"type.googleapis.com/google.rpc.ErrorInfo\" {\n\t\t\t\tquotaResetDelay := detail.Get(\"metadata.quotaResetDelay\").String()\n\t\t\t\tif quotaResetDelay != \"\" {\n\t\t\t\t\tduration, err := time.ParseDuration(quotaResetDelay)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\treturn &duration, nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: parse from error.message \"Your quota will reset after Xs.\"\n\tmessage := gjson.GetBytes(errorBody, \"error.message\").String()\n\tif message != \"\" {\n\t\tre := regexp.MustCompile(`after\\s+(\\d+)s\\.?`)\n\t\tif matches := re.FindStringSubmatch(message); len(matches) > 1 {\n\t\t\tseconds, err := strconv.Atoi(matches[1])\n\t\t\tif err == nil {\n\t\t\t\treturn new(time.Duration(seconds) * time.Second), nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"no RetryInfo found\")\n}\n"
  },
  {
    "path": "internal/runtime/executor/gemini_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// It includes stateless executors that handle API requests, streaming responses,\n// token counting, and authentication refresh for different AI service providers.\npackage executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst (\n\t// glEndpoint is the base URL for the Google Generative Language API.\n\tglEndpoint = \"https://generativelanguage.googleapis.com\"\n\n\t// glAPIVersion is the API version used for Gemini requests.\n\tglAPIVersion = \"v1beta\"\n\n\t// streamScannerBuffer is the buffer size for SSE stream scanning.\n\tstreamScannerBuffer = 52_428_800\n)\n\n// GeminiExecutor is a stateless executor for the official Gemini API using API keys.\n// It handles both API key and OAuth bearer token authentication, supporting both\n// regular and streaming requests to the Google Generative Language API.\ntype GeminiExecutor struct {\n\t// cfg holds the application configuration.\n\tcfg *config.Config\n}\n\n// NewGeminiExecutor creates a new Gemini executor instance.\n//\n// Parameters:\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *GeminiExecutor: A new Gemini executor instance\nfunc NewGeminiExecutor(cfg *config.Config) *GeminiExecutor {\n\treturn &GeminiExecutor{cfg: cfg}\n}\n\n// Identifier returns the executor identifier.\nfunc (e *GeminiExecutor) Identifier() string { return \"gemini\" }\n\n// PrepareRequest injects Gemini credentials into the outgoing HTTP request.\nfunc (e *GeminiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\tapiKey, bearer := geminiCreds(auth)\n\tif apiKey != \"\" {\n\t\treq.Header.Set(\"x-goog-api-key\", apiKey)\n\t\treq.Header.Del(\"Authorization\")\n\t} else if bearer != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+bearer)\n\t\treq.Header.Del(\"x-goog-api-key\")\n\t}\n\tapplyGeminiHeaders(req, auth)\n\treturn nil\n}\n\n// HttpRequest injects Gemini credentials into the request and executes it.\nfunc (e *GeminiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"gemini executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming request to the Gemini API.\n// It translates the request to Gemini format, sends it to the API, and translates\n// the response back to the requested format.\n//\n// Parameters:\n//   - ctx: The context for the request\n//   - auth: The authentication information\n//   - req: The request to execute\n//   - opts: Additional execution options\n//\n// Returns:\n//   - cliproxyexecutor.Response: The response from the API\n//   - error: An error if the request fails\nfunc (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, bearer := geminiCreds(auth)\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\t// Official Gemini API via API key or OAuth bearer\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\taction := \"generateContent\"\n\tif req.Metadata != nil {\n\t\tif a, _ := req.Metadata[\"action\"].(string); a == \"countTokens\" {\n\t\t\taction = \"countTokens\"\n\t\t}\n\t}\n\tbaseURL := resolveGeminiBaseURL(auth)\n\turl := fmt.Sprintf(\"%s/%s/models/%s:%s\", baseURL, glAPIVersion, baseModel, action)\n\tif opts.Alt != \"\" && action != \"countTokens\" {\n\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t}\n\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t} else if bearer != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+bearer)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"gemini executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseGeminiUsage(data))\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\n// ExecuteStream performs a streaming request to the Gemini API.\nfunc (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, bearer := geminiCreds(auth)\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbaseURL := resolveGeminiBaseURL(auth)\n\turl := fmt.Sprintf(\"%s/%s/models/%s:%s\", baseURL, glAPIVersion, baseModel, \"streamGenerateContent\")\n\tif opts.Alt == \"\" {\n\t\turl = url + \"?alt=sse\"\n\t} else {\n\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t}\n\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t} else {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+bearer)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"gemini executor: close response body error: %v\", errClose)\n\t\t}\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"gemini executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tfiltered := FilterSSEUsageMetadata(line)\n\t\t\tpayload := jsonPayload(filtered)\n\t\t\tif len(payload) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif detail, ok := parseGeminiStreamUsage(payload); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), &param)\n\t\t\tfor i := range lines {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t\t}\n\t\t}\n\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte(\"[DONE]\"), &param)\n\t\tfor i := range lines {\n\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\n// CountTokens counts tokens for the given request using the Gemini API.\nfunc (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, bearer := geminiCreds(auth)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\ttranslatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\ttranslatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\ttranslatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"tools\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"generationConfig\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"safetySettings\")\n\ttranslatedReq, _ = sjson.SetBytes(translatedReq, \"model\", baseModel)\n\n\tbaseURL := resolveGeminiBaseURL(auth)\n\turl := fmt.Sprintf(\"%s/%s/models/%s:%s\", baseURL, glAPIVersion, baseModel, \"countTokens\")\n\n\trequestBody := bytes.NewReader(translatedReq)\n\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t} else {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+bearer)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      translatedReq,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\tresp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\trecordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", resp.StatusCode, summarizeErrorBody(resp.Header.Get(\"Content-Type\"), data))\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(data)}\n\t}\n\n\tcount := gjson.GetBytes(data, \"totalTokens\").Int()\n\ttranslated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)\n\treturn cliproxyexecutor.Response{Payload: []byte(translated), Headers: resp.Header.Clone()}, nil\n}\n\n// Refresh refreshes the authentication credentials (no-op for Gemini API key).\nfunc (e *GeminiExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tif v := a.Attributes[\"api_key\"]; v != \"\" {\n\t\t\tapiKey = v\n\t\t}\n\t}\n\tif a.Metadata != nil {\n\t\t// GeminiTokenStorage.Token is a map that may contain access_token\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok && v != \"\" {\n\t\t\tbearer = v\n\t\t}\n\t\tif token, ok := a.Metadata[\"token\"].(map[string]any); ok && token != nil {\n\t\t\tif v, ok2 := token[\"access_token\"].(string); ok2 && v != \"\" {\n\t\t\t\tbearer = v\n\t\t\t}\n\t\t}\n\t}\n\treturn\n}\n\nfunc resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {\n\tbase := glEndpoint\n\tif auth != nil && auth.Attributes != nil {\n\t\tif custom := strings.TrimSpace(auth.Attributes[\"base_url\"]); custom != \"\" {\n\t\t\tbase = strings.TrimRight(custom, \"/\")\n\t\t}\n\t}\n\tif base == \"\" {\n\t\treturn glEndpoint\n\t}\n\treturn base\n}\n\nfunc (e *GeminiExecutor) resolveGeminiConfig(auth *cliproxyauth.Auth) *config.GeminiKey {\n\tif auth == nil || e.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range e.cfg.GeminiKey {\n\t\tentry := &e.cfg.GeminiKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && attrBase != \"\" {\n\t\t\tif strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range e.cfg.GeminiKey {\n\t\t\tentry := &e.cfg.GeminiKey[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(req, attrs)\n}\n\nfunc fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {\n\tif modelName == \"gemini-2.5-flash-image-preview\" {\n\t\taspectRatioResult := gjson.GetBytes(rawJSON, \"generationConfig.imageConfig.aspectRatio\")\n\t\tif aspectRatioResult.Exists() {\n\t\t\tcontents := gjson.GetBytes(rawJSON, \"contents\")\n\t\t\tcontentArray := contents.Array()\n\t\t\tif len(contentArray) > 0 {\n\t\t\t\thasInlineData := false\n\t\t\tloopContent:\n\t\t\t\tfor i := 0; i < len(contentArray); i++ {\n\t\t\t\t\tparts := contentArray[i].Get(\"parts\").Array()\n\t\t\t\t\tfor j := 0; j < len(parts); j++ {\n\t\t\t\t\t\tif parts[j].Get(\"inlineData\").Exists() {\n\t\t\t\t\t\t\thasInlineData = true\n\t\t\t\t\t\t\tbreak loopContent\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif !hasInlineData {\n\t\t\t\t\temptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())\n\t\t\t\t\temptyImagePart := `{\"inlineData\":{\"mime_type\":\"image/png\",\"data\":\"\"}}`\n\t\t\t\t\temptyImagePart, _ = sjson.Set(emptyImagePart, \"inlineData.data\", emptyImageBase64ed)\n\t\t\t\t\tnewPartsJson := `[]`\n\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", `{\"text\": \"Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear.\"}`)\n\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", emptyImagePart)\n\n\t\t\t\t\tparts := contentArray[0].Get(\"parts\").Array()\n\t\t\t\t\tfor j := 0; j < len(parts); j++ {\n\t\t\t\t\t\tnewPartsJson, _ = sjson.SetRaw(newPartsJson, \"-1\", parts[j].Raw)\n\t\t\t\t\t}\n\n\t\t\t\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"contents.0.parts\", []byte(newPartsJson))\n\t\t\t\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"generationConfig.responseModalities\", []byte(`[\"IMAGE\", \"TEXT\"]`))\n\t\t\t\t}\n\t\t\t}\n\t\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"generationConfig.imageConfig\")\n\t\t}\n\t}\n\treturn rawJSON\n}\n"
  },
  {
    "path": "internal/runtime/executor/gemini_vertex_executor.go",
    "content": "// Package executor provides runtime execution capabilities for various AI service providers.\n// This file implements the Vertex AI Gemini executor that talks to Google Vertex AI\n// endpoints using service account credentials or API keys.\npackage executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tvertexauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/google\"\n)\n\nconst (\n\t// vertexAPIVersion aligns with current public Vertex Generative AI API.\n\tvertexAPIVersion = \"v1\"\n)\n\n// isImagenModel checks if the model name is an Imagen image generation model.\n// Imagen models use the :predict action instead of :generateContent.\nfunc isImagenModel(model string) bool {\n\tlowerModel := strings.ToLower(model)\n\treturn strings.Contains(lowerModel, \"imagen\")\n}\n\n// getVertexAction returns the appropriate action for the given model.\n// Imagen models use \"predict\", while Gemini models use \"generateContent\".\nfunc getVertexAction(model string, isStream bool) string {\n\tif isImagenModel(model) {\n\t\treturn \"predict\"\n\t}\n\tif isStream {\n\t\treturn \"streamGenerateContent\"\n\t}\n\treturn \"generateContent\"\n}\n\n// convertImagenToGeminiResponse converts Imagen API response to Gemini format\n// so it can be processed by the standard translation pipeline.\n// This ensures Imagen models return responses in the same format as gemini-3-pro-image-preview.\nfunc convertImagenToGeminiResponse(data []byte, model string) []byte {\n\tpredictions := gjson.GetBytes(data, \"predictions\")\n\tif !predictions.Exists() || !predictions.IsArray() {\n\t\treturn data\n\t}\n\n\t// Build Gemini-compatible response with inlineData\n\tparts := make([]map[string]any, 0)\n\tfor _, pred := range predictions.Array() {\n\t\timageData := pred.Get(\"bytesBase64Encoded\").String()\n\t\tmimeType := pred.Get(\"mimeType\").String()\n\t\tif mimeType == \"\" {\n\t\t\tmimeType = \"image/png\"\n\t\t}\n\t\tif imageData != \"\" {\n\t\t\tparts = append(parts, map[string]any{\n\t\t\t\t\"inlineData\": map[string]any{\n\t\t\t\t\t\"mimeType\": mimeType,\n\t\t\t\t\t\"data\":     imageData,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\t// Generate unique response ID using timestamp\n\tresponseId := fmt.Sprintf(\"imagen-%d\", time.Now().UnixNano())\n\n\tresponse := map[string]any{\n\t\t\"candidates\": []map[string]any{{\n\t\t\t\"content\": map[string]any{\n\t\t\t\t\"parts\": parts,\n\t\t\t\t\"role\":  \"model\",\n\t\t\t},\n\t\t\t\"finishReason\": \"STOP\",\n\t\t}},\n\t\t\"responseId\":   responseId,\n\t\t\"modelVersion\": model,\n\t\t// Imagen API doesn't return token counts, set to 0 for tracking purposes\n\t\t\"usageMetadata\": map[string]any{\n\t\t\t\"promptTokenCount\":     0,\n\t\t\t\"candidatesTokenCount\": 0,\n\t\t\t\"totalTokenCount\":      0,\n\t\t},\n\t}\n\n\tresult, err := json.Marshal(response)\n\tif err != nil {\n\t\treturn data\n\t}\n\treturn result\n}\n\n// convertToImagenRequest converts a Gemini-style request to Imagen API format.\n// Imagen API uses a different structure: instances[].prompt instead of contents[].\nfunc convertToImagenRequest(payload []byte) ([]byte, error) {\n\t// Extract prompt from Gemini-style contents\n\tprompt := \"\"\n\n\t// Try to get prompt from contents[0].parts[0].text\n\tcontentsText := gjson.GetBytes(payload, \"contents.0.parts.0.text\")\n\tif contentsText.Exists() {\n\t\tprompt = contentsText.String()\n\t}\n\n\t// If no contents, try messages format (OpenAI-compatible)\n\tif prompt == \"\" {\n\t\tmessagesText := gjson.GetBytes(payload, \"messages.#.content\")\n\t\tif messagesText.Exists() && messagesText.IsArray() {\n\t\t\tfor _, msg := range messagesText.Array() {\n\t\t\t\tif msg.String() != \"\" {\n\t\t\t\t\tprompt = msg.String()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// If still no prompt, try direct prompt field\n\tif prompt == \"\" {\n\t\tdirectPrompt := gjson.GetBytes(payload, \"prompt\")\n\t\tif directPrompt.Exists() {\n\t\t\tprompt = directPrompt.String()\n\t\t}\n\t}\n\n\tif prompt == \"\" {\n\t\treturn nil, fmt.Errorf(\"imagen: no prompt found in request\")\n\t}\n\n\t// Build Imagen API request\n\timagenReq := map[string]any{\n\t\t\"instances\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"prompt\": prompt,\n\t\t\t},\n\t\t},\n\t\t\"parameters\": map[string]any{\n\t\t\t\"sampleCount\": 1,\n\t\t},\n\t}\n\n\t// Extract optional parameters\n\tif aspectRatio := gjson.GetBytes(payload, \"aspectRatio\"); aspectRatio.Exists() {\n\t\timagenReq[\"parameters\"].(map[string]any)[\"aspectRatio\"] = aspectRatio.String()\n\t}\n\tif sampleCount := gjson.GetBytes(payload, \"sampleCount\"); sampleCount.Exists() {\n\t\timagenReq[\"parameters\"].(map[string]any)[\"sampleCount\"] = int(sampleCount.Int())\n\t}\n\tif negativePrompt := gjson.GetBytes(payload, \"negativePrompt\"); negativePrompt.Exists() {\n\t\timagenReq[\"instances\"].([]map[string]any)[0][\"negativePrompt\"] = negativePrompt.String()\n\t}\n\n\treturn json.Marshal(imagenReq)\n}\n\n// GeminiVertexExecutor sends requests to Vertex AI Gemini endpoints using service account credentials.\ntype GeminiVertexExecutor struct {\n\tcfg *config.Config\n}\n\n// NewGeminiVertexExecutor creates a new Vertex AI Gemini executor instance.\n//\n// Parameters:\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *GeminiVertexExecutor: A new Vertex AI Gemini executor instance\nfunc NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor {\n\treturn &GeminiVertexExecutor{cfg: cfg}\n}\n\n// Identifier returns the executor identifier.\nfunc (e *GeminiVertexExecutor) Identifier() string { return \"vertex\" }\n\n// PrepareRequest injects Vertex credentials into the outgoing HTTP request.\nfunc (e *GeminiVertexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\tapiKey, _ := vertexAPICreds(auth)\n\tif strings.TrimSpace(apiKey) != \"\" {\n\t\treq.Header.Set(\"x-goog-api-key\", apiKey)\n\t\treq.Header.Del(\"Authorization\")\n\t\treturn nil\n\t}\n\t_, _, saJSON, errCreds := vertexCreds(auth)\n\tif errCreds != nil {\n\t\treturn errCreds\n\t}\n\ttoken, errToken := vertexAccessToken(req.Context(), e.cfg, auth, saJSON)\n\tif errToken != nil {\n\t\treturn errToken\n\t}\n\tif strings.TrimSpace(token) == \"\" {\n\t\treturn statusErr{code: http.StatusUnauthorized, msg: \"missing access token\"}\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\treq.Header.Del(\"x-goog-api-key\")\n\treturn nil\n}\n\n// HttpRequest injects Vertex credentials into the request and executes it.\nfunc (e *GeminiVertexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"vertex executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming request to the Vertex AI API.\nfunc (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\t// Try API key authentication first\n\tapiKey, baseURL := vertexAPICreds(auth)\n\n\t// If no API key found, fall back to service account authentication\n\tif apiKey == \"\" {\n\t\tprojectID, location, saJSON, errCreds := vertexCreds(auth)\n\t\tif errCreds != nil {\n\t\t\treturn resp, errCreds\n\t\t}\n\t\treturn e.executeWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)\n\t}\n\n\t// Use API key authentication\n\treturn e.executeWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)\n}\n\n// ExecuteStream performs a streaming request to the Vertex AI API.\nfunc (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\t// Try API key authentication first\n\tapiKey, baseURL := vertexAPICreds(auth)\n\n\t// If no API key found, fall back to service account authentication\n\tif apiKey == \"\" {\n\t\tprojectID, location, saJSON, errCreds := vertexCreds(auth)\n\t\tif errCreds != nil {\n\t\t\treturn nil, errCreds\n\t\t}\n\t\treturn e.executeStreamWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)\n\t}\n\n\t// Use API key authentication\n\treturn e.executeStreamWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)\n}\n\n// CountTokens counts tokens for the given request using the Vertex AI API.\nfunc (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\t// Try API key authentication first\n\tapiKey, baseURL := vertexAPICreds(auth)\n\n\t// If no API key found, fall back to service account authentication\n\tif apiKey == \"\" {\n\t\tprojectID, location, saJSON, errCreds := vertexCreds(auth)\n\t\tif errCreds != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errCreds\n\t\t}\n\t\treturn e.countTokensWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)\n\t}\n\n\t// Use API key authentication\n\treturn e.countTokensWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)\n}\n\n// Refresh refreshes the authentication credentials (no-op for Vertex).\nfunc (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\treturn auth, nil\n}\n\n// executeWithServiceAccount handles authentication using service account credentials.\n// This method contains the original service account authentication logic.\nfunc (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (resp cliproxyexecutor.Response, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tvar body []byte\n\n\t// Handle Imagen models with special request format\n\tif isImagenModel(baseModel) {\n\t\timagenBody, errImagen := convertToImagenRequest(req.Payload)\n\t\tif errImagen != nil {\n\t\t\treturn resp, errImagen\n\t\t}\n\t\tbody = imagenBody\n\t} else {\n\t\t// Standard Gemini translation flow\n\t\tfrom := opts.SourceFormat\n\t\tto := sdktranslator.FromString(\"gemini\")\n\n\t\toriginalPayloadSource := req.Payload\n\t\tif len(opts.OriginalRequest) > 0 {\n\t\t\toriginalPayloadSource = opts.OriginalRequest\n\t\t}\n\t\toriginalPayload := originalPayloadSource\n\t\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\t\tbody = sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\t\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\t\tif err != nil {\n\t\t\treturn resp, err\n\t\t}\n\n\t\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\t\trequestedModel := payloadRequestedModel(opts, req.Model)\n\t\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\t\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\t}\n\n\taction := getVertexAction(baseModel, false)\n\tif req.Metadata != nil {\n\t\tif a, _ := req.Metadata[\"action\"].(string); a == \"countTokens\" {\n\t\t\taction = \"countTokens\"\n\t\t}\n\t}\n\tbaseURL := vertexBaseURL(location)\n\turl := fmt.Sprintf(\"%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, projectID, location, baseModel, action)\n\tif opts.Alt != \"\" && action != \"countTokens\" {\n\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t}\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif errNewReq != nil {\n\t\treturn resp, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t} else if errTok != nil {\n\t\tlog.Errorf(\"vertex executor: access token error: %v\", errTok)\n\t\treturn resp, statusErr{code: 500, msg: \"internal server error\"}\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn resp, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\tdata, errRead := io.ReadAll(httpResp.Body)\n\tif errRead != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\treturn resp, errRead\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseGeminiUsage(data))\n\n\t// For Imagen models, convert response to Gemini format before translation\n\t// This ensures Imagen responses use the same format as gemini-3-pro-image-preview\n\tif isImagenModel(baseModel) {\n\t\tdata = convertImagenToGeminiResponse(data, baseModel)\n\t}\n\n\t// Standard Gemini translation (works for both Gemini and converted Imagen responses)\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\n// executeWithAPIKey handles authentication using API key credentials.\nfunc (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (resp cliproxyexecutor.Response, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\taction := getVertexAction(baseModel, false)\n\tif req.Metadata != nil {\n\t\tif a, _ := req.Metadata[\"action\"].(string); a == \"countTokens\" {\n\t\t\taction = \"countTokens\"\n\t\t}\n\t}\n\n\t// For API key auth, use simpler URL format without project/location\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://aiplatform.googleapis.com\"\n\t}\n\turl := fmt.Sprintf(\"%s/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, baseModel, action)\n\tif opts.Alt != \"\" && action != \"countTokens\" {\n\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t}\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif errNewReq != nil {\n\t\treturn resp, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn resp, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\tdata, errRead := io.ReadAll(httpResp.Body)\n\tif errRead != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\treturn resp, errRead\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseGeminiUsage(data))\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\n// executeStreamWithServiceAccount handles streaming authentication using service account credentials.\nfunc (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (_ *cliproxyexecutor.StreamResult, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\taction := getVertexAction(baseModel, true)\n\tbaseURL := vertexBaseURL(location)\n\turl := fmt.Sprintf(\"%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, projectID, location, baseModel, action)\n\t// Imagen models don't support streaming, skip SSE params\n\tif !isImagenModel(baseModel) {\n\t\tif opts.Alt == \"\" {\n\t\t\turl = url + \"?alt=sse\"\n\t\t} else {\n\t\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t\t}\n\t}\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif errNewReq != nil {\n\t\treturn nil, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t} else if errTok != nil {\n\t\tlog.Errorf(\"vertex executor: access token error: %v\", errTok)\n\t\treturn nil, statusErr{code: 500, msg: \"internal server error\"}\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn nil, errDo\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t\treturn nil, statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t}\n\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseGeminiStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)\n\t\t\tfor i := range lines {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t\t}\n\t\t}\n\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte(\"[DONE]\"), &param)\n\t\tfor i := range lines {\n\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\n// executeStreamWithAPIKey handles streaming authentication using API key credentials.\nfunc (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (_ *cliproxyexecutor.StreamResult, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody = fixGeminiImageAspectRatio(baseModel, body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\taction := getVertexAction(baseModel, true)\n\t// For API key auth, use simpler URL format without project/location\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://aiplatform.googleapis.com\"\n\t}\n\turl := fmt.Sprintf(\"%s/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, baseModel, action)\n\t// Imagen models don't support streaming, skip SSE params\n\tif !isImagenModel(baseModel) {\n\t\tif opts.Alt == \"\" {\n\t\t\turl = url + \"?alt=sse\"\n\t\t} else {\n\t\t\turl = url + fmt.Sprintf(\"?$alt=%s\", opts.Alt)\n\t\t}\n\t}\n\tbody, _ = sjson.DeleteBytes(body, \"session_id\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif errNewReq != nil {\n\t\treturn nil, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn nil, errDo\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t\treturn nil, statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t}\n\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, streamScannerBuffer)\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseGeminiStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)\n\t\t\tfor i := range lines {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t\t}\n\t\t}\n\t\tlines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte(\"[DONE]\"), &param)\n\t\tfor i := range lines {\n\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\n// countTokensWithServiceAccount counts tokens using service account credentials.\nfunc (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\n\ttranslatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\ttranslatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\ttranslatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)\n\ttranslatedReq, _ = sjson.SetBytes(translatedReq, \"model\", baseModel)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"tools\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"generationConfig\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"safetySettings\")\n\n\tbaseURL := vertexBaseURL(location)\n\turl := fmt.Sprintf(\"%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, projectID, location, baseModel, \"countTokens\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))\n\tif errNewReq != nil {\n\t\treturn cliproxyexecutor.Response{}, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t} else if errTok != nil {\n\t\tlog.Errorf(\"vertex executor: access token error: %v\", errTok)\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: 500, msg: \"internal server error\"}\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      translatedReq,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn cliproxyexecutor.Response{}, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t}\n\tdata, errRead := io.ReadAll(httpResp.Body)\n\tif errRead != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\treturn cliproxyexecutor.Response{}, errRead\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\tcount := gjson.GetBytes(data, \"totalTokens\").Int()\n\tout := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)\n\treturn cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}, nil\n}\n\n// countTokensWithAPIKey handles token counting using API key credentials.\nfunc (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"gemini\")\n\n\ttranslatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\ttranslatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\ttranslatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)\n\ttranslatedReq, _ = sjson.SetBytes(translatedReq, \"model\", baseModel)\n\trespCtx := context.WithValue(ctx, \"alt\", opts.Alt)\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"tools\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"generationConfig\")\n\ttranslatedReq, _ = sjson.DeleteBytes(translatedReq, \"safetySettings\")\n\n\t// For API key auth, use simpler URL format without project/location\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://aiplatform.googleapis.com\"\n\t}\n\turl := fmt.Sprintf(\"%s/%s/publishers/google/models/%s:%s\", baseURL, vertexAPIVersion, baseModel, \"countTokens\")\n\n\thttpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))\n\tif errNewReq != nil {\n\t\treturn cliproxyexecutor.Response{}, errNewReq\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"x-goog-api-key\", apiKey)\n\t}\n\tapplyGeminiHeaders(httpReq, auth)\n\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      translatedReq,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, errDo := httpClient.Do(httpReq)\n\tif errDo != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errDo)\n\t\treturn cliproxyexecutor.Response{}, errDo\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"vertex executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\treturn cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t}\n\tdata, errRead := io.ReadAll(httpResp.Body)\n\tif errRead != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, errRead)\n\t\treturn cliproxyexecutor.Response{}, errRead\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\tcount := gjson.GetBytes(data, \"totalTokens\").Int()\n\tout := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)\n\treturn cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}, nil\n}\n\n// vertexCreds extracts project, location and raw service account JSON from auth metadata.\nfunc vertexCreds(a *cliproxyauth.Auth) (projectID, location string, serviceAccountJSON []byte, err error) {\n\tif a == nil || a.Metadata == nil {\n\t\treturn \"\", \"\", nil, fmt.Errorf(\"vertex executor: missing auth metadata\")\n\t}\n\tif v, ok := a.Metadata[\"project_id\"].(string); ok {\n\t\tprojectID = strings.TrimSpace(v)\n\t}\n\tif projectID == \"\" {\n\t\t// Some service accounts may use \"project\"; still prefer standard field\n\t\tif v, ok := a.Metadata[\"project\"].(string); ok {\n\t\t\tprojectID = strings.TrimSpace(v)\n\t\t}\n\t}\n\tif projectID == \"\" {\n\t\treturn \"\", \"\", nil, fmt.Errorf(\"vertex executor: missing project_id in credentials\")\n\t}\n\tif v, ok := a.Metadata[\"location\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\tlocation = strings.TrimSpace(v)\n\t} else {\n\t\tlocation = \"us-central1\"\n\t}\n\tvar sa map[string]any\n\tif raw, ok := a.Metadata[\"service_account\"].(map[string]any); ok {\n\t\tsa = raw\n\t}\n\tif sa == nil {\n\t\treturn \"\", \"\", nil, fmt.Errorf(\"vertex executor: missing service_account in credentials\")\n\t}\n\tnormalized, errNorm := vertexauth.NormalizeServiceAccountMap(sa)\n\tif errNorm != nil {\n\t\treturn \"\", \"\", nil, fmt.Errorf(\"vertex executor: %w\", errNorm)\n\t}\n\tsaJSON, errMarshal := json.Marshal(normalized)\n\tif errMarshal != nil {\n\t\treturn \"\", \"\", nil, fmt.Errorf(\"vertex executor: marshal service_account failed: %w\", errMarshal)\n\t}\n\treturn projectID, location, saJSON, nil\n}\n\n// vertexAPICreds extracts API key and base URL from auth attributes following the claudeCreds pattern.\nfunc vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tapiKey = a.Attributes[\"api_key\"]\n\t\tbaseURL = a.Attributes[\"base_url\"]\n\t}\n\tif apiKey == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok {\n\t\t\tapiKey = v\n\t\t}\n\t}\n\treturn\n}\n\nfunc vertexBaseURL(location string) string {\n\tloc := strings.TrimSpace(location)\n\tif loc == \"\" {\n\t\tloc = \"us-central1\"\n\t} else if loc == \"global\" {\n\t\treturn \"https://aiplatform.googleapis.com\"\n\t}\n\treturn fmt.Sprintf(\"https://%s-aiplatform.googleapis.com\", loc)\n}\n\nfunc vertexAccessToken(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, saJSON []byte) (string, error) {\n\tif httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {\n\t\tctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)\n\t}\n\t// Use cloud-platform scope for Vertex AI.\n\tcreds, errCreds := google.CredentialsFromJSON(ctx, saJSON, \"https://www.googleapis.com/auth/cloud-platform\")\n\tif errCreds != nil {\n\t\treturn \"\", fmt.Errorf(\"vertex executor: parse service account json failed: %w\", errCreds)\n\t}\n\ttok, errTok := creds.TokenSource.Token()\n\tif errTok != nil {\n\t\treturn \"\", fmt.Errorf(\"vertex executor: get access token failed: %w\", errTok)\n\t}\n\treturn tok.AccessToken, nil\n}\n\n// resolveVertexConfig finds the matching vertex-api-key configuration entry for the given auth.\nfunc (e *GeminiVertexExecutor) resolveVertexConfig(auth *cliproxyauth.Auth) *config.VertexCompatKey {\n\tif auth == nil || e.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range e.cfg.VertexCompatAPIKey {\n\t\tentry := &e.cfg.VertexCompatAPIKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && attrBase != \"\" {\n\t\t\tif strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range e.cfg.VertexCompatAPIKey {\n\t\t\tentry := &e.cfg.VertexCompatAPIKey[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/runtime/executor/iflow_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\tiflowauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst (\n\tiflowDefaultEndpoint = \"/chat/completions\"\n\tiflowUserAgent       = \"iFlow-Cli\"\n)\n\n// IFlowExecutor executes OpenAI-compatible chat completions against the iFlow API using API keys derived from OAuth.\ntype IFlowExecutor struct {\n\tcfg *config.Config\n}\n\n// NewIFlowExecutor constructs a new executor instance.\nfunc NewIFlowExecutor(cfg *config.Config) *IFlowExecutor { return &IFlowExecutor{cfg: cfg} }\n\n// Identifier returns the provider key.\nfunc (e *IFlowExecutor) Identifier() string { return \"iflow\" }\n\n// PrepareRequest injects iFlow credentials into the outgoing HTTP request.\nfunc (e *IFlowExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\tapiKey, _ := iflowCreds(auth)\n\tif strings.TrimSpace(apiKey) != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\treturn nil\n}\n\n// HttpRequest injects iFlow credentials into the request and executes it.\nfunc (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"iflow executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming chat completion request.\nfunc (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := iflowCreds(auth)\n\tif strings.TrimSpace(apiKey) == \"\" {\n\t\terr = fmt.Errorf(\"iflow executor: missing api key\")\n\t\treturn resp, err\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = iflowauth.DefaultAPIBaseURL\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), \"iflow\", e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\tbody = preserveReasoningContentInMessages(body)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\tendpoint := strings.TrimSuffix(baseURL, \"/\") + iflowDefaultEndpoint\n\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyIFlowHeaders(httpReq, apiKey, false)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       endpoint,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"iflow executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseOpenAIUsage(data))\n\t// Ensure usage is recorded even if upstream omits usage metadata.\n\treporter.ensurePublished(ctx)\n\n\tvar param any\n\t// Note: TranslateNonStream uses req.Model (original with suffix) to preserve\n\t// the original model name in the response for client compatibility.\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\n// ExecuteStream performs a streaming chat completion request.\nfunc (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tapiKey, baseURL := iflowCreds(auth)\n\tif strings.TrimSpace(apiKey) == \"\" {\n\t\terr = fmt.Errorf(\"iflow executor: missing api key\")\n\t\treturn nil, err\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = iflowauth.DefaultAPIBaseURL\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), \"iflow\", e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody = preserveReasoningContentInMessages(body)\n\t// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.\n\ttoolsResult := gjson.GetBytes(body, \"tools\")\n\tif toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {\n\t\tbody = ensureToolsArray(body)\n\t}\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\tendpoint := strings.TrimSuffix(baseURL, \"/\") + iflowDefaultEndpoint\n\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapplyIFlowHeaders(httpReq, apiKey, true)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       endpoint,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tdata, _ := io.ReadAll(httpResp.Body)\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"iflow executor: close response body error: %v\", errClose)\n\t\t}\n\t\tappendAPIResponseChunk(ctx, e.cfg, data)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), data))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(data)}\n\t\treturn nil, err\n\t}\n\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"iflow executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseOpenAIStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t\t// Guarantee a usage record exists even if the stream never emitted usage data.\n\t\treporter.ensurePublished(ctx)\n\t}()\n\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\nfunc (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tenc, err := tokenizerForModel(baseModel)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"iflow executor: tokenizer init failed: %w\", err)\n\t}\n\n\tcount, err := countOpenAIChatTokens(enc, body)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"iflow executor: token counting failed: %w\", err)\n\t}\n\n\tusageJSON := buildOpenAIUsageJSON(count)\n\ttranslated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)\n\treturn cliproxyexecutor.Response{Payload: []byte(translated)}, nil\n}\n\n// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key.\nfunc (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"iflow executor: refresh called\")\n\tif auth == nil {\n\t\treturn nil, fmt.Errorf(\"iflow executor: auth is nil\")\n\t}\n\n\t// Check if this is cookie-based authentication\n\tvar cookie string\n\tvar email string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"cookie\"].(string); ok {\n\t\t\tcookie = strings.TrimSpace(v)\n\t\t}\n\t\tif v, ok := auth.Metadata[\"email\"].(string); ok {\n\t\t\temail = strings.TrimSpace(v)\n\t\t}\n\t}\n\n\t// If cookie is present, use cookie-based refresh\n\tif cookie != \"\" && email != \"\" {\n\t\treturn e.refreshCookieBased(ctx, auth, cookie, email)\n\t}\n\n\t// Otherwise, use OAuth-based refresh\n\treturn e.refreshOAuthBased(ctx, auth)\n}\n\n// refreshCookieBased refreshes API key using browser cookie\nfunc (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyauth.Auth, cookie, email string) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"iflow executor: checking refresh need for cookie-based API key for user: %s\", email)\n\n\t// Get current expiry time from metadata\n\tvar currentExpire string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"expired\"].(string); ok {\n\t\t\tcurrentExpire = strings.TrimSpace(v)\n\t\t}\n\t}\n\n\t// Check if refresh is needed\n\tneedsRefresh, _, err := iflowauth.ShouldRefreshAPIKey(currentExpire)\n\tif err != nil {\n\t\tlog.Warnf(\"iflow executor: failed to check refresh need: %v\", err)\n\t\t// If we can't check, continue with refresh anyway as a safety measure\n\t} else if !needsRefresh {\n\t\tlog.Debugf(\"iflow executor: no refresh needed for user: %s\", email)\n\t\treturn auth, nil\n\t}\n\n\tlog.Infof(\"iflow executor: refreshing cookie-based API key for user: %s\", email)\n\n\tsvc := iflowauth.NewIFlowAuth(e.cfg)\n\tkeyData, err := svc.RefreshAPIKey(ctx, cookie, email)\n\tif err != nil {\n\t\tlog.Errorf(\"iflow executor: cookie-based API key refresh failed: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"api_key\"] = keyData.APIKey\n\tauth.Metadata[\"expired\"] = keyData.ExpireTime\n\tauth.Metadata[\"type\"] = \"iflow\"\n\tauth.Metadata[\"last_refresh\"] = time.Now().Format(time.RFC3339)\n\tauth.Metadata[\"cookie\"] = cookie\n\tauth.Metadata[\"email\"] = email\n\n\tlog.Infof(\"iflow executor: cookie-based API key refreshed successfully, new expiry: %s\", keyData.ExpireTime)\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tauth.Attributes[\"api_key\"] = keyData.APIKey\n\n\treturn auth, nil\n}\n\n// refreshOAuthBased refreshes tokens using OAuth refresh token\nfunc (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\trefreshToken := \"\"\n\toldAccessToken := \"\"\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"refresh_token\"].(string); ok {\n\t\t\trefreshToken = strings.TrimSpace(v)\n\t\t}\n\t\tif v, ok := auth.Metadata[\"access_token\"].(string); ok {\n\t\t\toldAccessToken = strings.TrimSpace(v)\n\t\t}\n\t}\n\tif refreshToken == \"\" {\n\t\treturn auth, nil\n\t}\n\n\t// Log the old access token (masked) before refresh\n\tif oldAccessToken != \"\" {\n\t\tlog.Debugf(\"iflow executor: refreshing access token, old: %s\", util.HideAPIKey(oldAccessToken))\n\t}\n\n\tsvc := iflowauth.NewIFlowAuth(e.cfg)\n\ttokenData, err := svc.RefreshTokens(ctx, refreshToken)\n\tif err != nil {\n\t\tlog.Errorf(\"iflow executor: token refresh failed: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"access_token\"] = tokenData.AccessToken\n\tif tokenData.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = tokenData.RefreshToken\n\t}\n\tif tokenData.APIKey != \"\" {\n\t\tauth.Metadata[\"api_key\"] = tokenData.APIKey\n\t}\n\tauth.Metadata[\"expired\"] = tokenData.Expire\n\tauth.Metadata[\"type\"] = \"iflow\"\n\tauth.Metadata[\"last_refresh\"] = time.Now().Format(time.RFC3339)\n\n\t// Log the new access token (masked) after successful refresh\n\tlog.Debugf(\"iflow executor: token refresh successful, new: %s\", util.HideAPIKey(tokenData.AccessToken))\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tif tokenData.APIKey != \"\" {\n\t\tauth.Attributes[\"api_key\"] = tokenData.APIKey\n\t}\n\n\treturn auth, nil\n}\n\nfunc applyIFlowHeaders(r *http.Request, apiKey string, stream bool) {\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\tr.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\tr.Header.Set(\"User-Agent\", iflowUserAgent)\n\n\t// Generate session-id\n\tsessionID := \"session-\" + generateUUID()\n\tr.Header.Set(\"session-id\", sessionID)\n\n\t// Generate timestamp and signature\n\ttimestamp := time.Now().UnixMilli()\n\tr.Header.Set(\"x-iflow-timestamp\", fmt.Sprintf(\"%d\", timestamp))\n\n\tsignature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey)\n\tif signature != \"\" {\n\t\tr.Header.Set(\"x-iflow-signature\", signature)\n\t}\n\n\tif stream {\n\t\tr.Header.Set(\"Accept\", \"text/event-stream\")\n\t} else {\n\t\tr.Header.Set(\"Accept\", \"application/json\")\n\t}\n}\n\n// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests.\n// The signature payload format is: userAgent:sessionId:timestamp\nfunc createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string {\n\tif apiKey == \"\" {\n\t\treturn \"\"\n\t}\n\tpayload := fmt.Sprintf(\"%s:%s:%d\", userAgent, sessionID, timestamp)\n\th := hmac.New(sha256.New, []byte(apiKey))\n\th.Write([]byte(payload))\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\n// generateUUID generates a random UUID v4 string.\nfunc generateUUID() string {\n\treturn uuid.New().String()\n}\n\nfunc iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tif v := strings.TrimSpace(a.Attributes[\"api_key\"]); v != \"\" {\n\t\t\tapiKey = v\n\t\t}\n\t\tif v := strings.TrimSpace(a.Attributes[\"base_url\"]); v != \"\" {\n\t\t\tbaseURL = v\n\t\t}\n\t}\n\tif apiKey == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"api_key\"].(string); ok {\n\t\t\tapiKey = strings.TrimSpace(v)\n\t\t}\n\t}\n\tif baseURL == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"base_url\"].(string); ok {\n\t\t\tbaseURL = strings.TrimSpace(v)\n\t\t}\n\t}\n\treturn apiKey, baseURL\n}\n\nfunc ensureToolsArray(body []byte) []byte {\n\tplaceholder := `[{\"type\":\"function\",\"function\":{\"name\":\"noop\",\"description\":\"Placeholder tool to stabilise streaming\",\"parameters\":{\"type\":\"object\"}}}]`\n\tupdated, err := sjson.SetRawBytes(body, \"tools\", []byte(placeholder))\n\tif err != nil {\n\t\treturn body\n\t}\n\treturn updated\n}\n\n// preserveReasoningContentInMessages checks if reasoning_content from assistant messages\n// is preserved in conversation history for iFlow models that support thinking.\n// This is helpful for multi-turn conversations where the model may benefit from seeing\n// its previous reasoning to maintain coherent thought chains.\n//\n// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant\n// response (including reasoning_content) in message history for better context continuity.\nfunc preserveReasoningContentInMessages(body []byte) []byte {\n\tmodel := strings.ToLower(gjson.GetBytes(body, \"model\").String())\n\n\t// Only apply to models that support thinking with history preservation\n\tneedsPreservation := strings.HasPrefix(model, \"glm-4\") || strings.HasPrefix(model, \"minimax-m2\")\n\n\tif !needsPreservation {\n\t\treturn body\n\t}\n\n\tmessages := gjson.GetBytes(body, \"messages\")\n\tif !messages.Exists() || !messages.IsArray() {\n\t\treturn body\n\t}\n\n\t// Check if any assistant message already has reasoning_content preserved\n\thasReasoningContent := false\n\tmessages.ForEach(func(_, msg gjson.Result) bool {\n\t\trole := msg.Get(\"role\").String()\n\t\tif role == \"assistant\" {\n\t\t\trc := msg.Get(\"reasoning_content\")\n\t\t\tif rc.Exists() && rc.String() != \"\" {\n\t\t\t\thasReasoningContent = true\n\t\t\t\treturn false // stop iteration\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\t// If reasoning content is already present, the messages are properly formatted\n\t// No need to modify - the client has correctly preserved reasoning in history\n\tif hasReasoningContent {\n\t\tlog.Debugf(\"iflow executor: reasoning_content found in message history for %s\", model)\n\t}\n\n\treturn body\n}\n"
  },
  {
    "path": "internal/runtime/executor/iflow_executor_test.go",
    "content": "package executor\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n)\n\nfunc TestIFlowExecutorParseSuffix(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tmodel     string\n\t\twantBase  string\n\t\twantLevel string\n\t}{\n\t\t{\"no suffix\", \"glm-4\", \"glm-4\", \"\"},\n\t\t{\"glm with suffix\", \"glm-4.1-flash(high)\", \"glm-4.1-flash\", \"high\"},\n\t\t{\"minimax no suffix\", \"minimax-m2\", \"minimax-m2\", \"\"},\n\t\t{\"minimax with suffix\", \"minimax-m2.1(medium)\", \"minimax-m2.1\", \"medium\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := thinking.ParseSuffix(tt.model)\n\t\t\tif result.ModelName != tt.wantBase {\n\t\t\t\tt.Errorf(\"ParseSuffix(%q).ModelName = %q, want %q\", tt.model, result.ModelName, tt.wantBase)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPreserveReasoningContentInMessages(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput []byte\n\t\twant  []byte // nil means output should equal input\n\t}{\n\t\t{\n\t\t\t\"non-glm model passthrough\",\n\t\t\t[]byte(`{\"model\":\"gpt-4\",\"messages\":[]}`),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"glm model with empty messages\",\n\t\t\t[]byte(`{\"model\":\"glm-4\",\"messages\":[]}`),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"glm model preserves existing reasoning_content\",\n\t\t\t[]byte(`{\"model\":\"glm-4\",\"messages\":[{\"role\":\"assistant\",\"content\":\"hi\",\"reasoning_content\":\"thinking...\"}]}`),\n\t\t\tnil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := preserveReasoningContentInMessages(tt.input)\n\t\t\twant := tt.want\n\t\t\tif want == nil {\n\t\t\t\twant = tt.input\n\t\t\t}\n\t\t\tif string(got) != string(want) {\n\t\t\t\tt.Errorf(\"preserveReasoningContentInMessages() = %s, want %s\", got, want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/kimi_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\tkimiauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions.\ntype KimiExecutor struct {\n\tClaudeExecutor\n\tcfg *config.Config\n}\n\n// NewKimiExecutor creates a new Kimi executor.\nfunc NewKimiExecutor(cfg *config.Config) *KimiExecutor { return &KimiExecutor{cfg: cfg} }\n\n// Identifier returns the executor identifier.\nfunc (e *KimiExecutor) Identifier() string { return \"kimi\" }\n\n// PrepareRequest injects Kimi credentials into the outgoing HTTP request.\nfunc (e *KimiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\ttoken := kimiCreds(auth)\n\tif strings.TrimSpace(token) != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t}\n\treturn nil\n}\n\n// HttpRequest injects Kimi credentials into the request and executes it.\nfunc (e *KimiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"kimi executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\n// Execute performs a non-streaming chat completion request to Kimi.\nfunc (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tfrom := opts.SourceFormat\n\tif from.String() == \"claude\" {\n\t\tauth.Attributes[\"base_url\"] = kimiauth.KimiAPIBaseURL\n\t\treturn e.ClaudeExecutor.Execute(ctx, auth, req, opts)\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttoken := kimiCreds(auth)\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := bytes.Clone(originalPayloadSource)\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)\n\n\t// Strip kimi- prefix for upstream API\n\tupstreamModel := stripKimiPrefix(baseModel)\n\tbody, err = sjson.SetBytes(body, \"model\", upstreamModel)\n\tif err != nil {\n\t\treturn resp, fmt.Errorf(\"kimi executor: failed to set model in payload: %w\", err)\n\t}\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), \"kimi\", e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, err = normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\turl := kimiauth.KimiAPIBaseURL + \"/v1/chat/completions\"\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyKimiHeadersWithAuth(httpReq, token, false, auth)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"kimi executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseOpenAIUsage(data))\n\tvar param any\n\t// Note: TranslateNonStream uses req.Model (original with suffix) to preserve\n\t// the original model name in the response for client compatibility.\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\n// ExecuteStream performs a streaming chat completion request to Kimi.\nfunc (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tfrom := opts.SourceFormat\n\tif from.String() == \"claude\" {\n\t\tauth.Attributes[\"base_url\"] = kimiauth.KimiAPIBaseURL\n\t\treturn e.ClaudeExecutor.ExecuteStream(ctx, auth, req, opts)\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\ttoken := kimiCreds(auth)\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := bytes.Clone(originalPayloadSource)\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)\n\n\t// Strip kimi- prefix for upstream API\n\tupstreamModel := stripKimiPrefix(baseModel)\n\tbody, err = sjson.SetBytes(body, \"model\", upstreamModel)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi executor: failed to set model in payload: %w\", err)\n\t}\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), \"kimi\", e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody, err = sjson.SetBytes(body, \"stream_options.include_usage\", true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi executor: failed to set stream_options in payload: %w\", err)\n\t}\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\tbody, err = normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\turl := kimiauth.KimiAPIBaseURL + \"/v1/chat/completions\"\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapplyKimiHeadersWithAuth(httpReq, token, true, auth)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"kimi executor: close response body error: %v\", errClose)\n\t\t}\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"kimi executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, 1_048_576) // 1MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseOpenAIStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tdoneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte(\"[DONE]\"), &param)\n\t\tfor i := range doneChunks {\n\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\n// CountTokens estimates token count for Kimi requests.\nfunc (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tauth.Attributes[\"base_url\"] = kimiauth.KimiAPIBaseURL\n\treturn e.ClaudeExecutor.CountTokens(ctx, auth, req, opts)\n}\n\nfunc normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\treturn body, nil\n\t}\n\n\tmessages := gjson.GetBytes(body, \"messages\")\n\tif !messages.Exists() || !messages.IsArray() {\n\t\treturn body, nil\n\t}\n\n\tout := body\n\tpending := make([]string, 0)\n\tpatched := 0\n\tpatchedReasoning := 0\n\tambiguous := 0\n\tlatestReasoning := \"\"\n\thasLatestReasoning := false\n\n\tremovePending := func(id string) {\n\t\tfor idx := range pending {\n\t\t\tif pending[idx] != id {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpending = append(pending[:idx], pending[idx+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsgs := messages.Array()\n\tfor msgIdx := range msgs {\n\t\tmsg := msgs[msgIdx]\n\t\trole := strings.TrimSpace(msg.Get(\"role\").String())\n\t\tswitch role {\n\t\tcase \"assistant\":\n\t\t\treasoning := msg.Get(\"reasoning_content\")\n\t\t\tif reasoning.Exists() {\n\t\t\t\treasoningText := reasoning.String()\n\t\t\t\tif strings.TrimSpace(reasoningText) != \"\" {\n\t\t\t\t\tlatestReasoning = reasoningText\n\t\t\t\t\thasLatestReasoning = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttoolCalls := msg.Get(\"tool_calls\")\n\t\t\tif !toolCalls.Exists() || !toolCalls.IsArray() || len(toolCalls.Array()) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !reasoning.Exists() || strings.TrimSpace(reasoning.String()) == \"\" {\n\t\t\t\treasoningText := fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning)\n\t\t\t\tpath := fmt.Sprintf(\"messages.%d.reasoning_content\", msgIdx)\n\t\t\t\tnext, err := sjson.SetBytes(out, path, reasoningText)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn body, fmt.Errorf(\"kimi executor: failed to set assistant reasoning_content: %w\", err)\n\t\t\t\t}\n\t\t\t\tout = next\n\t\t\t\tpatchedReasoning++\n\t\t\t}\n\n\t\t\tfor _, tc := range toolCalls.Array() {\n\t\t\t\tid := strings.TrimSpace(tc.Get(\"id\").String())\n\t\t\t\tif id == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tpending = append(pending, id)\n\t\t\t}\n\t\tcase \"tool\":\n\t\t\ttoolCallID := strings.TrimSpace(msg.Get(\"tool_call_id\").String())\n\t\t\tif toolCallID == \"\" {\n\t\t\t\ttoolCallID = strings.TrimSpace(msg.Get(\"call_id\").String())\n\t\t\t\tif toolCallID != \"\" {\n\t\t\t\t\tpath := fmt.Sprintf(\"messages.%d.tool_call_id\", msgIdx)\n\t\t\t\t\tnext, err := sjson.SetBytes(out, path, toolCallID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn body, fmt.Errorf(\"kimi executor: failed to set tool_call_id from call_id: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tout = next\n\t\t\t\t\tpatched++\n\t\t\t\t}\n\t\t\t}\n\t\t\tif toolCallID == \"\" {\n\t\t\t\tif len(pending) == 1 {\n\t\t\t\t\ttoolCallID = pending[0]\n\t\t\t\t\tpath := fmt.Sprintf(\"messages.%d.tool_call_id\", msgIdx)\n\t\t\t\t\tnext, err := sjson.SetBytes(out, path, toolCallID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn body, fmt.Errorf(\"kimi executor: failed to infer tool_call_id: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tout = next\n\t\t\t\t\tpatched++\n\t\t\t\t} else if len(pending) > 1 {\n\t\t\t\t\tambiguous++\n\t\t\t\t}\n\t\t\t}\n\t\t\tif toolCallID != \"\" {\n\t\t\t\tremovePending(toolCallID)\n\t\t\t}\n\t\t}\n\t}\n\n\tif patched > 0 || patchedReasoning > 0 {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"patched_tool_messages\":      patched,\n\t\t\t\"patched_reasoning_messages\": patchedReasoning,\n\t\t}).Debug(\"kimi executor: normalized tool message fields\")\n\t}\n\tif ambiguous > 0 {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"ambiguous_tool_messages\": ambiguous,\n\t\t\t\"pending_tool_calls\":      len(pending),\n\t\t}).Warn(\"kimi executor: tool messages missing tool_call_id with ambiguous candidates\")\n\t}\n\n\treturn out, nil\n}\n\nfunc fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {\n\tif hasLatest && strings.TrimSpace(latest) != \"\" {\n\t\treturn latest\n\t}\n\n\tcontent := msg.Get(\"content\")\n\tif content.Type == gjson.String {\n\t\tif text := strings.TrimSpace(content.String()); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t}\n\tif content.IsArray() {\n\t\tparts := make([]string, 0, len(content.Array()))\n\t\tfor _, item := range content.Array() {\n\t\t\ttext := strings.TrimSpace(item.Get(\"text\").String())\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparts = append(parts, text)\n\t\t}\n\t\tif len(parts) > 0 {\n\t\t\treturn strings.Join(parts, \"\\n\")\n\t\t}\n\t}\n\n\treturn \"[reasoning unavailable]\"\n}\n\n// Refresh refreshes the Kimi token using the refresh token.\nfunc (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"kimi executor: refresh called\")\n\tif auth == nil {\n\t\treturn nil, fmt.Errorf(\"kimi executor: auth is nil\")\n\t}\n\t// Expect refresh_token in metadata for OAuth-based accounts\n\tvar refreshToken string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"refresh_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\t\trefreshToken = v\n\t\t}\n\t}\n\tif strings.TrimSpace(refreshToken) == \"\" {\n\t\t// Nothing to refresh\n\t\treturn auth, nil\n\t}\n\n\tclient := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth))\n\ttd, err := client.RefreshToken(ctx, refreshToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"access_token\"] = td.AccessToken\n\tif td.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = td.RefreshToken\n\t}\n\tif td.ExpiresAt > 0 {\n\t\texp := time.Unix(td.ExpiresAt, 0).UTC().Format(time.RFC3339)\n\t\tauth.Metadata[\"expired\"] = exp\n\t}\n\tauth.Metadata[\"type\"] = \"kimi\"\n\tnow := time.Now().Format(time.RFC3339)\n\tauth.Metadata[\"last_refresh\"] = now\n\treturn auth, nil\n}\n\n// applyKimiHeaders sets required headers for Kimi API requests.\n// Headers match kimi-cli client for compatibility.\nfunc applyKimiHeaders(r *http.Request, token string, stream bool) {\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\tr.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t// Match kimi-cli headers exactly\n\tr.Header.Set(\"User-Agent\", \"KimiCLI/1.10.6\")\n\tr.Header.Set(\"X-Msh-Platform\", \"kimi_cli\")\n\tr.Header.Set(\"X-Msh-Version\", \"1.10.6\")\n\tr.Header.Set(\"X-Msh-Device-Name\", getKimiHostname())\n\tr.Header.Set(\"X-Msh-Device-Model\", getKimiDeviceModel())\n\tr.Header.Set(\"X-Msh-Device-Id\", getKimiDeviceID())\n\tif stream {\n\t\tr.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn\n\t}\n\tr.Header.Set(\"Accept\", \"application/json\")\n}\n\nfunc resolveKimiDeviceIDFromAuth(auth *cliproxyauth.Auth) string {\n\tif auth == nil || auth.Metadata == nil {\n\t\treturn \"\"\n\t}\n\n\tdeviceIDRaw, ok := auth.Metadata[\"device_id\"]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tdeviceID, ok := deviceIDRaw.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(deviceID)\n}\n\nfunc resolveKimiDeviceIDFromStorage(auth *cliproxyauth.Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\n\tstorage, ok := auth.Storage.(*kimiauth.KimiTokenStorage)\n\tif !ok || storage == nil {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(storage.DeviceID)\n}\n\nfunc resolveKimiDeviceID(auth *cliproxyauth.Auth) string {\n\tdeviceID := resolveKimiDeviceIDFromAuth(auth)\n\tif deviceID != \"\" {\n\t\treturn deviceID\n\t}\n\treturn resolveKimiDeviceIDFromStorage(auth)\n}\n\nfunc applyKimiHeadersWithAuth(r *http.Request, token string, stream bool, auth *cliproxyauth.Auth) {\n\tapplyKimiHeaders(r, token, stream)\n\n\tif deviceID := resolveKimiDeviceID(auth); deviceID != \"\" {\n\t\tr.Header.Set(\"X-Msh-Device-Id\", deviceID)\n\t}\n}\n\n// getKimiHostname returns the machine hostname.\nfunc getKimiHostname() string {\n\thostname, err := os.Hostname()\n\tif err != nil {\n\t\treturn \"unknown\"\n\t}\n\treturn hostname\n}\n\n// getKimiDeviceModel returns a device model string matching kimi-cli format.\nfunc getKimiDeviceModel() string {\n\treturn fmt.Sprintf(\"%s %s\", runtime.GOOS, runtime.GOARCH)\n}\n\n// getKimiDeviceID returns a stable device ID, matching kimi-cli storage location.\nfunc getKimiDeviceID() string {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"cli-proxy-api-device\"\n\t}\n\t// Check kimi-cli's device_id location first (platform-specific)\n\tvar kimiShareDir string\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\tkimiShareDir = filepath.Join(homeDir, \"Library\", \"Application Support\", \"kimi\")\n\tcase \"windows\":\n\t\tappData := os.Getenv(\"APPDATA\")\n\t\tif appData == \"\" {\n\t\t\tappData = filepath.Join(homeDir, \"AppData\", \"Roaming\")\n\t\t}\n\t\tkimiShareDir = filepath.Join(appData, \"kimi\")\n\tdefault: // linux and other unix-like\n\t\tkimiShareDir = filepath.Join(homeDir, \".local\", \"share\", \"kimi\")\n\t}\n\tdeviceIDPath := filepath.Join(kimiShareDir, \"device_id\")\n\tif data, err := os.ReadFile(deviceIDPath); err == nil {\n\t\treturn strings.TrimSpace(string(data))\n\t}\n\treturn \"cli-proxy-api-device\"\n}\n\n// kimiCreds extracts the access token from auth.\nfunc kimiCreds(a *cliproxyauth.Auth) (token string) {\n\tif a == nil {\n\t\treturn \"\"\n\t}\n\t// Check metadata first (OAuth flow stores tokens here)\n\tif a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\t// Fallback to attributes (API key style)\n\tif a.Attributes != nil {\n\t\tif v := a.Attributes[\"access_token\"]; v != \"\" {\n\t\t\treturn v\n\t\t}\n\t\tif v := a.Attributes[\"api_key\"]; v != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// stripKimiPrefix removes the \"kimi-\" prefix from model names for the upstream API.\nfunc stripKimiPrefix(model string) string {\n\tmodel = strings.TrimSpace(model)\n\tif strings.HasPrefix(strings.ToLower(model), \"kimi-\") {\n\t\treturn model[5:]\n\t}\n\treturn model\n}\n"
  },
  {
    "path": "internal/runtime/executor/kimi_executor_test.go",
    "content": "package executor\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"list_directory:1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}]},\n\t\t\t{\"role\":\"tool\",\"call_id\":\"list_directory:1\",\"content\":\"[]\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.1.tool_call_id\").String()\n\tif got != \"list_directory:1\" {\n\t\tt.Fatalf(\"messages.1.tool_call_id = %q, want %q\", got, \"list_directory:1\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}]},\n\t\t\t{\"role\":\"tool\",\"content\":\"file-content\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.1.tool_call_id\").String()\n\tif got != \"call_123\" {\n\t\tt.Fatalf(\"messages.1.tool_call_id = %q, want %q\", got, \"call_123\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[\n\t\t\t\t{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}},\n\t\t\t\t{\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}\n\t\t\t]},\n\t\t\t{\"role\":\"tool\",\"content\":\"result-without-id\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tif gjson.GetBytes(out, \"messages.1.tool_call_id\").Exists() {\n\t\tt.Fatalf(\"messages.1.tool_call_id should be absent for ambiguous case, got %q\", gjson.GetBytes(out, \"messages.1.tool_call_id\").String())\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}]},\n\t\t\t{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"call_id\":\"different-id\",\"content\":\"result\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.1.tool_call_id\").String()\n\tif got != \"call_1\" {\n\t\tt.Fatalf(\"messages.1.tool_call_id = %q, want %q\", got, \"call_1\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantToolCalls(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"content\":\"plan\",\"reasoning_content\":\"previous reasoning\"},\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}]}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.1.reasoning_content\").String()\n\tif got != \"previous reasoning\" {\n\t\tt.Fatalf(\"messages.1.reasoning_content = %q, want %q\", got, \"previous reasoning\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}]}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\treasoning := gjson.GetBytes(out, \"messages.0.reasoning_content\")\n\tif !reasoning.Exists() {\n\t\tt.Fatalf(\"messages.0.reasoning_content should exist\")\n\t}\n\tif reasoning.String() != \"[reasoning unavailable]\" {\n\t\tt.Fatalf(\"messages.0.reasoning_content = %q, want %q\", reasoning.String(), \"[reasoning unavailable]\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"first line\"},{\"type\":\"text\",\"text\":\"second line\"}],\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}]}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.0.reasoning_content\").String()\n\tif got != \"first line\\nsecond line\" {\n\t\tt.Fatalf(\"messages.0.reasoning_content = %q, want %q\", got, \"first line\\nsecond line\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"content\":\"assistant summary\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}],\"reasoning_content\":\"\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.0.reasoning_content\").String()\n\tif got != \"assistant summary\" {\n\t\tt.Fatalf(\"messages.0.reasoning_content = %q, want %q\", got, \"assistant summary\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}],\"reasoning_content\":\"keep me\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tgot := gjson.GetBytes(out, \"messages.0.reasoning_content\").String()\n\tif got != \"keep me\" {\n\t\tt.Fatalf(\"messages.0.reasoning_content = %q, want %q\", got, \"keep me\")\n\t}\n}\n\nfunc TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing.T) {\n\tbody := []byte(`{\n\t\t\"messages\":[\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"list_directory\",\"arguments\":\"{}\"}}],\"reasoning_content\":\"r1\"},\n\t\t\t{\"role\":\"tool\",\"call_id\":\"call_1\",\"content\":\"[]\"},\n\t\t\t{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}]},\n\t\t\t{\"role\":\"tool\",\"call_id\":\"call_2\",\"content\":\"file\"}\n\t\t]\n\t}`)\n\n\tout, err := normalizeKimiToolMessageLinks(body)\n\tif err != nil {\n\t\tt.Fatalf(\"normalizeKimiToolMessageLinks() error = %v\", err)\n\t}\n\n\tif got := gjson.GetBytes(out, \"messages.1.tool_call_id\").String(); got != \"call_1\" {\n\t\tt.Fatalf(\"messages.1.tool_call_id = %q, want %q\", got, \"call_1\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.3.tool_call_id\").String(); got != \"call_2\" {\n\t\tt.Fatalf(\"messages.3.tool_call_id = %q, want %q\", got, \"call_2\")\n\t}\n\tif got := gjson.GetBytes(out, \"messages.2.reasoning_content\").String(); got != \"r1\" {\n\t\tt.Fatalf(\"messages.2.reasoning_content = %q, want %q\", got, \"r1\")\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/logging_helpers.go",
    "content": "package executor\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"html\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\nconst (\n\tapiAttemptsKey = \"API_UPSTREAM_ATTEMPTS\"\n\tapiRequestKey  = \"API_REQUEST\"\n\tapiResponseKey = \"API_RESPONSE\"\n)\n\n// upstreamRequestLog captures the outbound upstream request details for logging.\ntype upstreamRequestLog struct {\n\tURL       string\n\tMethod    string\n\tHeaders   http.Header\n\tBody      []byte\n\tProvider  string\n\tAuthID    string\n\tAuthLabel string\n\tAuthType  string\n\tAuthValue string\n}\n\ntype upstreamAttempt struct {\n\tindex                int\n\trequest              string\n\tresponse             *strings.Builder\n\tresponseIntroWritten bool\n\tstatusWritten        bool\n\theadersWritten       bool\n\tbodyStarted          bool\n\tbodyHasContent       bool\n\terrorWritten         bool\n}\n\n// recordAPIRequest stores the upstream request metadata in Gin context for request logging.\nfunc recordAPIRequest(ctx context.Context, cfg *config.Config, info upstreamRequestLog) {\n\tif cfg == nil || !cfg.RequestLog {\n\t\treturn\n\t}\n\tginCtx := ginContextFrom(ctx)\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\n\tattempts := getAttempts(ginCtx)\n\tindex := len(attempts) + 1\n\n\tbuilder := &strings.Builder{}\n\tbuilder.WriteString(fmt.Sprintf(\"=== API REQUEST %d ===\\n\", index))\n\tbuilder.WriteString(fmt.Sprintf(\"Timestamp: %s\\n\", time.Now().Format(time.RFC3339Nano)))\n\tif info.URL != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"Upstream URL: %s\\n\", info.URL))\n\t} else {\n\t\tbuilder.WriteString(\"Upstream URL: <unknown>\\n\")\n\t}\n\tif info.Method != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"HTTP Method: %s\\n\", info.Method))\n\t}\n\tif auth := formatAuthInfo(info); auth != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"Auth: %s\\n\", auth))\n\t}\n\tbuilder.WriteString(\"\\nHeaders:\\n\")\n\twriteHeaders(builder, info.Headers)\n\tbuilder.WriteString(\"\\nBody:\\n\")\n\tif len(info.Body) > 0 {\n\t\tbuilder.WriteString(string(info.Body))\n\t} else {\n\t\tbuilder.WriteString(\"<empty>\")\n\t}\n\tbuilder.WriteString(\"\\n\\n\")\n\n\tattempt := &upstreamAttempt{\n\t\tindex:    index,\n\t\trequest:  builder.String(),\n\t\tresponse: &strings.Builder{},\n\t}\n\tattempts = append(attempts, attempt)\n\tginCtx.Set(apiAttemptsKey, attempts)\n\tupdateAggregatedRequest(ginCtx, attempts)\n}\n\n// recordAPIResponseMetadata captures upstream response status/header information for the latest attempt.\nfunc recordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) {\n\tif cfg == nil || !cfg.RequestLog {\n\t\treturn\n\t}\n\tginCtx := ginContextFrom(ctx)\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\tattempts, attempt := ensureAttempt(ginCtx)\n\tensureResponseIntro(attempt)\n\n\tif status > 0 && !attempt.statusWritten {\n\t\tattempt.response.WriteString(fmt.Sprintf(\"Status: %d\\n\", status))\n\t\tattempt.statusWritten = true\n\t}\n\tif !attempt.headersWritten {\n\t\tattempt.response.WriteString(\"Headers:\\n\")\n\t\twriteHeaders(attempt.response, headers)\n\t\tattempt.headersWritten = true\n\t\tattempt.response.WriteString(\"\\n\")\n\t}\n\n\tupdateAggregatedResponse(ginCtx, attempts)\n}\n\n// recordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available.\nfunc recordAPIResponseError(ctx context.Context, cfg *config.Config, err error) {\n\tif cfg == nil || !cfg.RequestLog || err == nil {\n\t\treturn\n\t}\n\tginCtx := ginContextFrom(ctx)\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\tattempts, attempt := ensureAttempt(ginCtx)\n\tensureResponseIntro(attempt)\n\n\tif attempt.bodyStarted && !attempt.bodyHasContent {\n\t\t// Ensure body does not stay empty marker if error arrives first.\n\t\tattempt.bodyStarted = false\n\t}\n\tif attempt.errorWritten {\n\t\tattempt.response.WriteString(\"\\n\")\n\t}\n\tattempt.response.WriteString(fmt.Sprintf(\"Error: %s\\n\", err.Error()))\n\tattempt.errorWritten = true\n\n\tupdateAggregatedResponse(ginCtx, attempts)\n}\n\n// appendAPIResponseChunk appends an upstream response chunk to Gin context for request logging.\nfunc appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) {\n\tif cfg == nil || !cfg.RequestLog {\n\t\treturn\n\t}\n\tdata := bytes.TrimSpace(chunk)\n\tif len(data) == 0 {\n\t\treturn\n\t}\n\tginCtx := ginContextFrom(ctx)\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\tattempts, attempt := ensureAttempt(ginCtx)\n\tensureResponseIntro(attempt)\n\n\tif !attempt.headersWritten {\n\t\tattempt.response.WriteString(\"Headers:\\n\")\n\t\twriteHeaders(attempt.response, nil)\n\t\tattempt.headersWritten = true\n\t\tattempt.response.WriteString(\"\\n\")\n\t}\n\tif !attempt.bodyStarted {\n\t\tattempt.response.WriteString(\"Body:\\n\")\n\t\tattempt.bodyStarted = true\n\t}\n\tif attempt.bodyHasContent {\n\t\tattempt.response.WriteString(\"\\n\\n\")\n\t}\n\tattempt.response.WriteString(string(data))\n\tattempt.bodyHasContent = true\n\n\tupdateAggregatedResponse(ginCtx, attempts)\n}\n\nfunc ginContextFrom(ctx context.Context) *gin.Context {\n\tginCtx, _ := ctx.Value(\"gin\").(*gin.Context)\n\treturn ginCtx\n}\n\nfunc getAttempts(ginCtx *gin.Context) []*upstreamAttempt {\n\tif ginCtx == nil {\n\t\treturn nil\n\t}\n\tif value, exists := ginCtx.Get(apiAttemptsKey); exists {\n\t\tif attempts, ok := value.([]*upstreamAttempt); ok {\n\t\t\treturn attempts\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) {\n\tattempts := getAttempts(ginCtx)\n\tif len(attempts) == 0 {\n\t\tattempt := &upstreamAttempt{\n\t\t\tindex:    1,\n\t\t\trequest:  \"=== API REQUEST 1 ===\\n<missing>\\n\\n\",\n\t\t\tresponse: &strings.Builder{},\n\t\t}\n\t\tattempts = []*upstreamAttempt{attempt}\n\t\tginCtx.Set(apiAttemptsKey, attempts)\n\t\tupdateAggregatedRequest(ginCtx, attempts)\n\t}\n\treturn attempts, attempts[len(attempts)-1]\n}\n\nfunc ensureResponseIntro(attempt *upstreamAttempt) {\n\tif attempt == nil || attempt.response == nil || attempt.responseIntroWritten {\n\t\treturn\n\t}\n\tattempt.response.WriteString(fmt.Sprintf(\"=== API RESPONSE %d ===\\n\", attempt.index))\n\tattempt.response.WriteString(fmt.Sprintf(\"Timestamp: %s\\n\", time.Now().Format(time.RFC3339Nano)))\n\tattempt.response.WriteString(\"\\n\")\n\tattempt.responseIntroWritten = true\n}\n\nfunc updateAggregatedRequest(ginCtx *gin.Context, attempts []*upstreamAttempt) {\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\tvar builder strings.Builder\n\tfor _, attempt := range attempts {\n\t\tbuilder.WriteString(attempt.request)\n\t}\n\tginCtx.Set(apiRequestKey, []byte(builder.String()))\n}\n\nfunc updateAggregatedResponse(ginCtx *gin.Context, attempts []*upstreamAttempt) {\n\tif ginCtx == nil {\n\t\treturn\n\t}\n\tvar builder strings.Builder\n\tfor idx, attempt := range attempts {\n\t\tif attempt == nil || attempt.response == nil {\n\t\t\tcontinue\n\t\t}\n\t\tresponseText := attempt.response.String()\n\t\tif responseText == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tbuilder.WriteString(responseText)\n\t\tif !strings.HasSuffix(responseText, \"\\n\") {\n\t\t\tbuilder.WriteString(\"\\n\")\n\t\t}\n\t\tif idx < len(attempts)-1 {\n\t\t\tbuilder.WriteString(\"\\n\")\n\t\t}\n\t}\n\tginCtx.Set(apiResponseKey, []byte(builder.String()))\n}\n\nfunc writeHeaders(builder *strings.Builder, headers http.Header) {\n\tif builder == nil {\n\t\treturn\n\t}\n\tif len(headers) == 0 {\n\t\tbuilder.WriteString(\"<none>\\n\")\n\t\treturn\n\t}\n\tkeys := make([]string, 0, len(headers))\n\tfor key := range headers {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys)\n\tfor _, key := range keys {\n\t\tvalues := headers[key]\n\t\tif len(values) == 0 {\n\t\t\tbuilder.WriteString(fmt.Sprintf(\"%s:\\n\", key))\n\t\t\tcontinue\n\t\t}\n\t\tfor _, value := range values {\n\t\t\tmasked := util.MaskSensitiveHeaderValue(key, value)\n\t\t\tbuilder.WriteString(fmt.Sprintf(\"%s: %s\\n\", key, masked))\n\t\t}\n\t}\n}\n\nfunc formatAuthInfo(info upstreamRequestLog) string {\n\tvar parts []string\n\tif trimmed := strings.TrimSpace(info.Provider); trimmed != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"provider=%s\", trimmed))\n\t}\n\tif trimmed := strings.TrimSpace(info.AuthID); trimmed != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"auth_id=%s\", trimmed))\n\t}\n\tif trimmed := strings.TrimSpace(info.AuthLabel); trimmed != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"label=%s\", trimmed))\n\t}\n\n\tauthType := strings.ToLower(strings.TrimSpace(info.AuthType))\n\tauthValue := strings.TrimSpace(info.AuthValue)\n\tswitch authType {\n\tcase \"api_key\":\n\t\tif authValue != \"\" {\n\t\t\tparts = append(parts, fmt.Sprintf(\"type=api_key value=%s\", util.HideAPIKey(authValue)))\n\t\t} else {\n\t\t\tparts = append(parts, \"type=api_key\")\n\t\t}\n\tcase \"oauth\":\n\t\tparts = append(parts, \"type=oauth\")\n\tdefault:\n\t\tif authType != \"\" {\n\t\t\tif authValue != \"\" {\n\t\t\t\tparts = append(parts, fmt.Sprintf(\"type=%s value=%s\", authType, authValue))\n\t\t\t} else {\n\t\t\t\tparts = append(parts, fmt.Sprintf(\"type=%s\", authType))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strings.Join(parts, \", \")\n}\n\nfunc summarizeErrorBody(contentType string, body []byte) string {\n\tisHTML := strings.Contains(strings.ToLower(contentType), \"text/html\")\n\tif !isHTML {\n\t\ttrimmed := bytes.TrimSpace(bytes.ToLower(body))\n\t\tif bytes.HasPrefix(trimmed, []byte(\"<!doctype html\")) || bytes.HasPrefix(trimmed, []byte(\"<html\")) {\n\t\t\tisHTML = true\n\t\t}\n\t}\n\tif isHTML {\n\t\tif title := extractHTMLTitle(body); title != \"\" {\n\t\t\treturn title\n\t\t}\n\t\treturn \"[html body omitted]\"\n\t}\n\n\t// Try to extract error message from JSON response\n\tif message := extractJSONErrorMessage(body); message != \"\" {\n\t\treturn message\n\t}\n\n\treturn string(body)\n}\n\nfunc extractHTMLTitle(body []byte) string {\n\tlower := bytes.ToLower(body)\n\tstart := bytes.Index(lower, []byte(\"<title\"))\n\tif start == -1 {\n\t\treturn \"\"\n\t}\n\tgt := bytes.IndexByte(lower[start:], '>')\n\tif gt == -1 {\n\t\treturn \"\"\n\t}\n\tstart += gt + 1\n\tend := bytes.Index(lower[start:], []byte(\"</title>\"))\n\tif end == -1 {\n\t\treturn \"\"\n\t}\n\ttitle := string(body[start : start+end])\n\ttitle = html.UnescapeString(title)\n\ttitle = strings.TrimSpace(title)\n\tif title == \"\" {\n\t\treturn \"\"\n\t}\n\treturn strings.Join(strings.Fields(title), \" \")\n}\n\n// extractJSONErrorMessage attempts to extract error.message from JSON error responses\nfunc extractJSONErrorMessage(body []byte) string {\n\tresult := gjson.GetBytes(body, \"error.message\")\n\tif result.Exists() && result.String() != \"\" {\n\t\treturn result.String()\n\t}\n\treturn \"\"\n}\n\n// logWithRequestID returns a logrus Entry with request_id field populated from context.\n// If no request ID is found in context, it returns the standard logger.\nfunc logWithRequestID(ctx context.Context) *log.Entry {\n\tif ctx == nil {\n\t\treturn log.NewEntry(log.StandardLogger())\n\t}\n\trequestID := logging.GetRequestID(ctx)\n\tif requestID == \"\" {\n\t\treturn log.NewEntry(log.StandardLogger())\n\t}\n\treturn log.WithField(\"request_id\", requestID)\n}\n"
  },
  {
    "path": "internal/runtime/executor/openai_compat_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// OpenAICompatExecutor implements a stateless executor for OpenAI-compatible providers.\n// It performs request/response translation and executes against the provider base URL\n// using per-auth credentials (API key) and per-auth HTTP transport (proxy) from context.\ntype OpenAICompatExecutor struct {\n\tprovider string\n\tcfg      *config.Config\n}\n\n// NewOpenAICompatExecutor creates an executor bound to a provider key (e.g., \"openrouter\").\nfunc NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatExecutor {\n\treturn &OpenAICompatExecutor{provider: provider, cfg: cfg}\n}\n\n// Identifier implements cliproxyauth.ProviderExecutor.\nfunc (e *OpenAICompatExecutor) Identifier() string { return e.provider }\n\n// PrepareRequest injects OpenAI-compatible credentials into the outgoing HTTP request.\nfunc (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\t_, apiKey := e.resolveCredentials(auth)\n\tif strings.TrimSpace(apiKey) != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(req, attrs)\n\treturn nil\n}\n\n// HttpRequest injects OpenAI-compatible credentials into the request and executes it.\nfunc (e *OpenAICompatExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"openai compat executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\nfunc (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tbaseURL, apiKey := e.resolveCredentials(auth)\n\tif baseURL == \"\" {\n\t\terr = statusErr{code: http.StatusUnauthorized, msg: \"missing provider baseURL\"}\n\t\treturn\n\t}\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\tendpoint := \"/chat/completions\"\n\tif opts.Alt == \"responses/compact\" {\n\t\tto = sdktranslator.FromString(\"openai-response\")\n\t\tendpoint = \"/responses/compact\"\n\t}\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\ttranslated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", translated, originalTranslated, requestedModel)\n\tif opts.Alt == \"responses/compact\" {\n\t\tif updated, errDelete := sjson.DeleteBytes(translated, \"stream\"); errDelete == nil {\n\t\t\ttranslated = updated\n\t\t}\n\t}\n\n\ttranslated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + endpoint\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\thttpReq.Header.Set(\"User-Agent\", \"cli-proxy-openai-compat\")\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(httpReq, attrs)\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      translated,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"openai compat executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn resp, err\n\t}\n\tbody, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, body)\n\treporter.publish(ctx, parseOpenAIUsage(body))\n\t// Ensure we at least record the request even if upstream doesn't return usage\n\treporter.ensurePublished(ctx)\n\t// Translate response back to source format when needed\n\tvar param any\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\nfunc (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tbaseURL, apiKey := e.resolveCredentials(auth)\n\tif baseURL == \"\" {\n\t\terr = statusErr{code: http.StatusUnauthorized, msg: \"missing provider baseURL\"}\n\t\treturn nil, err\n\t}\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\ttranslated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", translated, originalTranslated, requestedModel)\n\n\ttranslated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Request usage data in the final streaming chunk so that token statistics\n\t// are captured even when the upstream is an OpenAI-compatible provider.\n\ttranslated, _ = sjson.SetBytes(translated, \"stream_options.include_usage\", true)\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/chat/completions\"\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\tif apiKey != \"\" {\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n\thttpReq.Header.Set(\"User-Agent\", \"cli-proxy-openai-compat\")\n\tvar attrs map[string]string\n\tif auth != nil {\n\t\tattrs = auth.Attributes\n\t}\n\tutil.ApplyCustomHeadersFromAttrs(httpReq, attrs)\n\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\thttpReq.Header.Set(\"Cache-Control\", \"no-cache\")\n\tvar authID, authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      translated,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d, error message: %s\", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"openai compat executor: close response body error: %v\", errClose)\n\t\t}\n\t\terr = statusErr{code: httpResp.StatusCode, msg: string(b)}\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"openai compat executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseOpenAIStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tif len(line) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !bytes.HasPrefix(line, []byte(\"data:\")) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// OpenAI-compatible streams are SSE: lines typically prefixed with \"data: \".\n\t\t\t// Pass through translator; it yields one or more chunks for the target schema.\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t\t// Ensure we record the request if no usage chunk was ever seen\n\t\treporter.ensurePublished(ctx)\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\nfunc (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\ttranslated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tmodelForCounting := baseModel\n\n\ttranslated, err := thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\n\tenc, err := tokenizerForModel(modelForCounting)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"openai compat executor: tokenizer init failed: %w\", err)\n\t}\n\n\tcount, err := countOpenAIChatTokens(enc, translated)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"openai compat executor: token counting failed: %w\", err)\n\t}\n\n\tusageJSON := buildOpenAIUsageJSON(count)\n\ttranslatedUsage := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)\n\treturn cliproxyexecutor.Response{Payload: []byte(translatedUsage)}, nil\n}\n\n// Refresh is a no-op for API-key based compatibility providers.\nfunc (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"openai compat executor: refresh called\")\n\t_ = ctx\n\treturn auth, nil\n}\n\nfunc (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (baseURL, apiKey string) {\n\tif auth == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif auth.Attributes != nil {\n\t\tbaseURL = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t\tapiKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t}\n\treturn\n}\n\nfunc (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *config.OpenAICompatibility {\n\tif auth == nil || e.cfg == nil {\n\t\treturn nil\n\t}\n\tcandidates := make([]string, 0, 3)\n\tif auth.Attributes != nil {\n\t\tif v := strings.TrimSpace(auth.Attributes[\"compat_name\"]); v != \"\" {\n\t\t\tcandidates = append(candidates, v)\n\t\t}\n\t\tif v := strings.TrimSpace(auth.Attributes[\"provider_key\"]); v != \"\" {\n\t\t\tcandidates = append(candidates, v)\n\t\t}\n\t}\n\tif v := strings.TrimSpace(auth.Provider); v != \"\" {\n\t\tcandidates = append(candidates, v)\n\t}\n\tfor i := range e.cfg.OpenAICompatibility {\n\t\tcompat := &e.cfg.OpenAICompatibility[i]\n\t\tfor _, candidate := range candidates {\n\t\t\tif candidate != \"\" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {\n\t\t\t\treturn compat\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (e *OpenAICompatExecutor) overrideModel(payload []byte, model string) []byte {\n\tif len(payload) == 0 || model == \"\" {\n\t\treturn payload\n\t}\n\tpayload, _ = sjson.SetBytes(payload, \"model\", model)\n\treturn payload\n}\n\ntype statusErr struct {\n\tcode       int\n\tmsg        string\n\tretryAfter *time.Duration\n}\n\nfunc (e statusErr) Error() string {\n\tif e.msg != \"\" {\n\t\treturn e.msg\n\t}\n\treturn fmt.Sprintf(\"status %d\", e.code)\n}\nfunc (e statusErr) StatusCode() int            { return e.code }\nfunc (e statusErr) RetryAfter() *time.Duration { return e.retryAfter }\n"
  },
  {
    "path": "internal/runtime/executor/openai_compat_executor_compact_test.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestOpenAICompatExecutorCompactPassthrough(t *testing.T) {\n\tvar gotPath string\n\tvar gotBody []byte\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotPath = r.URL.Path\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\tgotBody = body\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\":\"resp_1\",\"object\":\"response.compaction\",\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}`))\n\t}))\n\tdefer server.Close()\n\n\texecutor := NewOpenAICompatExecutor(\"openai-compatibility\", &config.Config{})\n\tauth := &cliproxyauth.Auth{Attributes: map[string]string{\n\t\t\"base_url\": server.URL + \"/v1\",\n\t\t\"api_key\":  \"test\",\n\t}}\n\tpayload := []byte(`{\"model\":\"gpt-5.1-codex-max\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}]}`)\n\tresp, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{\n\t\tModel:   \"gpt-5.1-codex-max\",\n\t\tPayload: payload,\n\t}, cliproxyexecutor.Options{\n\t\tSourceFormat: sdktranslator.FromString(\"openai-response\"),\n\t\tAlt:          \"responses/compact\",\n\t\tStream:       false,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Execute error: %v\", err)\n\t}\n\tif gotPath != \"/v1/responses/compact\" {\n\t\tt.Fatalf(\"path = %q, want %q\", gotPath, \"/v1/responses/compact\")\n\t}\n\tif !gjson.GetBytes(gotBody, \"input\").Exists() {\n\t\tt.Fatalf(\"expected input in body\")\n\t}\n\tif gjson.GetBytes(gotBody, \"messages\").Exists() {\n\t\tt.Fatalf(\"unexpected messages in body\")\n\t}\n\tif string(resp.Payload) != `{\"id\":\"resp_1\",\"object\":\"response.compaction\",\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}` {\n\t\tt.Fatalf(\"payload = %s\", string(resp.Payload))\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/payload_helpers.go",
    "content": "package executor\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter\n// paths as relative to the provided root path (for example, \"request\" for Gemini CLI)\n// and restricts matches to the given protocol when supplied. Defaults are checked\n// against the original payload when provided. requestedModel carries the client-visible\n// model name before alias resolution so payload rules can target aliases precisely.\nfunc applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string) []byte {\n\tif cfg == nil || len(payload) == 0 {\n\t\treturn payload\n\t}\n\trules := cfg.Payload\n\tif len(rules.Default) == 0 && len(rules.DefaultRaw) == 0 && len(rules.Override) == 0 && len(rules.OverrideRaw) == 0 && len(rules.Filter) == 0 {\n\t\treturn payload\n\t}\n\tmodel = strings.TrimSpace(model)\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif model == \"\" && requestedModel == \"\" {\n\t\treturn payload\n\t}\n\tcandidates := payloadModelCandidates(model, requestedModel)\n\tout := payload\n\tsource := original\n\tif len(source) == 0 {\n\t\tsource = payload\n\t}\n\tappliedDefaults := make(map[string]struct{})\n\t// Apply default rules: first write wins per field across all matching rules.\n\tfor i := range rules.Default {\n\t\trule := &rules.Default[i]\n\t\tif !payloadModelRulesMatch(rule.Models, protocol, candidates) {\n\t\t\tcontinue\n\t\t}\n\t\tfor path, value := range rule.Params {\n\t\t\tfullPath := buildPayloadPath(root, path)\n\t\t\tif fullPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif gjson.GetBytes(source, fullPath).Exists() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, ok := appliedDefaults[fullPath]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdated, errSet := sjson.SetBytes(out, fullPath, value)\n\t\t\tif errSet != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = updated\n\t\t\tappliedDefaults[fullPath] = struct{}{}\n\t\t}\n\t}\n\t// Apply default raw rules: first write wins per field across all matching rules.\n\tfor i := range rules.DefaultRaw {\n\t\trule := &rules.DefaultRaw[i]\n\t\tif !payloadModelRulesMatch(rule.Models, protocol, candidates) {\n\t\t\tcontinue\n\t\t}\n\t\tfor path, value := range rule.Params {\n\t\t\tfullPath := buildPayloadPath(root, path)\n\t\t\tif fullPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif gjson.GetBytes(source, fullPath).Exists() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, ok := appliedDefaults[fullPath]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trawValue, ok := payloadRawValue(value)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)\n\t\t\tif errSet != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = updated\n\t\t\tappliedDefaults[fullPath] = struct{}{}\n\t\t}\n\t}\n\t// Apply override rules: last write wins per field across all matching rules.\n\tfor i := range rules.Override {\n\t\trule := &rules.Override[i]\n\t\tif !payloadModelRulesMatch(rule.Models, protocol, candidates) {\n\t\t\tcontinue\n\t\t}\n\t\tfor path, value := range rule.Params {\n\t\t\tfullPath := buildPayloadPath(root, path)\n\t\t\tif fullPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdated, errSet := sjson.SetBytes(out, fullPath, value)\n\t\t\tif errSet != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = updated\n\t\t}\n\t}\n\t// Apply override raw rules: last write wins per field across all matching rules.\n\tfor i := range rules.OverrideRaw {\n\t\trule := &rules.OverrideRaw[i]\n\t\tif !payloadModelRulesMatch(rule.Models, protocol, candidates) {\n\t\t\tcontinue\n\t\t}\n\t\tfor path, value := range rule.Params {\n\t\t\tfullPath := buildPayloadPath(root, path)\n\t\t\tif fullPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trawValue, ok := payloadRawValue(value)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)\n\t\t\tif errSet != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = updated\n\t\t}\n\t}\n\t// Apply filter rules: remove matching paths from payload.\n\tfor i := range rules.Filter {\n\t\trule := &rules.Filter[i]\n\t\tif !payloadModelRulesMatch(rule.Models, protocol, candidates) {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, path := range rule.Params {\n\t\t\tfullPath := buildPayloadPath(root, path)\n\t\t\tif fullPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdated, errDel := sjson.DeleteBytes(out, fullPath)\n\t\t\tif errDel != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = updated\n\t\t}\n\t}\n\treturn out\n}\n\nfunc payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, models []string) bool {\n\tif len(rules) == 0 || len(models) == 0 {\n\t\treturn false\n\t}\n\tfor _, model := range models {\n\t\tfor _, entry := range rules {\n\t\t\tname := strings.TrimSpace(entry.Name)\n\t\t\tif name == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif ep := strings.TrimSpace(entry.Protocol); ep != \"\" && protocol != \"\" && !strings.EqualFold(ep, protocol) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif matchModelPattern(name, model) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc payloadModelCandidates(model, requestedModel string) []string {\n\tmodel = strings.TrimSpace(model)\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif model == \"\" && requestedModel == \"\" {\n\t\treturn nil\n\t}\n\tcandidates := make([]string, 0, 3)\n\tseen := make(map[string]struct{}, 3)\n\taddCandidate := func(value string) {\n\t\tvalue = strings.TrimSpace(value)\n\t\tif value == \"\" {\n\t\t\treturn\n\t\t}\n\t\tkey := strings.ToLower(value)\n\t\tif _, ok := seen[key]; ok {\n\t\t\treturn\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tcandidates = append(candidates, value)\n\t}\n\tif model != \"\" {\n\t\taddCandidate(model)\n\t}\n\tif requestedModel != \"\" {\n\t\tparsed := thinking.ParseSuffix(requestedModel)\n\t\tbase := strings.TrimSpace(parsed.ModelName)\n\t\tif base != \"\" {\n\t\t\taddCandidate(base)\n\t\t}\n\t\tif parsed.HasSuffix {\n\t\t\taddCandidate(requestedModel)\n\t\t}\n\t}\n\treturn candidates\n}\n\n// buildPayloadPath combines an optional root path with a relative parameter path.\n// When root is empty, the parameter path is used as-is. When root is non-empty,\n// the parameter path is treated as relative to root.\nfunc buildPayloadPath(root, path string) string {\n\tr := strings.TrimSpace(root)\n\tp := strings.TrimSpace(path)\n\tif r == \"\" {\n\t\treturn p\n\t}\n\tif p == \"\" {\n\t\treturn r\n\t}\n\tif strings.HasPrefix(p, \".\") {\n\t\tp = p[1:]\n\t}\n\treturn r + \".\" + p\n}\n\nfunc payloadRawValue(value any) ([]byte, bool) {\n\tif value == nil {\n\t\treturn nil, false\n\t}\n\tswitch typed := value.(type) {\n\tcase string:\n\t\treturn []byte(typed), true\n\tcase []byte:\n\t\treturn typed, true\n\tdefault:\n\t\traw, errMarshal := json.Marshal(typed)\n\t\tif errMarshal != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn raw, true\n\t}\n}\n\nfunc payloadRequestedModel(opts cliproxyexecutor.Options, fallback string) string {\n\tfallback = strings.TrimSpace(fallback)\n\tif len(opts.Metadata) == 0 {\n\t\treturn fallback\n\t}\n\traw, ok := opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey]\n\tif !ok || raw == nil {\n\t\treturn fallback\n\t}\n\tswitch v := raw.(type) {\n\tcase string:\n\t\tif strings.TrimSpace(v) == \"\" {\n\t\t\treturn fallback\n\t\t}\n\t\treturn strings.TrimSpace(v)\n\tcase []byte:\n\t\tif len(v) == 0 {\n\t\t\treturn fallback\n\t\t}\n\t\ttrimmed := strings.TrimSpace(string(v))\n\t\tif trimmed == \"\" {\n\t\t\treturn fallback\n\t\t}\n\t\treturn trimmed\n\tdefault:\n\t\treturn fallback\n\t}\n}\n\n// matchModelPattern performs simple wildcard matching where '*' matches zero or more characters.\n// Examples:\n//\n//\t\"*-5\" matches \"gpt-5\"\n//\t\"gpt-*\" matches \"gpt-5\" and \"gpt-4\"\n//\t\"gemini-*-pro\" matches \"gemini-2.5-pro\" and \"gemini-3-pro\".\nfunc matchModelPattern(pattern, model string) bool {\n\tpattern = strings.TrimSpace(pattern)\n\tmodel = strings.TrimSpace(model)\n\tif pattern == \"\" {\n\t\treturn false\n\t}\n\tif pattern == \"*\" {\n\t\treturn true\n\t}\n\t// Iterative glob-style matcher supporting only '*' wildcard.\n\tpi, si := 0, 0\n\tstarIdx := -1\n\tmatchIdx := 0\n\tfor si < len(model) {\n\t\tif pi < len(pattern) && (pattern[pi] == model[si]) {\n\t\t\tpi++\n\t\t\tsi++\n\t\t\tcontinue\n\t\t}\n\t\tif pi < len(pattern) && pattern[pi] == '*' {\n\t\t\tstarIdx = pi\n\t\t\tmatchIdx = si\n\t\t\tpi++\n\t\t\tcontinue\n\t\t}\n\t\tif starIdx != -1 {\n\t\t\tpi = starIdx + 1\n\t\t\tmatchIdx++\n\t\t\tsi = matchIdx\n\t\t\tcontinue\n\t\t}\n\t\treturn false\n\t}\n\tfor pi < len(pattern) && pattern[pi] == '*' {\n\t\tpi++\n\t}\n\treturn pi == len(pattern)\n}\n"
  },
  {
    "path": "internal/runtime/executor/proxy_helpers.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority:\n// 1. Use auth.ProxyURL if configured (highest priority)\n// 2. Use cfg.ProxyURL if auth proxy is not configured\n// 3. Use RoundTripper from context if neither are configured\n//\n// Parameters:\n//   - ctx: The context containing optional RoundTripper\n//   - cfg: The application configuration\n//   - auth: The authentication information\n//   - timeout: The client timeout (0 means no timeout)\n//\n// Returns:\n//   - *http.Client: An HTTP client with configured proxy or transport\nfunc newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {\n\thttpClient := &http.Client{}\n\tif timeout > 0 {\n\t\thttpClient.Timeout = timeout\n\t}\n\n\t// Priority 1: Use auth.ProxyURL if configured\n\tvar proxyURL string\n\tif auth != nil {\n\t\tproxyURL = strings.TrimSpace(auth.ProxyURL)\n\t}\n\n\t// Priority 2: Use cfg.ProxyURL if auth proxy is not configured\n\tif proxyURL == \"\" && cfg != nil {\n\t\tproxyURL = strings.TrimSpace(cfg.ProxyURL)\n\t}\n\n\t// If we have a proxy URL configured, set up the transport\n\tif proxyURL != \"\" {\n\t\ttransport := buildProxyTransport(proxyURL)\n\t\tif transport != nil {\n\t\t\thttpClient.Transport = transport\n\t\t\treturn httpClient\n\t\t}\n\t\t// If proxy setup failed, log and fall through to context RoundTripper\n\t\tlog.Debugf(\"failed to setup proxy from URL: %s, falling back to context transport\", proxyURL)\n\t}\n\n\t// Priority 3: Use RoundTripper from context (typically from RoundTripperFor)\n\tif rt, ok := ctx.Value(\"cliproxy.roundtripper\").(http.RoundTripper); ok && rt != nil {\n\t\thttpClient.Transport = rt\n\t}\n\n\treturn httpClient\n}\n\n// buildProxyTransport creates an HTTP transport configured for the given proxy URL.\n// It supports SOCKS5, HTTP, and HTTPS proxy protocols.\n//\n// Parameters:\n//   - proxyURL: The proxy URL string (e.g., \"socks5://user:pass@host:port\", \"http://host:port\")\n//\n// Returns:\n//   - *http.Transport: A configured transport, or nil if the proxy URL is invalid\nfunc buildProxyTransport(proxyURL string) *http.Transport {\n\ttransport, _, errBuild := proxyutil.BuildHTTPTransport(proxyURL)\n\tif errBuild != nil {\n\t\tlog.Errorf(\"%v\", errBuild)\n\t\treturn nil\n\t}\n\treturn transport\n}\n"
  },
  {
    "path": "internal/runtime/executor/proxy_helpers_test.go",
    "content": "package executor\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestNewProxyAwareHTTPClientDirectBypassesGlobalProxy(t *testing.T) {\n\tt.Parallel()\n\n\tclient := newProxyAwareHTTPClient(\n\t\tcontext.Background(),\n\t\t&config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: \"http://global-proxy.example.com:8080\"}},\n\t\t&cliproxyauth.Auth{ProxyURL: \"direct\"},\n\t\t0,\n\t)\n\n\ttransport, ok := client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"transport type = %T, want *http.Transport\", client.Transport)\n\t}\n\tif transport.Proxy != nil {\n\t\tt.Fatal(\"expected direct transport to disable proxy function\")\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/qwen_executor.go",
    "content": "package executor\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tqwenauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst (\n\tqwenUserAgent       = \"QwenCode/0.10.3 (darwin; arm64)\"\n\tqwenRateLimitPerMin = 60          // 60 requests per minute per credential\n\tqwenRateLimitWindow = time.Minute // sliding window duration\n)\n\n// qwenBeijingLoc caches the Beijing timezone to avoid repeated LoadLocation syscalls.\nvar qwenBeijingLoc = func() *time.Location {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil || loc == nil {\n\t\tlog.Warnf(\"qwen: failed to load Asia/Shanghai timezone: %v, using fixed UTC+8\", err)\n\t\treturn time.FixedZone(\"CST\", 8*3600)\n\t}\n\treturn loc\n}()\n\n// qwenQuotaCodes is a package-level set of error codes that indicate quota exhaustion.\nvar qwenQuotaCodes = map[string]struct{}{\n\t\"insufficient_quota\": {},\n\t\"quota_exceeded\":     {},\n}\n\n// qwenRateLimiter tracks request timestamps per credential for rate limiting.\n// Qwen has a limit of 60 requests per minute per account.\nvar qwenRateLimiter = struct {\n\tsync.Mutex\n\trequests map[string][]time.Time // authID -> request timestamps\n}{\n\trequests: make(map[string][]time.Time),\n}\n\n// redactAuthID returns a redacted version of the auth ID for safe logging.\n// Keeps a small prefix/suffix to allow correlation across events.\nfunc redactAuthID(id string) string {\n\tif id == \"\" {\n\t\treturn \"\"\n\t}\n\tif len(id) <= 8 {\n\t\treturn id\n\t}\n\treturn id[:4] + \"...\" + id[len(id)-4:]\n}\n\n// checkQwenRateLimit checks if the credential has exceeded the rate limit.\n// Returns nil if allowed, or a statusErr with retryAfter if rate limited.\nfunc checkQwenRateLimit(authID string) error {\n\tif authID == \"\" {\n\t\t// Empty authID should not bypass rate limiting in production\n\t\t// Use debug level to avoid log spam for certain auth flows\n\t\tlog.Debug(\"qwen rate limit check: empty authID, skipping rate limit\")\n\t\treturn nil\n\t}\n\n\tnow := time.Now()\n\twindowStart := now.Add(-qwenRateLimitWindow)\n\n\tqwenRateLimiter.Lock()\n\tdefer qwenRateLimiter.Unlock()\n\n\t// Get and filter timestamps within the window\n\ttimestamps := qwenRateLimiter.requests[authID]\n\tvar validTimestamps []time.Time\n\tfor _, ts := range timestamps {\n\t\tif ts.After(windowStart) {\n\t\t\tvalidTimestamps = append(validTimestamps, ts)\n\t\t}\n\t}\n\n\t// Always prune expired entries to prevent memory leak\n\t// Delete empty entries, otherwise update with pruned slice\n\tif len(validTimestamps) == 0 {\n\t\tdelete(qwenRateLimiter.requests, authID)\n\t}\n\n\t// Check if rate limit exceeded\n\tif len(validTimestamps) >= qwenRateLimitPerMin {\n\t\t// Calculate when the oldest request will expire\n\t\toldestInWindow := validTimestamps[0]\n\t\tretryAfter := oldestInWindow.Add(qwenRateLimitWindow).Sub(now)\n\t\tif retryAfter < time.Second {\n\t\t\tretryAfter = time.Second\n\t\t}\n\t\tretryAfterSec := int(retryAfter.Seconds())\n\t\treturn statusErr{\n\t\t\tcode:       http.StatusTooManyRequests,\n\t\t\tmsg:        fmt.Sprintf(`{\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Qwen rate limit: %d requests/minute exceeded, retry after %ds\",\"type\":\"rate_limit_exceeded\"}}`, qwenRateLimitPerMin, retryAfterSec),\n\t\t\tretryAfter: &retryAfter,\n\t\t}\n\t}\n\n\t// Record this request and update the map with pruned timestamps\n\tvalidTimestamps = append(validTimestamps, now)\n\tqwenRateLimiter.requests[authID] = validTimestamps\n\n\treturn nil\n}\n\n// isQwenQuotaError checks if the error response indicates a quota exceeded error.\n// Qwen returns HTTP 403 with error.code=\"insufficient_quota\" when daily quota is exhausted.\nfunc isQwenQuotaError(body []byte) bool {\n\tcode := strings.ToLower(gjson.GetBytes(body, \"error.code\").String())\n\terrType := strings.ToLower(gjson.GetBytes(body, \"error.type\").String())\n\n\t// Primary check: exact match on error.code or error.type (most reliable)\n\tif _, ok := qwenQuotaCodes[code]; ok {\n\t\treturn true\n\t}\n\tif _, ok := qwenQuotaCodes[errType]; ok {\n\t\treturn true\n\t}\n\n\t// Fallback: check message only if code/type don't match (less reliable)\n\tmsg := strings.ToLower(gjson.GetBytes(body, \"error.message\").String())\n\tif strings.Contains(msg, \"insufficient_quota\") || strings.Contains(msg, \"quota exceeded\") ||\n\t\tstrings.Contains(msg, \"free allocated quota exceeded\") {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// wrapQwenError wraps an HTTP error response, detecting quota errors and mapping them to 429.\n// Returns the appropriate status code and retryAfter duration for statusErr.\n// Only checks for quota errors when httpCode is 403 or 429 to avoid false positives.\nfunc wrapQwenError(ctx context.Context, httpCode int, body []byte) (errCode int, retryAfter *time.Duration) {\n\terrCode = httpCode\n\t// Only check quota errors for expected status codes to avoid false positives\n\t// Qwen returns 403 for quota errors, 429 for rate limits\n\tif (httpCode == http.StatusForbidden || httpCode == http.StatusTooManyRequests) && isQwenQuotaError(body) {\n\t\terrCode = http.StatusTooManyRequests // Map to 429 to trigger quota logic\n\t\tcooldown := timeUntilNextDay()\n\t\tretryAfter = &cooldown\n\t\tlogWithRequestID(ctx).Warnf(\"qwen quota exceeded (http %d -> %d), cooling down until tomorrow (%v)\", httpCode, errCode, cooldown)\n\t}\n\treturn errCode, retryAfter\n}\n\n// timeUntilNextDay returns duration until midnight Beijing time (UTC+8).\n// Qwen's daily quota resets at 00:00 Beijing time.\nfunc timeUntilNextDay() time.Duration {\n\tnow := time.Now()\n\tnowLocal := now.In(qwenBeijingLoc)\n\ttomorrow := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day()+1, 0, 0, 0, 0, qwenBeijingLoc)\n\treturn tomorrow.Sub(now)\n}\n\n// QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.\n// If access token is unavailable, it falls back to legacy via ClientAdapter.\ntype QwenExecutor struct {\n\tcfg *config.Config\n}\n\nfunc NewQwenExecutor(cfg *config.Config) *QwenExecutor { return &QwenExecutor{cfg: cfg} }\n\nfunc (e *QwenExecutor) Identifier() string { return \"qwen\" }\n\n// PrepareRequest injects Qwen credentials into the outgoing HTTP request.\nfunc (e *QwenExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {\n\tif req == nil {\n\t\treturn nil\n\t}\n\ttoken, _ := qwenCreds(auth)\n\tif strings.TrimSpace(token) != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t}\n\treturn nil\n}\n\n// HttpRequest injects Qwen credentials into the request and executes it.\nfunc (e *QwenExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"qwen executor: request is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = req.Context()\n\t}\n\thttpReq := req.WithContext(ctx)\n\tif err := e.PrepareRequest(httpReq, auth); err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\treturn httpClient.Do(httpReq)\n}\n\nfunc (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn resp, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\n\t// Check rate limit before proceeding\n\tvar authID string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t}\n\tif err := checkQwenRateLimit(authID); err != nil {\n\t\tlogWithRequestID(ctx).Warnf(\"qwen rate limit exceeded for credential %s\", redactAuthID(authID))\n\t\treturn resp, err\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttoken, baseURL := qwenCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://portal.qwen.ai/v1\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/chat/completions\"\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tapplyQwenHeaders(httpReq, token, false)\n\tvar authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tdefer func() {\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"qwen executor: close response body error: %v\", errClose)\n\t\t}\n\t}()\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\n\t\terrCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d (mapped: %d), error message: %s\", httpResp.StatusCode, errCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\terr = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}\n\t\treturn resp, err\n\t}\n\tdata, err := io.ReadAll(httpResp.Body)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn resp, err\n\t}\n\tappendAPIResponseChunk(ctx, e.cfg, data)\n\treporter.publish(ctx, parseOpenAIUsage(data))\n\tvar param any\n\t// Note: TranslateNonStream uses req.Model (original with suffix) to preserve\n\t// the original model name in the response for client compatibility.\n\tout := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)\n\tresp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}\n\treturn resp, nil\n}\n\nfunc (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {\n\tif opts.Alt == \"responses/compact\" {\n\t\treturn nil, statusErr{code: http.StatusNotImplemented, msg: \"/responses/compact not supported\"}\n\t}\n\n\t// Check rate limit before proceeding\n\tvar authID string\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t}\n\tif err := checkQwenRateLimit(authID); err != nil {\n\t\tlogWithRequestID(ctx).Warnf(\"qwen rate limit exceeded for credential %s\", redactAuthID(authID))\n\t\treturn nil, err\n\t}\n\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\ttoken, baseURL := qwenCreds(auth)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://portal.qwen.ai/v1\"\n\t}\n\n\treporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)\n\tdefer reporter.trackFailure(ctx, &err)\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\toriginalPayloadSource := req.Payload\n\tif len(opts.OriginalRequest) > 0 {\n\t\toriginalPayloadSource = opts.OriginalRequest\n\t}\n\toriginalPayload := originalPayloadSource\n\toriginalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)\n\tbody, _ = sjson.SetBytes(body, \"model\", baseModel)\n\n\tbody, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttoolsResult := gjson.GetBytes(body, \"tools\")\n\t// I'm addressing the Qwen3 \"poisoning\" issue, which is caused by the model needing a tool to be defined. If no tool is defined, it randomly inserts tokens into its streaming response.\n\t// This will have no real consequences. It's just to scare Qwen3.\n\tif (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {\n\t\tbody, _ = sjson.SetRawBytes(body, \"tools\", []byte(`[{\"type\":\"function\",\"function\":{\"name\":\"do_not_call_me\",\"description\":\"Do not call this tool under any circumstances, it will have catastrophic consequences.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"operation\":{\"type\":\"number\",\"description\":\"1:poweroff\\n2:rm -fr /\\n3:mkfs.ext4 /dev/sda1\"}},\"required\":[\"operation\"]}}}]`))\n\t}\n\tbody, _ = sjson.SetBytes(body, \"stream_options.include_usage\", true)\n\trequestedModel := payloadRequestedModel(opts, req.Model)\n\tbody = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), \"\", body, originalTranslated, requestedModel)\n\n\turl := strings.TrimSuffix(baseURL, \"/\") + \"/chat/completions\"\n\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapplyQwenHeaders(httpReq, token, true)\n\tvar authLabel, authType, authValue string\n\tif auth != nil {\n\t\tauthLabel = auth.Label\n\t\tauthType, authValue = auth.AccountInfo()\n\t}\n\trecordAPIRequest(ctx, e.cfg, upstreamRequestLog{\n\t\tURL:       url,\n\t\tMethod:    http.MethodPost,\n\t\tHeaders:   httpReq.Header.Clone(),\n\t\tBody:      body,\n\t\tProvider:  e.Identifier(),\n\t\tAuthID:    authID,\n\t\tAuthLabel: authLabel,\n\t\tAuthType:  authType,\n\t\tAuthValue: authValue,\n\t})\n\n\thttpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)\n\thttpResp, err := httpClient.Do(httpReq)\n\tif err != nil {\n\t\trecordAPIResponseError(ctx, e.cfg, err)\n\t\treturn nil, err\n\t}\n\trecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())\n\tif httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {\n\t\tb, _ := io.ReadAll(httpResp.Body)\n\t\tappendAPIResponseChunk(ctx, e.cfg, b)\n\n\t\terrCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)\n\t\tlogWithRequestID(ctx).Debugf(\"request error, error status: %d (mapped: %d), error message: %s\", httpResp.StatusCode, errCode, summarizeErrorBody(httpResp.Header.Get(\"Content-Type\"), b))\n\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\tlog.Errorf(\"qwen executor: close response body error: %v\", errClose)\n\t\t}\n\t\terr = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}\n\t\treturn nil, err\n\t}\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tdefer func() {\n\t\t\tif errClose := httpResp.Body.Close(); errClose != nil {\n\t\t\t\tlog.Errorf(\"qwen executor: close response body error: %v\", errClose)\n\t\t\t}\n\t\t}()\n\t\tscanner := bufio.NewScanner(httpResp.Body)\n\t\tscanner.Buffer(nil, 52_428_800) // 50MB\n\t\tvar param any\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tappendAPIResponseChunk(ctx, e.cfg, line)\n\t\t\tif detail, ok := parseOpenAIStreamUsage(line); ok {\n\t\t\t\treporter.publish(ctx, detail)\n\t\t\t}\n\t\t\tchunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)\n\t\t\tfor i := range chunks {\n\t\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}\n\t\t\t}\n\t\t}\n\t\tdoneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte(\"[DONE]\"), &param)\n\t\tfor i := range doneChunks {\n\t\t\tout <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}\n\t\t}\n\t\tif errScan := scanner.Err(); errScan != nil {\n\t\t\trecordAPIResponseError(ctx, e.cfg, errScan)\n\t\t\treporter.publishFailure(ctx)\n\t\t\tout <- cliproxyexecutor.StreamChunk{Err: errScan}\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil\n}\n\nfunc (e *QwenExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tbaseModel := thinking.ParseSuffix(req.Model).ModelName\n\n\tfrom := opts.SourceFormat\n\tto := sdktranslator.FromString(\"openai\")\n\tbody := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)\n\n\tmodelName := gjson.GetBytes(body, \"model\").String()\n\tif strings.TrimSpace(modelName) == \"\" {\n\t\tmodelName = baseModel\n\t}\n\n\tenc, err := tokenizerForModel(modelName)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"qwen executor: tokenizer init failed: %w\", err)\n\t}\n\n\tcount, err := countOpenAIChatTokens(enc, body)\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, fmt.Errorf(\"qwen executor: token counting failed: %w\", err)\n\t}\n\n\tusageJSON := buildOpenAIUsageJSON(count)\n\ttranslated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)\n\treturn cliproxyexecutor.Response{Payload: []byte(translated)}, nil\n}\n\nfunc (e *QwenExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {\n\tlog.Debugf(\"qwen executor: refresh called\")\n\tif auth == nil {\n\t\treturn nil, fmt.Errorf(\"qwen executor: auth is nil\")\n\t}\n\t// Expect refresh_token in metadata for OAuth-based accounts\n\tvar refreshToken string\n\tif auth.Metadata != nil {\n\t\tif v, ok := auth.Metadata[\"refresh_token\"].(string); ok && strings.TrimSpace(v) != \"\" {\n\t\t\trefreshToken = v\n\t\t}\n\t}\n\tif strings.TrimSpace(refreshToken) == \"\" {\n\t\t// Nothing to refresh\n\t\treturn auth, nil\n\t}\n\n\tsvc := qwenauth.NewQwenAuth(e.cfg)\n\ttd, err := svc.RefreshTokens(ctx, refreshToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif auth.Metadata == nil {\n\t\tauth.Metadata = make(map[string]any)\n\t}\n\tauth.Metadata[\"access_token\"] = td.AccessToken\n\tif td.RefreshToken != \"\" {\n\t\tauth.Metadata[\"refresh_token\"] = td.RefreshToken\n\t}\n\tif td.ResourceURL != \"\" {\n\t\tauth.Metadata[\"resource_url\"] = td.ResourceURL\n\t}\n\t// Use \"expired\" for consistency with existing file format\n\tauth.Metadata[\"expired\"] = td.Expire\n\tauth.Metadata[\"type\"] = \"qwen\"\n\tnow := time.Now().Format(time.RFC3339)\n\tauth.Metadata[\"last_refresh\"] = now\n\treturn auth, nil\n}\n\nfunc applyQwenHeaders(r *http.Request, token string, stream bool) {\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\tr.Header.Set(\"Authorization\", \"Bearer \"+token)\n\tr.Header.Set(\"User-Agent\", qwenUserAgent)\n\tr.Header.Set(\"X-Dashscope-Useragent\", qwenUserAgent)\n\tr.Header.Set(\"X-Stainless-Runtime-Version\", \"v22.17.0\")\n\tr.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\n\tr.Header.Set(\"X-Stainless-Lang\", \"js\")\n\tr.Header.Set(\"X-Stainless-Arch\", \"arm64\")\n\tr.Header.Set(\"X-Stainless-Package-Version\", \"5.11.0\")\n\tr.Header.Set(\"X-Dashscope-Cachecontrol\", \"enable\")\n\tr.Header.Set(\"X-Stainless-Retry-Count\", \"0\")\n\tr.Header.Set(\"X-Stainless-Os\", \"MacOS\")\n\tr.Header.Set(\"X-Dashscope-Authtype\", \"qwen-oauth\")\n\tr.Header.Set(\"X-Stainless-Runtime\", \"node\")\n\n\tif stream {\n\t\tr.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn\n\t}\n\tr.Header.Set(\"Accept\", \"application/json\")\n}\n\nfunc qwenCreds(a *cliproxyauth.Auth) (token, baseURL string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\tif a.Attributes != nil {\n\t\tif v := a.Attributes[\"api_key\"]; v != \"\" {\n\t\t\ttoken = v\n\t\t}\n\t\tif v := a.Attributes[\"base_url\"]; v != \"\" {\n\t\t\tbaseURL = v\n\t\t}\n\t}\n\tif token == \"\" && a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"access_token\"].(string); ok {\n\t\t\ttoken = v\n\t\t}\n\t\tif v, ok := a.Metadata[\"resource_url\"].(string); ok {\n\t\t\tbaseURL = fmt.Sprintf(\"https://%s/v1\", v)\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "internal/runtime/executor/qwen_executor_test.go",
    "content": "package executor\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n)\n\nfunc TestQwenExecutorParseSuffix(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tmodel     string\n\t\twantBase  string\n\t\twantLevel string\n\t}{\n\t\t{\"no suffix\", \"qwen-max\", \"qwen-max\", \"\"},\n\t\t{\"with level suffix\", \"qwen-max(high)\", \"qwen-max\", \"high\"},\n\t\t{\"with budget suffix\", \"qwen-max(16384)\", \"qwen-max\", \"16384\"},\n\t\t{\"complex model name\", \"qwen-plus-latest(medium)\", \"qwen-plus-latest\", \"medium\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := thinking.ParseSuffix(tt.model)\n\t\t\tif result.ModelName != tt.wantBase {\n\t\t\t\tt.Errorf(\"ParseSuffix(%q).ModelName = %q, want %q\", tt.model, result.ModelName, tt.wantBase)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/thinking_providers.go",
    "content": "package executor\n\nimport (\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai\"\n)\n"
  },
  {
    "path": "internal/runtime/executor/token_helpers.go",
    "content": "package executor\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tiktoken-go/tokenizer\"\n)\n\n// tokenizerForModel returns a tokenizer codec suitable for an OpenAI-style model id.\nfunc tokenizerForModel(model string) (tokenizer.Codec, error) {\n\tsanitized := strings.ToLower(strings.TrimSpace(model))\n\tswitch {\n\tcase sanitized == \"\":\n\t\treturn tokenizer.Get(tokenizer.Cl100kBase)\n\tcase strings.HasPrefix(sanitized, \"gpt-5\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT5)\n\tcase strings.HasPrefix(sanitized, \"gpt-5.1\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT5)\n\tcase strings.HasPrefix(sanitized, \"gpt-4.1\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT41)\n\tcase strings.HasPrefix(sanitized, \"gpt-4o\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT4o)\n\tcase strings.HasPrefix(sanitized, \"gpt-4\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT4)\n\tcase strings.HasPrefix(sanitized, \"gpt-3.5\"), strings.HasPrefix(sanitized, \"gpt-3\"):\n\t\treturn tokenizer.ForModel(tokenizer.GPT35Turbo)\n\tcase strings.HasPrefix(sanitized, \"o1\"):\n\t\treturn tokenizer.ForModel(tokenizer.O1)\n\tcase strings.HasPrefix(sanitized, \"o3\"):\n\t\treturn tokenizer.ForModel(tokenizer.O3)\n\tcase strings.HasPrefix(sanitized, \"o4\"):\n\t\treturn tokenizer.ForModel(tokenizer.O4Mini)\n\tdefault:\n\t\treturn tokenizer.Get(tokenizer.O200kBase)\n\t}\n}\n\n// countOpenAIChatTokens approximates prompt tokens for OpenAI chat completions payloads.\nfunc countOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) {\n\tif enc == nil {\n\t\treturn 0, fmt.Errorf(\"encoder is nil\")\n\t}\n\tif len(payload) == 0 {\n\t\treturn 0, nil\n\t}\n\n\troot := gjson.ParseBytes(payload)\n\tsegments := make([]string, 0, 32)\n\n\tcollectOpenAIMessages(root.Get(\"messages\"), &segments)\n\tcollectOpenAITools(root.Get(\"tools\"), &segments)\n\tcollectOpenAIFunctions(root.Get(\"functions\"), &segments)\n\tcollectOpenAIToolChoice(root.Get(\"tool_choice\"), &segments)\n\tcollectOpenAIResponseFormat(root.Get(\"response_format\"), &segments)\n\taddIfNotEmpty(&segments, root.Get(\"input\").String())\n\taddIfNotEmpty(&segments, root.Get(\"prompt\").String())\n\n\tjoined := strings.TrimSpace(strings.Join(segments, \"\\n\"))\n\tif joined == \"\" {\n\t\treturn 0, nil\n\t}\n\n\tcount, err := enc.Count(joined)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn int64(count), nil\n}\n\n// buildOpenAIUsageJSON returns a minimal usage structure understood by downstream translators.\nfunc buildOpenAIUsageJSON(count int64) []byte {\n\treturn []byte(fmt.Sprintf(`{\"usage\":{\"prompt_tokens\":%d,\"completion_tokens\":0,\"total_tokens\":%d}}`, count, count))\n}\n\nfunc collectOpenAIMessages(messages gjson.Result, segments *[]string) {\n\tif !messages.Exists() || !messages.IsArray() {\n\t\treturn\n\t}\n\tmessages.ForEach(func(_, message gjson.Result) bool {\n\t\taddIfNotEmpty(segments, message.Get(\"role\").String())\n\t\taddIfNotEmpty(segments, message.Get(\"name\").String())\n\t\tcollectOpenAIContent(message.Get(\"content\"), segments)\n\t\tcollectOpenAIToolCalls(message.Get(\"tool_calls\"), segments)\n\t\tcollectOpenAIFunctionCall(message.Get(\"function_call\"), segments)\n\t\treturn true\n\t})\n}\n\nfunc collectOpenAIContent(content gjson.Result, segments *[]string) {\n\tif !content.Exists() {\n\t\treturn\n\t}\n\tif content.Type == gjson.String {\n\t\taddIfNotEmpty(segments, content.String())\n\t\treturn\n\t}\n\tif content.IsArray() {\n\t\tcontent.ForEach(func(_, part gjson.Result) bool {\n\t\t\tpartType := part.Get(\"type\").String()\n\t\t\tswitch partType {\n\t\t\tcase \"text\", \"input_text\", \"output_text\":\n\t\t\t\taddIfNotEmpty(segments, part.Get(\"text\").String())\n\t\t\tcase \"image_url\":\n\t\t\t\taddIfNotEmpty(segments, part.Get(\"image_url.url\").String())\n\t\t\tcase \"input_audio\", \"output_audio\", \"audio\":\n\t\t\t\taddIfNotEmpty(segments, part.Get(\"id\").String())\n\t\t\tcase \"tool_result\":\n\t\t\t\taddIfNotEmpty(segments, part.Get(\"name\").String())\n\t\t\t\tcollectOpenAIContent(part.Get(\"content\"), segments)\n\t\t\tdefault:\n\t\t\t\tif part.IsArray() {\n\t\t\t\t\tcollectOpenAIContent(part, segments)\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tif part.Type == gjson.JSON {\n\t\t\t\t\taddIfNotEmpty(segments, part.Raw)\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\taddIfNotEmpty(segments, part.String())\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\treturn\n\t}\n\tif content.Type == gjson.JSON {\n\t\taddIfNotEmpty(segments, content.Raw)\n\t}\n}\n\nfunc collectOpenAIToolCalls(calls gjson.Result, segments *[]string) {\n\tif !calls.Exists() || !calls.IsArray() {\n\t\treturn\n\t}\n\tcalls.ForEach(func(_, call gjson.Result) bool {\n\t\taddIfNotEmpty(segments, call.Get(\"id\").String())\n\t\taddIfNotEmpty(segments, call.Get(\"type\").String())\n\t\tfunction := call.Get(\"function\")\n\t\tif function.Exists() {\n\t\t\taddIfNotEmpty(segments, function.Get(\"name\").String())\n\t\t\taddIfNotEmpty(segments, function.Get(\"description\").String())\n\t\t\taddIfNotEmpty(segments, function.Get(\"arguments\").String())\n\t\t\tif params := function.Get(\"parameters\"); params.Exists() {\n\t\t\t\taddIfNotEmpty(segments, params.Raw)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n}\n\nfunc collectOpenAIFunctionCall(call gjson.Result, segments *[]string) {\n\tif !call.Exists() {\n\t\treturn\n\t}\n\taddIfNotEmpty(segments, call.Get(\"name\").String())\n\taddIfNotEmpty(segments, call.Get(\"arguments\").String())\n}\n\nfunc collectOpenAITools(tools gjson.Result, segments *[]string) {\n\tif !tools.Exists() {\n\t\treturn\n\t}\n\tif tools.IsArray() {\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tappendToolPayload(tool, segments)\n\t\t\treturn true\n\t\t})\n\t\treturn\n\t}\n\tappendToolPayload(tools, segments)\n}\n\nfunc collectOpenAIFunctions(functions gjson.Result, segments *[]string) {\n\tif !functions.Exists() || !functions.IsArray() {\n\t\treturn\n\t}\n\tfunctions.ForEach(func(_, function gjson.Result) bool {\n\t\taddIfNotEmpty(segments, function.Get(\"name\").String())\n\t\taddIfNotEmpty(segments, function.Get(\"description\").String())\n\t\tif params := function.Get(\"parameters\"); params.Exists() {\n\t\t\taddIfNotEmpty(segments, params.Raw)\n\t\t}\n\t\treturn true\n\t})\n}\n\nfunc collectOpenAIToolChoice(choice gjson.Result, segments *[]string) {\n\tif !choice.Exists() {\n\t\treturn\n\t}\n\tif choice.Type == gjson.String {\n\t\taddIfNotEmpty(segments, choice.String())\n\t\treturn\n\t}\n\taddIfNotEmpty(segments, choice.Raw)\n}\n\nfunc collectOpenAIResponseFormat(format gjson.Result, segments *[]string) {\n\tif !format.Exists() {\n\t\treturn\n\t}\n\taddIfNotEmpty(segments, format.Get(\"type\").String())\n\taddIfNotEmpty(segments, format.Get(\"name\").String())\n\tif schema := format.Get(\"json_schema\"); schema.Exists() {\n\t\taddIfNotEmpty(segments, schema.Raw)\n\t}\n\tif schema := format.Get(\"schema\"); schema.Exists() {\n\t\taddIfNotEmpty(segments, schema.Raw)\n\t}\n}\n\nfunc appendToolPayload(tool gjson.Result, segments *[]string) {\n\tif !tool.Exists() {\n\t\treturn\n\t}\n\taddIfNotEmpty(segments, tool.Get(\"type\").String())\n\taddIfNotEmpty(segments, tool.Get(\"name\").String())\n\taddIfNotEmpty(segments, tool.Get(\"description\").String())\n\tif function := tool.Get(\"function\"); function.Exists() {\n\t\taddIfNotEmpty(segments, function.Get(\"name\").String())\n\t\taddIfNotEmpty(segments, function.Get(\"description\").String())\n\t\tif params := function.Get(\"parameters\"); params.Exists() {\n\t\t\taddIfNotEmpty(segments, params.Raw)\n\t\t}\n\t}\n}\n\nfunc addIfNotEmpty(segments *[]string, value string) {\n\tif segments == nil {\n\t\treturn\n\t}\n\tif trimmed := strings.TrimSpace(value); trimmed != \"\" {\n\t\t*segments = append(*segments, trimmed)\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/usage_helpers.go",
    "content": "package executor\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\ntype usageReporter struct {\n\tprovider    string\n\tmodel       string\n\tauthID      string\n\tauthIndex   string\n\tapiKey      string\n\tsource      string\n\trequestedAt time.Time\n\tonce        sync.Once\n}\n\nfunc newUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *usageReporter {\n\tapiKey := apiKeyFromContext(ctx)\n\treporter := &usageReporter{\n\t\tprovider:    provider,\n\t\tmodel:       model,\n\t\trequestedAt: time.Now(),\n\t\tapiKey:      apiKey,\n\t\tsource:      resolveUsageSource(auth, apiKey),\n\t}\n\tif auth != nil {\n\t\treporter.authID = auth.ID\n\t\treporter.authIndex = auth.EnsureIndex()\n\t}\n\treturn reporter\n}\n\nfunc (r *usageReporter) publish(ctx context.Context, detail usage.Detail) {\n\tr.publishWithOutcome(ctx, detail, false)\n}\n\nfunc (r *usageReporter) publishFailure(ctx context.Context) {\n\tr.publishWithOutcome(ctx, usage.Detail{}, true)\n}\n\nfunc (r *usageReporter) trackFailure(ctx context.Context, errPtr *error) {\n\tif r == nil || errPtr == nil {\n\t\treturn\n\t}\n\tif *errPtr != nil {\n\t\tr.publishFailure(ctx)\n\t}\n}\n\nfunc (r *usageReporter) publishWithOutcome(ctx context.Context, detail usage.Detail, failed bool) {\n\tif r == nil {\n\t\treturn\n\t}\n\tif detail.TotalTokens == 0 {\n\t\ttotal := detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens\n\t\tif total > 0 {\n\t\t\tdetail.TotalTokens = total\n\t\t}\n\t}\n\tif detail.InputTokens == 0 && detail.OutputTokens == 0 && detail.ReasoningTokens == 0 && detail.CachedTokens == 0 && detail.TotalTokens == 0 && !failed {\n\t\treturn\n\t}\n\tr.once.Do(func() {\n\t\tusage.PublishRecord(ctx, usage.Record{\n\t\t\tProvider:    r.provider,\n\t\t\tModel:       r.model,\n\t\t\tSource:      r.source,\n\t\t\tAPIKey:      r.apiKey,\n\t\t\tAuthID:      r.authID,\n\t\t\tAuthIndex:   r.authIndex,\n\t\t\tRequestedAt: r.requestedAt,\n\t\t\tFailed:      failed,\n\t\t\tDetail:      detail,\n\t\t})\n\t})\n}\n\n// ensurePublished guarantees that a usage record is emitted exactly once.\n// It is safe to call multiple times; only the first call wins due to once.Do.\n// This is used to ensure request counting even when upstream responses do not\n// include any usage fields (tokens), especially for streaming paths.\nfunc (r *usageReporter) ensurePublished(ctx context.Context) {\n\tif r == nil {\n\t\treturn\n\t}\n\tr.once.Do(func() {\n\t\tusage.PublishRecord(ctx, usage.Record{\n\t\t\tProvider:    r.provider,\n\t\t\tModel:       r.model,\n\t\t\tSource:      r.source,\n\t\t\tAPIKey:      r.apiKey,\n\t\t\tAuthID:      r.authID,\n\t\t\tAuthIndex:   r.authIndex,\n\t\t\tRequestedAt: r.requestedAt,\n\t\t\tFailed:      false,\n\t\t\tDetail:      usage.Detail{},\n\t\t})\n\t})\n}\n\nfunc apiKeyFromContext(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn \"\"\n\t}\n\tginCtx, ok := ctx.Value(\"gin\").(*gin.Context)\n\tif !ok || ginCtx == nil {\n\t\treturn \"\"\n\t}\n\tif v, exists := ginCtx.Get(\"apiKey\"); exists {\n\t\tswitch value := v.(type) {\n\t\tcase string:\n\t\t\treturn value\n\t\tcase fmt.Stringer:\n\t\t\treturn value.String()\n\t\tdefault:\n\t\t\treturn fmt.Sprintf(\"%v\", value)\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {\n\tif auth != nil {\n\t\tprovider := strings.TrimSpace(auth.Provider)\n\t\tif strings.EqualFold(provider, \"gemini-cli\") {\n\t\t\tif id := strings.TrimSpace(auth.ID); id != \"\" {\n\t\t\t\treturn id\n\t\t\t}\n\t\t}\n\t\tif strings.EqualFold(provider, \"vertex\") {\n\t\t\tif auth.Metadata != nil {\n\t\t\t\tif projectID, ok := auth.Metadata[\"project_id\"].(string); ok {\n\t\t\t\t\tif trimmed := strings.TrimSpace(projectID); trimmed != \"\" {\n\t\t\t\t\t\treturn trimmed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif project, ok := auth.Metadata[\"project\"].(string); ok {\n\t\t\t\t\tif trimmed := strings.TrimSpace(project); trimmed != \"\" {\n\t\t\t\t\t\treturn trimmed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif _, value := auth.AccountInfo(); value != \"\" {\n\t\t\treturn strings.TrimSpace(value)\n\t\t}\n\t\tif auth.Metadata != nil {\n\t\t\tif email, ok := auth.Metadata[\"email\"].(string); ok {\n\t\t\t\tif trimmed := strings.TrimSpace(email); trimmed != \"\" {\n\t\t\t\t\treturn trimmed\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif auth.Attributes != nil {\n\t\t\tif key := strings.TrimSpace(auth.Attributes[\"api_key\"]); key != \"\" {\n\t\t\t\treturn key\n\t\t\t}\n\t\t}\n\t}\n\tif trimmed := strings.TrimSpace(ctxAPIKey); trimmed != \"\" {\n\t\treturn trimmed\n\t}\n\treturn \"\"\n}\n\nfunc parseCodexUsage(data []byte) (usage.Detail, bool) {\n\tusageNode := gjson.ParseBytes(data).Get(\"response.usage\")\n\tif !usageNode.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\tdetail := usage.Detail{\n\t\tInputTokens:  usageNode.Get(\"input_tokens\").Int(),\n\t\tOutputTokens: usageNode.Get(\"output_tokens\").Int(),\n\t\tTotalTokens:  usageNode.Get(\"total_tokens\").Int(),\n\t}\n\tif cached := usageNode.Get(\"input_tokens_details.cached_tokens\"); cached.Exists() {\n\t\tdetail.CachedTokens = cached.Int()\n\t}\n\tif reasoning := usageNode.Get(\"output_tokens_details.reasoning_tokens\"); reasoning.Exists() {\n\t\tdetail.ReasoningTokens = reasoning.Int()\n\t}\n\treturn detail, true\n}\n\nfunc parseOpenAIUsage(data []byte) usage.Detail {\n\tusageNode := gjson.ParseBytes(data).Get(\"usage\")\n\tif !usageNode.Exists() {\n\t\treturn usage.Detail{}\n\t}\n\tinputNode := usageNode.Get(\"prompt_tokens\")\n\tif !inputNode.Exists() {\n\t\tinputNode = usageNode.Get(\"input_tokens\")\n\t}\n\toutputNode := usageNode.Get(\"completion_tokens\")\n\tif !outputNode.Exists() {\n\t\toutputNode = usageNode.Get(\"output_tokens\")\n\t}\n\tdetail := usage.Detail{\n\t\tInputTokens:  inputNode.Int(),\n\t\tOutputTokens: outputNode.Int(),\n\t\tTotalTokens:  usageNode.Get(\"total_tokens\").Int(),\n\t}\n\tcached := usageNode.Get(\"prompt_tokens_details.cached_tokens\")\n\tif !cached.Exists() {\n\t\tcached = usageNode.Get(\"input_tokens_details.cached_tokens\")\n\t}\n\tif cached.Exists() {\n\t\tdetail.CachedTokens = cached.Int()\n\t}\n\treasoning := usageNode.Get(\"completion_tokens_details.reasoning_tokens\")\n\tif !reasoning.Exists() {\n\t\treasoning = usageNode.Get(\"output_tokens_details.reasoning_tokens\")\n\t}\n\tif reasoning.Exists() {\n\t\tdetail.ReasoningTokens = reasoning.Int()\n\t}\n\treturn detail\n}\n\nfunc parseOpenAIStreamUsage(line []byte) (usage.Detail, bool) {\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn usage.Detail{}, false\n\t}\n\tusageNode := gjson.GetBytes(payload, \"usage\")\n\tif !usageNode.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\tdetail := usage.Detail{\n\t\tInputTokens:  usageNode.Get(\"prompt_tokens\").Int(),\n\t\tOutputTokens: usageNode.Get(\"completion_tokens\").Int(),\n\t\tTotalTokens:  usageNode.Get(\"total_tokens\").Int(),\n\t}\n\tif cached := usageNode.Get(\"prompt_tokens_details.cached_tokens\"); cached.Exists() {\n\t\tdetail.CachedTokens = cached.Int()\n\t}\n\tif reasoning := usageNode.Get(\"completion_tokens_details.reasoning_tokens\"); reasoning.Exists() {\n\t\tdetail.ReasoningTokens = reasoning.Int()\n\t}\n\treturn detail, true\n}\n\nfunc parseClaudeUsage(data []byte) usage.Detail {\n\tusageNode := gjson.ParseBytes(data).Get(\"usage\")\n\tif !usageNode.Exists() {\n\t\treturn usage.Detail{}\n\t}\n\tdetail := usage.Detail{\n\t\tInputTokens:  usageNode.Get(\"input_tokens\").Int(),\n\t\tOutputTokens: usageNode.Get(\"output_tokens\").Int(),\n\t\tCachedTokens: usageNode.Get(\"cache_read_input_tokens\").Int(),\n\t}\n\tif detail.CachedTokens == 0 {\n\t\t// fall back to creation tokens when read tokens are absent\n\t\tdetail.CachedTokens = usageNode.Get(\"cache_creation_input_tokens\").Int()\n\t}\n\tdetail.TotalTokens = detail.InputTokens + detail.OutputTokens\n\treturn detail\n}\n\nfunc parseClaudeStreamUsage(line []byte) (usage.Detail, bool) {\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn usage.Detail{}, false\n\t}\n\tusageNode := gjson.GetBytes(payload, \"usage\")\n\tif !usageNode.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\tdetail := usage.Detail{\n\t\tInputTokens:  usageNode.Get(\"input_tokens\").Int(),\n\t\tOutputTokens: usageNode.Get(\"output_tokens\").Int(),\n\t\tCachedTokens: usageNode.Get(\"cache_read_input_tokens\").Int(),\n\t}\n\tif detail.CachedTokens == 0 {\n\t\tdetail.CachedTokens = usageNode.Get(\"cache_creation_input_tokens\").Int()\n\t}\n\tdetail.TotalTokens = detail.InputTokens + detail.OutputTokens\n\treturn detail, true\n}\n\nfunc parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {\n\tdetail := usage.Detail{\n\t\tInputTokens:     node.Get(\"promptTokenCount\").Int(),\n\t\tOutputTokens:    node.Get(\"candidatesTokenCount\").Int(),\n\t\tReasoningTokens: node.Get(\"thoughtsTokenCount\").Int(),\n\t\tTotalTokens:     node.Get(\"totalTokenCount\").Int(),\n\t\tCachedTokens:    node.Get(\"cachedContentTokenCount\").Int(),\n\t}\n\tif detail.TotalTokens == 0 {\n\t\tdetail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens\n\t}\n\treturn detail\n}\n\nfunc parseGeminiCLIUsage(data []byte) usage.Detail {\n\tusageNode := gjson.ParseBytes(data)\n\tnode := usageNode.Get(\"response.usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = usageNode.Get(\"response.usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}\n\t}\n\treturn parseGeminiFamilyUsageDetail(node)\n}\n\nfunc parseGeminiUsage(data []byte) usage.Detail {\n\tusageNode := gjson.ParseBytes(data)\n\tnode := usageNode.Get(\"usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = usageNode.Get(\"usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}\n\t}\n\treturn parseGeminiFamilyUsageDetail(node)\n}\n\nfunc parseGeminiStreamUsage(line []byte) (usage.Detail, bool) {\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn usage.Detail{}, false\n\t}\n\tnode := gjson.GetBytes(payload, \"usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = gjson.GetBytes(payload, \"usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\treturn parseGeminiFamilyUsageDetail(node), true\n}\n\nfunc parseGeminiCLIStreamUsage(line []byte) (usage.Detail, bool) {\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn usage.Detail{}, false\n\t}\n\tnode := gjson.GetBytes(payload, \"response.usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = gjson.GetBytes(payload, \"usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\treturn parseGeminiFamilyUsageDetail(node), true\n}\n\nfunc parseAntigravityUsage(data []byte) usage.Detail {\n\tusageNode := gjson.ParseBytes(data)\n\tnode := usageNode.Get(\"response.usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = usageNode.Get(\"usageMetadata\")\n\t}\n\tif !node.Exists() {\n\t\tnode = usageNode.Get(\"usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}\n\t}\n\treturn parseGeminiFamilyUsageDetail(node)\n}\n\nfunc parseAntigravityStreamUsage(line []byte) (usage.Detail, bool) {\n\tpayload := jsonPayload(line)\n\tif len(payload) == 0 || !gjson.ValidBytes(payload) {\n\t\treturn usage.Detail{}, false\n\t}\n\tnode := gjson.GetBytes(payload, \"response.usageMetadata\")\n\tif !node.Exists() {\n\t\tnode = gjson.GetBytes(payload, \"usageMetadata\")\n\t}\n\tif !node.Exists() {\n\t\tnode = gjson.GetBytes(payload, \"usage_metadata\")\n\t}\n\tif !node.Exists() {\n\t\treturn usage.Detail{}, false\n\t}\n\treturn parseGeminiFamilyUsageDetail(node), true\n}\n\nvar stopChunkWithoutUsage sync.Map\n\nfunc rememberStopWithoutUsage(traceID string) {\n\tstopChunkWithoutUsage.Store(traceID, struct{}{})\n\ttime.AfterFunc(10*time.Minute, func() { stopChunkWithoutUsage.Delete(traceID) })\n}\n\n// FilterSSEUsageMetadata removes usageMetadata from SSE events that are not\n// terminal (finishReason != \"stop\"). Stop chunks are left untouched. This\n// function is shared between aistudio and antigravity executors.\nfunc FilterSSEUsageMetadata(payload []byte) []byte {\n\tif len(payload) == 0 {\n\t\treturn payload\n\t}\n\n\tlines := bytes.Split(payload, []byte(\"\\n\"))\n\tmodified := false\n\tfoundData := false\n\tfor idx, line := range lines {\n\t\ttrimmed := bytes.TrimSpace(line)\n\t\tif len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte(\"data:\")) {\n\t\t\tcontinue\n\t\t}\n\t\tfoundData = true\n\t\tdataIdx := bytes.Index(line, []byte(\"data:\"))\n\t\tif dataIdx < 0 {\n\t\t\tcontinue\n\t\t}\n\t\trawJSON := bytes.TrimSpace(line[dataIdx+5:])\n\t\ttraceID := gjson.GetBytes(rawJSON, \"traceId\").String()\n\t\tif isStopChunkWithoutUsage(rawJSON) && traceID != \"\" {\n\t\t\trememberStopWithoutUsage(traceID)\n\t\t\tcontinue\n\t\t}\n\t\tif traceID != \"\" {\n\t\t\tif _, ok := stopChunkWithoutUsage.Load(traceID); ok && hasUsageMetadata(rawJSON) {\n\t\t\t\tstopChunkWithoutUsage.Delete(traceID)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tcleaned, changed := StripUsageMetadataFromJSON(rawJSON)\n\t\tif !changed {\n\t\t\tcontinue\n\t\t}\n\t\tvar rebuilt []byte\n\t\trebuilt = append(rebuilt, line[:dataIdx]...)\n\t\trebuilt = append(rebuilt, []byte(\"data:\")...)\n\t\tif len(cleaned) > 0 {\n\t\t\trebuilt = append(rebuilt, ' ')\n\t\t\trebuilt = append(rebuilt, cleaned...)\n\t\t}\n\t\tlines[idx] = rebuilt\n\t\tmodified = true\n\t}\n\tif !modified {\n\t\tif !foundData {\n\t\t\t// Handle payloads that are raw JSON without SSE data: prefix.\n\t\t\ttrimmed := bytes.TrimSpace(payload)\n\t\t\tcleaned, changed := StripUsageMetadataFromJSON(trimmed)\n\t\t\tif !changed {\n\t\t\t\treturn payload\n\t\t\t}\n\t\t\treturn cleaned\n\t\t}\n\t\treturn payload\n\t}\n\treturn bytes.Join(lines, []byte(\"\\n\"))\n}\n\n// StripUsageMetadataFromJSON drops usageMetadata unless finishReason is present (terminal).\n// It handles both formats:\n// - Aistudio: candidates.0.finishReason\n// - Antigravity: response.candidates.0.finishReason\nfunc StripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {\n\tjsonBytes := bytes.TrimSpace(rawJSON)\n\tif len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {\n\t\treturn rawJSON, false\n\t}\n\n\t// Check for finishReason in both aistudio and antigravity formats\n\tfinishReason := gjson.GetBytes(jsonBytes, \"candidates.0.finishReason\")\n\tif !finishReason.Exists() {\n\t\tfinishReason = gjson.GetBytes(jsonBytes, \"response.candidates.0.finishReason\")\n\t}\n\tterminalReason := finishReason.Exists() && strings.TrimSpace(finishReason.String()) != \"\"\n\n\tusageMetadata := gjson.GetBytes(jsonBytes, \"usageMetadata\")\n\tif !usageMetadata.Exists() {\n\t\tusageMetadata = gjson.GetBytes(jsonBytes, \"response.usageMetadata\")\n\t}\n\n\t// Terminal chunk: keep as-is.\n\tif terminalReason {\n\t\treturn rawJSON, false\n\t}\n\n\t// Nothing to strip\n\tif !usageMetadata.Exists() {\n\t\treturn rawJSON, false\n\t}\n\n\t// Remove usageMetadata from both possible locations\n\tcleaned := jsonBytes\n\tvar changed bool\n\n\tif usageMetadata = gjson.GetBytes(cleaned, \"usageMetadata\"); usageMetadata.Exists() {\n\t\t// Rename usageMetadata to cpaUsageMetadata in the message_start event of Claude\n\t\tcleaned, _ = sjson.SetRawBytes(cleaned, \"cpaUsageMetadata\", []byte(usageMetadata.Raw))\n\t\tcleaned, _ = sjson.DeleteBytes(cleaned, \"usageMetadata\")\n\t\tchanged = true\n\t}\n\n\tif usageMetadata = gjson.GetBytes(cleaned, \"response.usageMetadata\"); usageMetadata.Exists() {\n\t\t// Rename usageMetadata to cpaUsageMetadata in the message_start event of Claude\n\t\tcleaned, _ = sjson.SetRawBytes(cleaned, \"response.cpaUsageMetadata\", []byte(usageMetadata.Raw))\n\t\tcleaned, _ = sjson.DeleteBytes(cleaned, \"response.usageMetadata\")\n\t\tchanged = true\n\t}\n\n\treturn cleaned, changed\n}\n\nfunc hasUsageMetadata(jsonBytes []byte) bool {\n\tif len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {\n\t\treturn false\n\t}\n\tif gjson.GetBytes(jsonBytes, \"usageMetadata\").Exists() {\n\t\treturn true\n\t}\n\tif gjson.GetBytes(jsonBytes, \"response.usageMetadata\").Exists() {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isStopChunkWithoutUsage(jsonBytes []byte) bool {\n\tif len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {\n\t\treturn false\n\t}\n\tfinishReason := gjson.GetBytes(jsonBytes, \"candidates.0.finishReason\")\n\tif !finishReason.Exists() {\n\t\tfinishReason = gjson.GetBytes(jsonBytes, \"response.candidates.0.finishReason\")\n\t}\n\ttrimmed := strings.TrimSpace(finishReason.String())\n\tif !finishReason.Exists() || trimmed == \"\" {\n\t\treturn false\n\t}\n\treturn !hasUsageMetadata(jsonBytes)\n}\n\nfunc jsonPayload(line []byte) []byte {\n\ttrimmed := bytes.TrimSpace(line)\n\tif len(trimmed) == 0 {\n\t\treturn nil\n\t}\n\tif bytes.Equal(trimmed, []byte(\"[DONE]\")) {\n\t\treturn nil\n\t}\n\tif bytes.HasPrefix(trimmed, []byte(\"event:\")) {\n\t\treturn nil\n\t}\n\tif bytes.HasPrefix(trimmed, []byte(\"data:\")) {\n\t\ttrimmed = bytes.TrimSpace(trimmed[len(\"data:\"):])\n\t}\n\tif len(trimmed) == 0 || trimmed[0] != '{' {\n\t\treturn nil\n\t}\n\treturn trimmed\n}\n"
  },
  {
    "path": "internal/runtime/executor/usage_helpers_test.go",
    "content": "package executor\n\nimport \"testing\"\n\nfunc TestParseOpenAIUsageChatCompletions(t *testing.T) {\n\tdata := []byte(`{\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":4},\"completion_tokens_details\":{\"reasoning_tokens\":5}}}`)\n\tdetail := parseOpenAIUsage(data)\n\tif detail.InputTokens != 1 {\n\t\tt.Fatalf(\"input tokens = %d, want %d\", detail.InputTokens, 1)\n\t}\n\tif detail.OutputTokens != 2 {\n\t\tt.Fatalf(\"output tokens = %d, want %d\", detail.OutputTokens, 2)\n\t}\n\tif detail.TotalTokens != 3 {\n\t\tt.Fatalf(\"total tokens = %d, want %d\", detail.TotalTokens, 3)\n\t}\n\tif detail.CachedTokens != 4 {\n\t\tt.Fatalf(\"cached tokens = %d, want %d\", detail.CachedTokens, 4)\n\t}\n\tif detail.ReasoningTokens != 5 {\n\t\tt.Fatalf(\"reasoning tokens = %d, want %d\", detail.ReasoningTokens, 5)\n\t}\n}\n\nfunc TestParseOpenAIUsageResponses(t *testing.T) {\n\tdata := []byte(`{\"usage\":{\"input_tokens\":10,\"output_tokens\":20,\"total_tokens\":30,\"input_tokens_details\":{\"cached_tokens\":7},\"output_tokens_details\":{\"reasoning_tokens\":9}}}`)\n\tdetail := parseOpenAIUsage(data)\n\tif detail.InputTokens != 10 {\n\t\tt.Fatalf(\"input tokens = %d, want %d\", detail.InputTokens, 10)\n\t}\n\tif detail.OutputTokens != 20 {\n\t\tt.Fatalf(\"output tokens = %d, want %d\", detail.OutputTokens, 20)\n\t}\n\tif detail.TotalTokens != 30 {\n\t\tt.Fatalf(\"total tokens = %d, want %d\", detail.TotalTokens, 30)\n\t}\n\tif detail.CachedTokens != 7 {\n\t\tt.Fatalf(\"cached tokens = %d, want %d\", detail.CachedTokens, 7)\n\t}\n\tif detail.ReasoningTokens != 9 {\n\t\tt.Fatalf(\"reasoning tokens = %d, want %d\", detail.ReasoningTokens, 9)\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/executor/user_id_cache.go",
    "content": "package executor\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype userIDCacheEntry struct {\n\tvalue  string\n\texpire time.Time\n}\n\nvar (\n\tuserIDCache            = make(map[string]userIDCacheEntry)\n\tuserIDCacheMu          sync.RWMutex\n\tuserIDCacheCleanupOnce sync.Once\n)\n\nconst (\n\tuserIDTTL                = time.Hour\n\tuserIDCacheCleanupPeriod = 15 * time.Minute\n)\n\nfunc startUserIDCacheCleanup() {\n\tgo func() {\n\t\tticker := time.NewTicker(userIDCacheCleanupPeriod)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tpurgeExpiredUserIDs()\n\t\t}\n\t}()\n}\n\nfunc purgeExpiredUserIDs() {\n\tnow := time.Now()\n\tuserIDCacheMu.Lock()\n\tfor key, entry := range userIDCache {\n\t\tif !entry.expire.After(now) {\n\t\t\tdelete(userIDCache, key)\n\t\t}\n\t}\n\tuserIDCacheMu.Unlock()\n}\n\nfunc userIDCacheKey(apiKey string) string {\n\tsum := sha256.Sum256([]byte(apiKey))\n\treturn hex.EncodeToString(sum[:])\n}\n\nfunc cachedUserID(apiKey string) string {\n\tif apiKey == \"\" {\n\t\treturn generateFakeUserID()\n\t}\n\n\tuserIDCacheCleanupOnce.Do(startUserIDCacheCleanup)\n\n\tkey := userIDCacheKey(apiKey)\n\tnow := time.Now()\n\n\tuserIDCacheMu.RLock()\n\tentry, ok := userIDCache[key]\n\tvalid := ok && entry.value != \"\" && entry.expire.After(now) && isValidUserID(entry.value)\n\tuserIDCacheMu.RUnlock()\n\tif valid {\n\t\tuserIDCacheMu.Lock()\n\t\tentry = userIDCache[key]\n\t\tif entry.value != \"\" && entry.expire.After(now) && isValidUserID(entry.value) {\n\t\t\tentry.expire = now.Add(userIDTTL)\n\t\t\tuserIDCache[key] = entry\n\t\t\tuserIDCacheMu.Unlock()\n\t\t\treturn entry.value\n\t\t}\n\t\tuserIDCacheMu.Unlock()\n\t}\n\n\tnewID := generateFakeUserID()\n\n\tuserIDCacheMu.Lock()\n\tentry, ok = userIDCache[key]\n\tif !ok || entry.value == \"\" || !entry.expire.After(now) || !isValidUserID(entry.value) {\n\t\tentry.value = newID\n\t}\n\tentry.expire = now.Add(userIDTTL)\n\tuserIDCache[key] = entry\n\tuserIDCacheMu.Unlock()\n\treturn entry.value\n}\n"
  },
  {
    "path": "internal/runtime/executor/user_id_cache_test.go",
    "content": "package executor\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc resetUserIDCache() {\n\tuserIDCacheMu.Lock()\n\tuserIDCache = make(map[string]userIDCacheEntry)\n\tuserIDCacheMu.Unlock()\n}\n\nfunc TestCachedUserID_ReusesWithinTTL(t *testing.T) {\n\tresetUserIDCache()\n\n\tfirst := cachedUserID(\"api-key-1\")\n\tsecond := cachedUserID(\"api-key-1\")\n\n\tif first == \"\" {\n\t\tt.Fatal(\"expected generated user_id to be non-empty\")\n\t}\n\tif first != second {\n\t\tt.Fatalf(\"expected cached user_id to be reused, got %q and %q\", first, second)\n\t}\n}\n\nfunc TestCachedUserID_ExpiresAfterTTL(t *testing.T) {\n\tresetUserIDCache()\n\n\texpiredID := cachedUserID(\"api-key-expired\")\n\tcacheKey := userIDCacheKey(\"api-key-expired\")\n\tuserIDCacheMu.Lock()\n\tuserIDCache[cacheKey] = userIDCacheEntry{\n\t\tvalue:  expiredID,\n\t\texpire: time.Now().Add(-time.Minute),\n\t}\n\tuserIDCacheMu.Unlock()\n\n\tnewID := cachedUserID(\"api-key-expired\")\n\tif newID == expiredID {\n\t\tt.Fatalf(\"expected expired user_id to be replaced, got %q\", newID)\n\t}\n\tif newID == \"\" {\n\t\tt.Fatal(\"expected regenerated user_id to be non-empty\")\n\t}\n}\n\nfunc TestCachedUserID_IsScopedByAPIKey(t *testing.T) {\n\tresetUserIDCache()\n\n\tfirst := cachedUserID(\"api-key-1\")\n\tsecond := cachedUserID(\"api-key-2\")\n\n\tif first == second {\n\t\tt.Fatalf(\"expected different API keys to have different user_ids, got %q\", first)\n\t}\n}\n\nfunc TestCachedUserID_RenewsTTLOnHit(t *testing.T) {\n\tresetUserIDCache()\n\n\tkey := \"api-key-renew\"\n\tid := cachedUserID(key)\n\tcacheKey := userIDCacheKey(key)\n\n\tsoon := time.Now()\n\tuserIDCacheMu.Lock()\n\tuserIDCache[cacheKey] = userIDCacheEntry{\n\t\tvalue:  id,\n\t\texpire: soon.Add(2 * time.Second),\n\t}\n\tuserIDCacheMu.Unlock()\n\n\tif refreshed := cachedUserID(key); refreshed != id {\n\t\tt.Fatalf(\"expected cached user_id to be reused before expiry, got %q\", refreshed)\n\t}\n\n\tuserIDCacheMu.RLock()\n\tentry := userIDCache[cacheKey]\n\tuserIDCacheMu.RUnlock()\n\n\tif entry.expire.Sub(soon) < 30*time.Minute {\n\t\tt.Fatalf(\"expected TTL to renew, got %v remaining\", entry.expire.Sub(soon))\n\t}\n}\n"
  },
  {
    "path": "internal/runtime/geminicli/state.go",
    "content": "package geminicli\n\nimport (\n\t\"strings\"\n\t\"sync\"\n)\n\n// SharedCredential keeps canonical OAuth metadata for a multi-project Gemini CLI login.\ntype SharedCredential struct {\n\tprimaryID  string\n\temail      string\n\tmetadata   map[string]any\n\tprojectIDs []string\n\tmu         sync.RWMutex\n}\n\n// NewSharedCredential builds a shared credential container for the given primary entry.\nfunc NewSharedCredential(primaryID, email string, metadata map[string]any, projectIDs []string) *SharedCredential {\n\treturn &SharedCredential{\n\t\tprimaryID:  strings.TrimSpace(primaryID),\n\t\temail:      strings.TrimSpace(email),\n\t\tmetadata:   cloneMap(metadata),\n\t\tprojectIDs: cloneStrings(projectIDs),\n\t}\n}\n\n// PrimaryID returns the owning credential identifier.\nfunc (s *SharedCredential) PrimaryID() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.primaryID\n}\n\n// Email returns the associated account email.\nfunc (s *SharedCredential) Email() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.email\n}\n\n// ProjectIDs returns a snapshot of the configured project identifiers.\nfunc (s *SharedCredential) ProjectIDs() []string {\n\tif s == nil {\n\t\treturn nil\n\t}\n\treturn cloneStrings(s.projectIDs)\n}\n\n// MetadataSnapshot returns a deep copy of the stored OAuth metadata.\nfunc (s *SharedCredential) MetadataSnapshot() map[string]any {\n\tif s == nil {\n\t\treturn nil\n\t}\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\treturn cloneMap(s.metadata)\n}\n\n// MergeMetadata merges the provided fields into the shared metadata and returns an updated copy.\nfunc (s *SharedCredential) MergeMetadata(values map[string]any) map[string]any {\n\tif s == nil {\n\t\treturn nil\n\t}\n\tif len(values) == 0 {\n\t\treturn s.MetadataSnapshot()\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif s.metadata == nil {\n\t\ts.metadata = make(map[string]any, len(values))\n\t}\n\tfor k, v := range values {\n\t\tif v == nil {\n\t\t\tdelete(s.metadata, k)\n\t\t\tcontinue\n\t\t}\n\t\ts.metadata[k] = v\n\t}\n\treturn cloneMap(s.metadata)\n}\n\n// SetProjectIDs updates the stored project identifiers.\nfunc (s *SharedCredential) SetProjectIDs(ids []string) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\ts.projectIDs = cloneStrings(ids)\n\ts.mu.Unlock()\n}\n\n// VirtualCredential tracks a per-project virtual auth entry that reuses a primary credential.\ntype VirtualCredential struct {\n\tProjectID string\n\tParent    *SharedCredential\n}\n\n// NewVirtualCredential creates a virtual credential descriptor bound to the shared parent.\nfunc NewVirtualCredential(projectID string, parent *SharedCredential) *VirtualCredential {\n\treturn &VirtualCredential{ProjectID: strings.TrimSpace(projectID), Parent: parent}\n}\n\n// ResolveSharedCredential returns the shared credential backing the provided runtime payload.\nfunc ResolveSharedCredential(runtime any) *SharedCredential {\n\tswitch typed := runtime.(type) {\n\tcase *SharedCredential:\n\t\treturn typed\n\tcase *VirtualCredential:\n\t\treturn typed.Parent\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// IsVirtual reports whether the runtime payload represents a virtual credential.\nfunc IsVirtual(runtime any) bool {\n\tif runtime == nil {\n\t\treturn false\n\t}\n\t_, ok := runtime.(*VirtualCredential)\n\treturn ok\n}\n\nfunc cloneMap(in map[string]any) map[string]any {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]any, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc cloneStrings(in []string) []string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, len(in))\n\tcopy(out, in)\n\treturn out\n}\n"
  },
  {
    "path": "internal/store/gitstore.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-git/go-git/v6\"\n\t\"github.com/go-git/go-git/v6/config\"\n\t\"github.com/go-git/go-git/v6/plumbing\"\n\t\"github.com/go-git/go-git/v6/plumbing/object\"\n\t\"github.com/go-git/go-git/v6/plumbing/transport\"\n\t\"github.com/go-git/go-git/v6/plumbing/transport/http\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// gcInterval defines minimum time between garbage collection runs.\nconst gcInterval = 5 * time.Minute\n\n// GitTokenStore persists token records and auth metadata using git as the backing storage.\ntype GitTokenStore struct {\n\tmu        sync.Mutex\n\tdirLock   sync.RWMutex\n\tbaseDir   string\n\trepoDir   string\n\tconfigDir string\n\tremote    string\n\tusername  string\n\tpassword  string\n\tlastGC    time.Time\n}\n\n// NewGitTokenStore creates a token store that saves credentials to disk through the\n// TokenStorage implementation embedded in the token record.\nfunc NewGitTokenStore(remote, username, password string) *GitTokenStore {\n\treturn &GitTokenStore{\n\t\tremote:   remote,\n\t\tusername: username,\n\t\tpassword: password,\n\t}\n}\n\n// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.\nfunc (s *GitTokenStore) SetBaseDir(dir string) {\n\tclean := strings.TrimSpace(dir)\n\tif clean == \"\" {\n\t\ts.dirLock.Lock()\n\t\ts.baseDir = \"\"\n\t\ts.repoDir = \"\"\n\t\ts.configDir = \"\"\n\t\ts.dirLock.Unlock()\n\t\treturn\n\t}\n\tif abs, err := filepath.Abs(clean); err == nil {\n\t\tclean = abs\n\t}\n\trepoDir := filepath.Dir(clean)\n\tif repoDir == \"\" || repoDir == \".\" {\n\t\trepoDir = clean\n\t}\n\tconfigDir := filepath.Join(repoDir, \"config\")\n\ts.dirLock.Lock()\n\ts.baseDir = clean\n\ts.repoDir = repoDir\n\ts.configDir = configDir\n\ts.dirLock.Unlock()\n}\n\n// AuthDir returns the directory used for auth persistence.\nfunc (s *GitTokenStore) AuthDir() string {\n\treturn s.baseDirSnapshot()\n}\n\n// ConfigPath returns the managed config file path.\nfunc (s *GitTokenStore) ConfigPath() string {\n\ts.dirLock.RLock()\n\tdefer s.dirLock.RUnlock()\n\tif s.configDir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(s.configDir, \"config.yaml\")\n}\n\n// EnsureRepository prepares the local git working tree by cloning or opening the repository.\nfunc (s *GitTokenStore) EnsureRepository() error {\n\ts.dirLock.Lock()\n\tif s.remote == \"\" {\n\t\ts.dirLock.Unlock()\n\t\treturn fmt.Errorf(\"git token store: remote not configured\")\n\t}\n\tif s.baseDir == \"\" {\n\t\ts.dirLock.Unlock()\n\t\treturn fmt.Errorf(\"git token store: base directory not configured\")\n\t}\n\trepoDir := s.repoDir\n\tif repoDir == \"\" {\n\t\trepoDir = filepath.Dir(s.baseDir)\n\t\tif repoDir == \"\" || repoDir == \".\" {\n\t\t\trepoDir = s.baseDir\n\t\t}\n\t\ts.repoDir = repoDir\n\t}\n\tif s.configDir == \"\" {\n\t\ts.configDir = filepath.Join(repoDir, \"config\")\n\t}\n\tauthDir := filepath.Join(repoDir, \"auths\")\n\tconfigDir := filepath.Join(repoDir, \"config\")\n\tgitDir := filepath.Join(repoDir, \".git\")\n\tauthMethod := s.gitAuth()\n\tvar initPaths []string\n\tif _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) {\n\t\tif errMk := os.MkdirAll(repoDir, 0o700); errMk != nil {\n\t\t\ts.dirLock.Unlock()\n\t\t\treturn fmt.Errorf(\"git token store: create repo dir: %w\", errMk)\n\t\t}\n\t\tif _, errClone := git.PlainClone(repoDir, &git.CloneOptions{Auth: authMethod, URL: s.remote}); errClone != nil {\n\t\t\tif errors.Is(errClone, transport.ErrEmptyRemoteRepository) {\n\t\t\t\t_ = os.RemoveAll(gitDir)\n\t\t\t\trepo, errInit := git.PlainInit(repoDir, false)\n\t\t\t\tif errInit != nil {\n\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\treturn fmt.Errorf(\"git token store: init empty repo: %w\", errInit)\n\t\t\t\t}\n\t\t\t\tif _, errRemote := repo.Remote(\"origin\"); errRemote != nil {\n\t\t\t\t\tif _, errCreate := repo.CreateRemote(&config.RemoteConfig{\n\t\t\t\t\t\tName: \"origin\",\n\t\t\t\t\t\tURLs: []string{s.remote},\n\t\t\t\t\t}); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) {\n\t\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\t\treturn fmt.Errorf(\"git token store: configure remote: %w\", errCreate)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif err := os.MkdirAll(authDir, 0o700); err != nil {\n\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\treturn fmt.Errorf(\"git token store: create auth dir: %w\", err)\n\t\t\t\t}\n\t\t\t\tif err := os.MkdirAll(configDir, 0o700); err != nil {\n\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\treturn fmt.Errorf(\"git token store: create config dir: %w\", err)\n\t\t\t\t}\n\t\t\t\tif err := ensureEmptyFile(filepath.Join(authDir, \".gitkeep\")); err != nil {\n\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\treturn fmt.Errorf(\"git token store: create auth placeholder: %w\", err)\n\t\t\t\t}\n\t\t\t\tif err := ensureEmptyFile(filepath.Join(configDir, \".gitkeep\")); err != nil {\n\t\t\t\t\ts.dirLock.Unlock()\n\t\t\t\t\treturn fmt.Errorf(\"git token store: create config placeholder: %w\", err)\n\t\t\t\t}\n\t\t\t\tinitPaths = []string{\n\t\t\t\t\tfilepath.Join(\"auths\", \".gitkeep\"),\n\t\t\t\t\tfilepath.Join(\"config\", \".gitkeep\"),\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ts.dirLock.Unlock()\n\t\t\t\treturn fmt.Errorf(\"git token store: clone remote: %w\", errClone)\n\t\t\t}\n\t\t}\n\t} else if err != nil {\n\t\ts.dirLock.Unlock()\n\t\treturn fmt.Errorf(\"git token store: stat repo: %w\", err)\n\t} else {\n\t\trepo, errOpen := git.PlainOpen(repoDir)\n\t\tif errOpen != nil {\n\t\t\ts.dirLock.Unlock()\n\t\t\treturn fmt.Errorf(\"git token store: open repo: %w\", errOpen)\n\t\t}\n\t\tworktree, errWorktree := repo.Worktree()\n\t\tif errWorktree != nil {\n\t\t\ts.dirLock.Unlock()\n\t\t\treturn fmt.Errorf(\"git token store: worktree: %w\", errWorktree)\n\t\t}\n\t\tif errPull := worktree.Pull(&git.PullOptions{Auth: authMethod, RemoteName: \"origin\"}); errPull != nil {\n\t\t\tswitch {\n\t\t\tcase errors.Is(errPull, git.NoErrAlreadyUpToDate),\n\t\t\t\terrors.Is(errPull, git.ErrUnstagedChanges),\n\t\t\t\terrors.Is(errPull, git.ErrNonFastForwardUpdate):\n\t\t\t\t// Ignore clean syncs, local edits, and remote divergence—local changes win.\n\t\t\tcase errors.Is(errPull, transport.ErrAuthenticationRequired),\n\t\t\t\terrors.Is(errPull, plumbing.ErrReferenceNotFound),\n\t\t\t\terrors.Is(errPull, transport.ErrEmptyRemoteRepository):\n\t\t\t\t// Ignore authentication prompts and empty remote references on initial sync.\n\t\t\tdefault:\n\t\t\t\ts.dirLock.Unlock()\n\t\t\t\treturn fmt.Errorf(\"git token store: pull: %w\", errPull)\n\t\t\t}\n\t\t}\n\t}\n\tif err := os.MkdirAll(s.baseDir, 0o700); err != nil {\n\t\ts.dirLock.Unlock()\n\t\treturn fmt.Errorf(\"git token store: create auth dir: %w\", err)\n\t}\n\tif err := os.MkdirAll(s.configDir, 0o700); err != nil {\n\t\ts.dirLock.Unlock()\n\t\treturn fmt.Errorf(\"git token store: create config dir: %w\", err)\n\t}\n\ts.dirLock.Unlock()\n\tif len(initPaths) > 0 {\n\t\ts.mu.Lock()\n\t\terr := s.commitAndPushLocked(\"Initialize git token store\", initPaths...)\n\t\ts.mu.Unlock()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Save persists token storage and metadata to the resolved auth file path.\nfunc (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: auth is nil\")\n\t}\n\n\tpath, err := s.resolveAuthPath(auth)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif path == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: missing file path attribute for %s\", auth.ID)\n\t}\n\n\tif auth.Disabled {\n\t\tif _, statErr := os.Stat(path); os.IsNotExist(statErr) {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\n\tif err = s.EnsureRepository(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: create dir failed: %w\", err)\n\t}\n\n\tswitch {\n\tcase auth.Storage != nil:\n\t\tif err = auth.Storage.SaveTokenToFile(path); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\tcase auth.Metadata != nil:\n\t\traw, errMarshal := json.Marshal(auth.Metadata)\n\t\tif errMarshal != nil {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: marshal metadata failed: %w\", errMarshal)\n\t\t}\n\t\tif existing, errRead := os.ReadFile(path); errRead == nil {\n\t\t\tif jsonEqual(existing, raw) {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t} else if !os.IsNotExist(errRead) {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: read existing failed: %w\", errRead)\n\t\t}\n\t\ttmp := path + \".tmp\"\n\t\tif errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: write temp failed: %w\", errWrite)\n\t\t}\n\t\tif errRename := os.Rename(tmp, path); errRename != nil {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: rename failed: %w\", errRename)\n\t\t}\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"auth filestore: nothing to persist for %s\", auth.ID)\n\t}\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tauth.Attributes[\"path\"] = path\n\n\tif strings.TrimSpace(auth.FileName) == \"\" {\n\t\tauth.FileName = auth.ID\n\t}\n\n\trelPath, errRel := s.relativeToRepo(path)\n\tif errRel != nil {\n\t\treturn \"\", errRel\n\t}\n\tmessageID := auth.ID\n\tif strings.TrimSpace(messageID) == \"\" {\n\t\tmessageID = filepath.Base(path)\n\t}\n\tif errCommit := s.commitAndPushLocked(fmt.Sprintf(\"Update auth %s\", strings.TrimSpace(messageID)), relPath); errCommit != nil {\n\t\treturn \"\", errCommit\n\t}\n\n\treturn path, nil\n}\n\n// List enumerates all auth JSON files under the configured directory.\nfunc (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {\n\tif err := s.EnsureRepository(); err != nil {\n\t\treturn nil, err\n\t}\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn nil, fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\tentries := make([]*cliproxyauth.Auth, 0)\n\terr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {\n\t\tif walkErr != nil {\n\t\t\treturn walkErr\n\t\t}\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(d.Name()), \".json\") {\n\t\t\treturn nil\n\t\t}\n\t\tauth, err := s.readAuthFile(path, dir)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tif auth != nil {\n\t\t\tentries = append(entries, auth)\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn entries, nil\n}\n\n// Delete removes the auth file.\nfunc (s *GitTokenStore) Delete(_ context.Context, id string) error {\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn fmt.Errorf(\"auth filestore: id is empty\")\n\t}\n\tpath, err := s.resolveDeletePath(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err = s.EnsureRepository(); err != nil {\n\t\treturn err\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.Remove(path); err != nil && !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"auth filestore: delete failed: %w\", err)\n\t}\n\tif err == nil {\n\t\trel, errRel := s.relativeToRepo(path)\n\t\tif errRel != nil {\n\t\t\treturn errRel\n\t\t}\n\t\tmessageID := id\n\t\tif errCommit := s.commitAndPushLocked(fmt.Sprintf(\"Delete auth %s\", messageID), rel); errCommit != nil {\n\t\t\treturn errCommit\n\t\t}\n\t}\n\treturn nil\n}\n\n// PersistAuthFiles commits and pushes the provided paths to the remote repository.\n// It no-ops when the store is not fully configured or when there are no paths.\nfunc (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {\n\tif len(paths) == 0 {\n\t\treturn nil\n\t}\n\tif err := s.EnsureRepository(); err != nil {\n\t\treturn err\n\t}\n\n\tfiltered := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\ttrimmed := strings.TrimSpace(p)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trel, err := s.relativeToRepo(trimmed)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfiltered = append(filtered, rel)\n\t}\n\tif len(filtered) == 0 {\n\t\treturn nil\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"Sync watcher updates\"\n\t}\n\treturn s.commitAndPushLocked(message, filtered...)\n}\n\nfunc (s *GitTokenStore) resolveDeletePath(id string) (string, error) {\n\tif strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {\n\t\treturn id, nil\n\t}\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\treturn filepath.Join(dir, id), nil\n}\n\nfunc (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn nil, nil\n\t}\n\tmetadata := make(map[string]any)\n\tif err = json.Unmarshal(data, &metadata); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal auth json: %w\", err)\n\t}\n\tprovider, _ := metadata[\"type\"].(string)\n\tif provider == \"\" {\n\t\tprovider = \"unknown\"\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stat file: %w\", err)\n\t}\n\tid := s.idFor(path, baseDir)\n\tauth := &cliproxyauth.Auth{\n\t\tID:               id,\n\t\tProvider:         provider,\n\t\tFileName:         id,\n\t\tLabel:            s.labelFor(metadata),\n\t\tStatus:           cliproxyauth.StatusActive,\n\t\tAttributes:       map[string]string{\"path\": path},\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        info.ModTime(),\n\t\tUpdatedAt:        info.ModTime(),\n\t\tLastRefreshedAt:  time.Time{},\n\t\tNextRefreshAfter: time.Time{},\n\t}\n\tif email, ok := metadata[\"email\"].(string); ok && email != \"\" {\n\t\tauth.Attributes[\"email\"] = email\n\t}\n\treturn auth, nil\n}\n\nfunc (s *GitTokenStore) idFor(path, baseDir string) string {\n\tif baseDir == \"\" {\n\t\treturn path\n\t}\n\trel, err := filepath.Rel(baseDir, path)\n\tif err != nil {\n\t\treturn path\n\t}\n\treturn rel\n}\n\nfunc (s *GitTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: auth is nil\")\n\t}\n\tif auth.Attributes != nil {\n\t\tif p := strings.TrimSpace(auth.Attributes[\"path\"]); p != \"\" {\n\t\t\treturn p, nil\n\t\t}\n\t}\n\tif fileName := strings.TrimSpace(auth.FileName); fileName != \"\" {\n\t\tif filepath.IsAbs(fileName) {\n\t\t\treturn fileName, nil\n\t\t}\n\t\tif dir := s.baseDirSnapshot(); dir != \"\" {\n\t\t\treturn filepath.Join(dir, fileName), nil\n\t\t}\n\t\treturn fileName, nil\n\t}\n\tif auth.ID == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: missing id\")\n\t}\n\tif filepath.IsAbs(auth.ID) {\n\t\treturn auth.ID, nil\n\t}\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\treturn filepath.Join(dir, auth.ID), nil\n}\n\nfunc (s *GitTokenStore) labelFor(metadata map[string]any) string {\n\tif metadata == nil {\n\t\treturn \"\"\n\t}\n\tif v, ok := metadata[\"label\"].(string); ok && v != \"\" {\n\t\treturn v\n\t}\n\tif v, ok := metadata[\"email\"].(string); ok && v != \"\" {\n\t\treturn v\n\t}\n\tif project, ok := metadata[\"project_id\"].(string); ok && project != \"\" {\n\t\treturn project\n\t}\n\treturn \"\"\n}\n\nfunc (s *GitTokenStore) baseDirSnapshot() string {\n\ts.dirLock.RLock()\n\tdefer s.dirLock.RUnlock()\n\treturn s.baseDir\n}\n\nfunc (s *GitTokenStore) repoDirSnapshot() string {\n\ts.dirLock.RLock()\n\tdefer s.dirLock.RUnlock()\n\treturn s.repoDir\n}\n\nfunc (s *GitTokenStore) gitAuth() transport.AuthMethod {\n\tif s.username == \"\" && s.password == \"\" {\n\t\treturn nil\n\t}\n\tuser := s.username\n\tif user == \"\" {\n\t\tuser = \"git\"\n\t}\n\treturn &http.BasicAuth{Username: user, Password: s.password}\n}\n\nfunc (s *GitTokenStore) relativeToRepo(path string) (string, error) {\n\trepoDir := s.repoDirSnapshot()\n\tif repoDir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"git token store: repository path not configured\")\n\t}\n\tabsRepo := repoDir\n\tif abs, err := filepath.Abs(repoDir); err == nil {\n\t\tabsRepo = abs\n\t}\n\tcleanPath := path\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tcleanPath = abs\n\t}\n\trel, err := filepath.Rel(absRepo, cleanPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"git token store: relative path: %w\", err)\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", fmt.Errorf(\"git token store: path outside repository\")\n\t}\n\treturn rel, nil\n}\n\nfunc (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error {\n\trepoDir := s.repoDirSnapshot()\n\tif repoDir == \"\" {\n\t\treturn fmt.Errorf(\"git token store: repository path not configured\")\n\t}\n\trepo, err := git.PlainOpen(repoDir)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"git token store: open repo: %w\", err)\n\t}\n\tworktree, err := repo.Worktree()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"git token store: worktree: %w\", err)\n\t}\n\tadded := false\n\tfor _, rel := range relPaths {\n\t\tif strings.TrimSpace(rel) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err = worktree.Add(rel); err != nil {\n\t\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t\tif _, errRemove := worktree.Remove(rel); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {\n\t\t\t\t\treturn fmt.Errorf(\"git token store: remove %s: %w\", rel, errRemove)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn fmt.Errorf(\"git token store: add %s: %w\", rel, err)\n\t\t\t}\n\t\t}\n\t\tadded = true\n\t}\n\tif !added {\n\t\treturn nil\n\t}\n\tstatus, err := worktree.Status()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"git token store: status: %w\", err)\n\t}\n\tif status.IsClean() {\n\t\treturn nil\n\t}\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"Update auth store\"\n\t}\n\tsignature := &object.Signature{\n\t\tName:  \"CLIProxyAPI\",\n\t\tEmail: \"cliproxy@local\",\n\t\tWhen:  time.Now(),\n\t}\n\tcommitHash, err := worktree.Commit(message, &git.CommitOptions{\n\t\tAuthor: signature,\n\t})\n\tif err != nil {\n\t\tif errors.Is(err, git.ErrEmptyCommit) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"git token store: commit: %w\", err)\n\t}\n\theadRef, errHead := repo.Head()\n\tif errHead != nil {\n\t\tif !errors.Is(errHead, plumbing.ErrReferenceNotFound) {\n\t\t\treturn fmt.Errorf(\"git token store: get head: %w\", errHead)\n\t\t}\n\t} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {\n\t\treturn errRewrite\n\t}\n\ts.maybeRunGC(repo)\n\tif err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {\n\t\tif errors.Is(err, git.NoErrAlreadyUpToDate) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"git token store: push: %w\", err)\n\t}\n\treturn nil\n}\n\n// rewriteHeadAsSingleCommit rewrites the current branch tip to a single-parentless commit and leaves history squashed.\nfunc (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch plumbing.ReferenceName, commitHash plumbing.Hash, message string, signature *object.Signature) error {\n\tcommitObj, err := repo.CommitObject(commitHash)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"git token store: inspect head commit: %w\", err)\n\t}\n\tsquashed := &object.Commit{\n\t\tAuthor:       *signature,\n\t\tCommitter:    *signature,\n\t\tMessage:      message,\n\t\tTreeHash:     commitObj.TreeHash,\n\t\tParentHashes: nil,\n\t\tEncoding:     commitObj.Encoding,\n\t\tExtraHeaders: commitObj.ExtraHeaders,\n\t}\n\tmem := &plumbing.MemoryObject{}\n\tmem.SetType(plumbing.CommitObject)\n\tif err := squashed.Encode(mem); err != nil {\n\t\treturn fmt.Errorf(\"git token store: encode squashed commit: %w\", err)\n\t}\n\tnewHash, err := repo.Storer.SetEncodedObject(mem)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"git token store: write squashed commit: %w\", err)\n\t}\n\tif err := repo.Storer.SetReference(plumbing.NewHashReference(branch, newHash)); err != nil {\n\t\treturn fmt.Errorf(\"git token store: update branch reference: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *GitTokenStore) maybeRunGC(repo *git.Repository) {\n\tnow := time.Now()\n\tif now.Sub(s.lastGC) < gcInterval {\n\t\treturn\n\t}\n\ts.lastGC = now\n\n\tpruneOpts := git.PruneOptions{\n\t\tOnlyObjectsOlderThan: now,\n\t\tHandler:              repo.DeleteObject,\n\t}\n\tif err := repo.Prune(pruneOpts); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) {\n\t\treturn\n\t}\n\t_ = repo.RepackObjects(&git.RepackConfig{})\n}\n\n// PersistConfig commits and pushes configuration changes to git.\nfunc (s *GitTokenStore) PersistConfig(_ context.Context) error {\n\tif err := s.EnsureRepository(); err != nil {\n\t\treturn err\n\t}\n\tconfigPath := s.ConfigPath()\n\tif configPath == \"\" {\n\t\treturn fmt.Errorf(\"git token store: config path not configured\")\n\t}\n\tif _, err := os.Stat(configPath); err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"git token store: stat config: %w\", err)\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\trel, err := s.relativeToRepo(configPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.commitAndPushLocked(\"Update config\", rel)\n}\n\nfunc ensureEmptyFile(path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn os.WriteFile(path, []byte{}, 0o600)\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc jsonEqual(a, b []byte) bool {\n\tvar objA any\n\tvar objB any\n\tif err := json.Unmarshal(a, &objA); err != nil {\n\t\treturn false\n\t}\n\tif err := json.Unmarshal(b, &objB); err != nil {\n\t\treturn false\n\t}\n\treturn deepEqualJSON(objA, objB)\n}\n\nfunc deepEqualJSON(a, b any) bool {\n\tswitch valA := a.(type) {\n\tcase map[string]any:\n\t\tvalB, ok := b.(map[string]any)\n\t\tif !ok || len(valA) != len(valB) {\n\t\t\treturn false\n\t\t}\n\t\tfor key, subA := range valA {\n\t\t\tsubB, ok1 := valB[key]\n\t\t\tif !ok1 || !deepEqualJSON(subA, subB) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase []any:\n\t\tsliceB, ok := b.([]any)\n\t\tif !ok || len(valA) != len(sliceB) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := range valA {\n\t\t\tif !deepEqualJSON(valA[i], sliceB[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase float64:\n\t\tvalB, ok := b.(float64)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase string:\n\t\tvalB, ok := b.(string)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase bool:\n\t\tvalB, ok := b.(bool)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase nil:\n\t\treturn b == nil\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "internal/store/objectstore.go",
    "content": "package store\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/minio/minio-go/v7\"\n\t\"github.com/minio/minio-go/v7/pkg/credentials\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tobjectStoreConfigKey  = \"config/config.yaml\"\n\tobjectStoreAuthPrefix = \"auths\"\n)\n\n// ObjectStoreConfig captures configuration for the object storage-backed token store.\ntype ObjectStoreConfig struct {\n\tEndpoint  string\n\tBucket    string\n\tAccessKey string\n\tSecretKey string\n\tRegion    string\n\tPrefix    string\n\tLocalRoot string\n\tUseSSL    bool\n\tPathStyle bool\n}\n\n// ObjectTokenStore persists configuration and authentication metadata using an S3-compatible object storage backend.\n// Files are mirrored to a local workspace so existing file-based flows continue to operate.\ntype ObjectTokenStore struct {\n\tclient     *minio.Client\n\tcfg        ObjectStoreConfig\n\tspoolRoot  string\n\tconfigPath string\n\tauthDir    string\n\tmu         sync.Mutex\n}\n\n// NewObjectTokenStore initializes an object storage backed token store.\nfunc NewObjectTokenStore(cfg ObjectStoreConfig) (*ObjectTokenStore, error) {\n\tcfg.Endpoint = strings.TrimSpace(cfg.Endpoint)\n\tcfg.Bucket = strings.TrimSpace(cfg.Bucket)\n\tcfg.AccessKey = strings.TrimSpace(cfg.AccessKey)\n\tcfg.SecretKey = strings.TrimSpace(cfg.SecretKey)\n\tcfg.Prefix = strings.Trim(cfg.Prefix, \"/\")\n\n\tif cfg.Endpoint == \"\" {\n\t\treturn nil, fmt.Errorf(\"object store: endpoint is required\")\n\t}\n\tif cfg.Bucket == \"\" {\n\t\treturn nil, fmt.Errorf(\"object store: bucket is required\")\n\t}\n\tif cfg.AccessKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"object store: access key is required\")\n\t}\n\tif cfg.SecretKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"object store: secret key is required\")\n\t}\n\n\troot := strings.TrimSpace(cfg.LocalRoot)\n\tif root == \"\" {\n\t\tif cwd, err := os.Getwd(); err == nil {\n\t\t\troot = filepath.Join(cwd, \"objectstore\")\n\t\t} else {\n\t\t\troot = filepath.Join(os.TempDir(), \"objectstore\")\n\t\t}\n\t}\n\tabsRoot, err := filepath.Abs(root)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"object store: resolve spool directory: %w\", err)\n\t}\n\n\tconfigDir := filepath.Join(absRoot, \"config\")\n\tauthDir := filepath.Join(absRoot, \"auths\")\n\n\tif err = os.MkdirAll(configDir, 0o700); err != nil {\n\t\treturn nil, fmt.Errorf(\"object store: create config directory: %w\", err)\n\t}\n\tif err = os.MkdirAll(authDir, 0o700); err != nil {\n\t\treturn nil, fmt.Errorf(\"object store: create auth directory: %w\", err)\n\t}\n\n\toptions := &minio.Options{\n\t\tCreds:  credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, \"\"),\n\t\tSecure: cfg.UseSSL,\n\t\tRegion: cfg.Region,\n\t}\n\tif cfg.PathStyle {\n\t\toptions.BucketLookup = minio.BucketLookupPath\n\t}\n\n\tclient, err := minio.New(cfg.Endpoint, options)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"object store: create client: %w\", err)\n\t}\n\n\treturn &ObjectTokenStore{\n\t\tclient:     client,\n\t\tcfg:        cfg,\n\t\tspoolRoot:  absRoot,\n\t\tconfigPath: filepath.Join(configDir, \"config.yaml\"),\n\t\tauthDir:    authDir,\n\t}, nil\n}\n\n// SetBaseDir implements the optional interface used by authenticators; it is a no-op because\n// the object store controls its own workspace.\nfunc (s *ObjectTokenStore) SetBaseDir(string) {}\n\n// ConfigPath returns the managed configuration file path inside the spool directory.\nfunc (s *ObjectTokenStore) ConfigPath() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.configPath\n}\n\n// AuthDir returns the local directory containing mirrored auth files.\nfunc (s *ObjectTokenStore) AuthDir() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.authDir\n}\n\n// Bootstrap ensures the target bucket exists and synchronizes data from the object storage backend.\nfunc (s *ObjectTokenStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {\n\tif s == nil {\n\t\treturn fmt.Errorf(\"object store: not initialized\")\n\t}\n\tif err := s.ensureBucket(ctx); err != nil {\n\t\treturn err\n\t}\n\tif err := s.syncConfigFromBucket(ctx, exampleConfigPath); err != nil {\n\t\treturn err\n\t}\n\tif err := s.syncAuthFromBucket(ctx); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Save persists authentication metadata to disk and uploads it to the object storage backend.\nfunc (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"object store: auth is nil\")\n\t}\n\n\tpath, err := s.resolveAuthPath(auth)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif path == \"\" {\n\t\treturn \"\", fmt.Errorf(\"object store: missing file path attribute for %s\", auth.ID)\n\t}\n\n\tif auth.Disabled {\n\t\tif _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"object store: create auth directory: %w\", err)\n\t}\n\n\tswitch {\n\tcase auth.Storage != nil:\n\t\tif err = auth.Storage.SaveTokenToFile(path); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\tcase auth.Metadata != nil:\n\t\traw, errMarshal := json.Marshal(auth.Metadata)\n\t\tif errMarshal != nil {\n\t\t\treturn \"\", fmt.Errorf(\"object store: marshal metadata: %w\", errMarshal)\n\t\t}\n\t\tif existing, errRead := os.ReadFile(path); errRead == nil {\n\t\t\tif jsonEqual(existing, raw) {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {\n\t\t\treturn \"\", fmt.Errorf(\"object store: read existing metadata: %w\", errRead)\n\t\t}\n\t\ttmp := path + \".tmp\"\n\t\tif errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {\n\t\t\treturn \"\", fmt.Errorf(\"object store: write temp auth file: %w\", errWrite)\n\t\t}\n\t\tif errRename := os.Rename(tmp, path); errRename != nil {\n\t\t\treturn \"\", fmt.Errorf(\"object store: rename auth file: %w\", errRename)\n\t\t}\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"object store: nothing to persist for %s\", auth.ID)\n\t}\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tauth.Attributes[\"path\"] = path\n\n\tif strings.TrimSpace(auth.FileName) == \"\" {\n\t\tauth.FileName = auth.ID\n\t}\n\n\tif err = s.uploadAuth(ctx, path); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn path, nil\n}\n\n// List enumerates auth JSON files from the mirrored workspace.\nfunc (s *ObjectTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {\n\tdir := strings.TrimSpace(s.AuthDir())\n\tif dir == \"\" {\n\t\treturn nil, fmt.Errorf(\"object store: auth directory not configured\")\n\t}\n\tentries := make([]*cliproxyauth.Auth, 0, 32)\n\terr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {\n\t\tif walkErr != nil {\n\t\t\treturn walkErr\n\t\t}\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(d.Name()), \".json\") {\n\t\t\treturn nil\n\t\t}\n\t\tauth, err := s.readAuthFile(path, dir)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).Warnf(\"object store: skip auth %s\", path)\n\t\t\treturn nil\n\t\t}\n\t\tif auth != nil {\n\t\t\tentries = append(entries, auth)\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"object store: walk auth directory: %w\", err)\n\t}\n\treturn entries, nil\n}\n\n// Delete removes an auth file locally and remotely.\nfunc (s *ObjectTokenStore) Delete(ctx context.Context, id string) error {\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn fmt.Errorf(\"object store: id is empty\")\n\t}\n\tpath, err := s.resolveDeletePath(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn fmt.Errorf(\"object store: delete auth file: %w\", err)\n\t}\n\tif err = s.deleteAuthObject(ctx, path); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// PersistAuthFiles uploads the provided auth files to the object storage backend.\nfunc (s *ObjectTokenStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {\n\tif len(paths) == 0 {\n\t\treturn nil\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tfor _, p := range paths {\n\t\ttrimmed := strings.TrimSpace(p)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tabs := trimmed\n\t\tif !filepath.IsAbs(abs) {\n\t\t\tabs = filepath.Join(s.authDir, trimmed)\n\t\t}\n\t\tif err := s.uploadAuth(ctx, abs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// PersistConfig uploads the local configuration file to the object storage backend.\nfunc (s *ObjectTokenStore) PersistConfig(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tdata, err := os.ReadFile(s.configPath)\n\tif err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn s.deleteObject(ctx, objectStoreConfigKey)\n\t\t}\n\t\treturn fmt.Errorf(\"object store: read config file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn s.deleteObject(ctx, objectStoreConfigKey)\n\t}\n\treturn s.putObject(ctx, objectStoreConfigKey, data, \"application/x-yaml\")\n}\n\nfunc (s *ObjectTokenStore) ensureBucket(ctx context.Context) error {\n\texists, err := s.client.BucketExists(ctx, s.cfg.Bucket)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"object store: check bucket: %w\", err)\n\t}\n\tif exists {\n\t\treturn nil\n\t}\n\tif err = s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{Region: s.cfg.Region}); err != nil {\n\t\treturn fmt.Errorf(\"object store: create bucket: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *ObjectTokenStore) syncConfigFromBucket(ctx context.Context, example string) error {\n\tkey := s.prefixedKey(objectStoreConfigKey)\n\t_, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{})\n\tswitch {\n\tcase err == nil:\n\t\tobject, errGet := s.client.GetObject(ctx, s.cfg.Bucket, key, minio.GetObjectOptions{})\n\t\tif errGet != nil {\n\t\t\treturn fmt.Errorf(\"object store: fetch config: %w\", errGet)\n\t\t}\n\t\tdefer object.Close()\n\t\tdata, errRead := io.ReadAll(object)\n\t\tif errRead != nil {\n\t\t\treturn fmt.Errorf(\"object store: read config: %w\", errRead)\n\t\t}\n\t\tif errWrite := os.WriteFile(s.configPath, normalizeLineEndingsBytes(data), 0o600); errWrite != nil {\n\t\t\treturn fmt.Errorf(\"object store: write config: %w\", errWrite)\n\t\t}\n\tcase isObjectNotFound(err):\n\t\tif _, statErr := os.Stat(s.configPath); errors.Is(statErr, fs.ErrNotExist) {\n\t\t\tif example != \"\" {\n\t\t\t\tif errCopy := misc.CopyConfigTemplate(example, s.configPath); errCopy != nil {\n\t\t\t\t\treturn fmt.Errorf(\"object store: copy example config: %w\", errCopy)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {\n\t\t\t\t\treturn fmt.Errorf(\"object store: prepare config directory: %w\", errCreate)\n\t\t\t\t}\n\t\t\t\tif errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {\n\t\t\t\t\treturn fmt.Errorf(\"object store: create empty config: %w\", errWrite)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tdata, errRead := os.ReadFile(s.configPath)\n\t\tif errRead != nil {\n\t\t\treturn fmt.Errorf(\"object store: read local config: %w\", errRead)\n\t\t}\n\t\tif len(data) > 0 {\n\t\t\tif errPut := s.putObject(ctx, objectStoreConfigKey, data, \"application/x-yaml\"); errPut != nil {\n\t\t\t\treturn errPut\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"object store: stat config: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *ObjectTokenStore) syncAuthFromBucket(ctx context.Context) error {\n\t// NOTE: We intentionally do NOT use os.RemoveAll here.\n\t// Wiping the directory triggers file watcher delete events, which then\n\t// propagate deletions to the remote object store (race condition).\n\t// Instead, we just ensure the directory exists and overwrite files incrementally.\n\tif err := os.MkdirAll(s.authDir, 0o700); err != nil {\n\t\treturn fmt.Errorf(\"object store: create auth directory: %w\", err)\n\t}\n\n\tprefix := s.prefixedKey(objectStoreAuthPrefix + \"/\")\n\tobjectCh := s.client.ListObjects(ctx, s.cfg.Bucket, minio.ListObjectsOptions{\n\t\tPrefix:    prefix,\n\t\tRecursive: true,\n\t})\n\tfor object := range objectCh {\n\t\tif object.Err != nil {\n\t\t\treturn fmt.Errorf(\"object store: list auth objects: %w\", object.Err)\n\t\t}\n\t\trel := strings.TrimPrefix(object.Key, prefix)\n\t\tif rel == \"\" || strings.HasSuffix(rel, \"/\") {\n\t\t\tcontinue\n\t\t}\n\t\trelPath := filepath.FromSlash(rel)\n\t\tif filepath.IsAbs(relPath) {\n\t\t\tlog.WithField(\"key\", object.Key).Warn(\"object store: skip auth outside mirror\")\n\t\t\tcontinue\n\t\t}\n\t\tcleanRel := filepath.Clean(relPath)\n\t\tif cleanRel == \".\" || cleanRel == \"..\" || strings.HasPrefix(cleanRel, \"..\"+string(os.PathSeparator)) {\n\t\t\tlog.WithField(\"key\", object.Key).Warn(\"object store: skip auth outside mirror\")\n\t\t\tcontinue\n\t\t}\n\t\tlocal := filepath.Join(s.authDir, cleanRel)\n\t\tif err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil {\n\t\t\treturn fmt.Errorf(\"object store: prepare auth subdir: %w\", err)\n\t\t}\n\t\treader, errGet := s.client.GetObject(ctx, s.cfg.Bucket, object.Key, minio.GetObjectOptions{})\n\t\tif errGet != nil {\n\t\t\treturn fmt.Errorf(\"object store: download auth %s: %w\", object.Key, errGet)\n\t\t}\n\t\tdata, errRead := io.ReadAll(reader)\n\t\t_ = reader.Close()\n\t\tif errRead != nil {\n\t\t\treturn fmt.Errorf(\"object store: read auth %s: %w\", object.Key, errRead)\n\t\t}\n\t\tif errWrite := os.WriteFile(local, data, 0o600); errWrite != nil {\n\t\t\treturn fmt.Errorf(\"object store: write auth %s: %w\", local, errWrite)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *ObjectTokenStore) uploadAuth(ctx context.Context, path string) error {\n\tif path == \"\" {\n\t\treturn nil\n\t}\n\trel, err := filepath.Rel(s.authDir, path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"object store: resolve auth relative path: %w\", err)\n\t}\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn s.deleteAuthObject(ctx, path)\n\t\t}\n\t\treturn fmt.Errorf(\"object store: read auth file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn s.deleteAuthObject(ctx, path)\n\t}\n\tkey := objectStoreAuthPrefix + \"/\" + filepath.ToSlash(rel)\n\treturn s.putObject(ctx, key, data, \"application/json\")\n}\n\nfunc (s *ObjectTokenStore) deleteAuthObject(ctx context.Context, path string) error {\n\tif path == \"\" {\n\t\treturn nil\n\t}\n\trel, err := filepath.Rel(s.authDir, path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"object store: resolve auth relative path: %w\", err)\n\t}\n\tkey := objectStoreAuthPrefix + \"/\" + filepath.ToSlash(rel)\n\treturn s.deleteObject(ctx, key)\n}\n\nfunc (s *ObjectTokenStore) putObject(ctx context.Context, key string, data []byte, contentType string) error {\n\tif len(data) == 0 {\n\t\treturn s.deleteObject(ctx, key)\n\t}\n\tfullKey := s.prefixedKey(key)\n\treader := bytes.NewReader(data)\n\t_, err := s.client.PutObject(ctx, s.cfg.Bucket, fullKey, reader, int64(len(data)), minio.PutObjectOptions{\n\t\tContentType: contentType,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"object store: put object %s: %w\", fullKey, err)\n\t}\n\treturn nil\n}\n\nfunc (s *ObjectTokenStore) deleteObject(ctx context.Context, key string) error {\n\tfullKey := s.prefixedKey(key)\n\terr := s.client.RemoveObject(ctx, s.cfg.Bucket, fullKey, minio.RemoveObjectOptions{})\n\tif err != nil {\n\t\tif isObjectNotFound(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"object store: delete object %s: %w\", fullKey, err)\n\t}\n\treturn nil\n}\n\nfunc (s *ObjectTokenStore) prefixedKey(key string) string {\n\tkey = strings.TrimLeft(key, \"/\")\n\tif s.cfg.Prefix == \"\" {\n\t\treturn key\n\t}\n\treturn strings.TrimLeft(s.cfg.Prefix+\"/\"+key, \"/\")\n}\n\nfunc (s *ObjectTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"object store: auth is nil\")\n\t}\n\tif auth.Attributes != nil {\n\t\tif path := strings.TrimSpace(auth.Attributes[\"path\"]); path != \"\" {\n\t\t\tif filepath.IsAbs(path) {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t\treturn filepath.Join(s.authDir, path), nil\n\t\t}\n\t}\n\tfileName := strings.TrimSpace(auth.FileName)\n\tif fileName == \"\" {\n\t\tfileName = strings.TrimSpace(auth.ID)\n\t}\n\tif fileName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"object store: auth %s missing filename\", auth.ID)\n\t}\n\tif !strings.HasSuffix(strings.ToLower(fileName), \".json\") {\n\t\tfileName += \".json\"\n\t}\n\treturn filepath.Join(s.authDir, fileName), nil\n}\n\nfunc (s *ObjectTokenStore) resolveDeletePath(id string) (string, error) {\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn \"\", fmt.Errorf(\"object store: id is empty\")\n\t}\n\t// Absolute paths are honored as-is; callers must ensure they point inside the mirror.\n\tif filepath.IsAbs(id) {\n\t\treturn id, nil\n\t}\n\t// Treat any non-absolute id (including nested like \"team/foo\") as relative to the mirror authDir.\n\t// Normalize separators and guard against path traversal.\n\tclean := filepath.Clean(filepath.FromSlash(id))\n\tif clean == \".\" || clean == \"..\" || strings.HasPrefix(clean, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", fmt.Errorf(\"object store: invalid auth identifier %s\", id)\n\t}\n\t// Ensure .json suffix.\n\tif !strings.HasSuffix(strings.ToLower(clean), \".json\") {\n\t\tclean += \".json\"\n\t}\n\treturn filepath.Join(s.authDir, clean), nil\n}\n\nfunc (s *ObjectTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn nil, nil\n\t}\n\tmetadata := make(map[string]any)\n\tif err = json.Unmarshal(data, &metadata); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal auth json: %w\", err)\n\t}\n\tprovider := strings.TrimSpace(valueAsString(metadata[\"type\"]))\n\tif provider == \"\" {\n\t\tprovider = \"unknown\"\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stat auth file: %w\", err)\n\t}\n\trel, errRel := filepath.Rel(baseDir, path)\n\tif errRel != nil {\n\t\trel = filepath.Base(path)\n\t}\n\trel = normalizeAuthID(rel)\n\tattr := map[string]string{\"path\": path}\n\tif email := strings.TrimSpace(valueAsString(metadata[\"email\"])); email != \"\" {\n\t\tattr[\"email\"] = email\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tID:               rel,\n\t\tProvider:         provider,\n\t\tFileName:         rel,\n\t\tLabel:            labelFor(metadata),\n\t\tStatus:           cliproxyauth.StatusActive,\n\t\tAttributes:       attr,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        info.ModTime(),\n\t\tUpdatedAt:        info.ModTime(),\n\t\tLastRefreshedAt:  time.Time{},\n\t\tNextRefreshAfter: time.Time{},\n\t}\n\treturn auth, nil\n}\n\nfunc normalizeLineEndingsBytes(data []byte) []byte {\n\treplaced := bytes.ReplaceAll(data, []byte{'\\r', '\\n'}, []byte{'\\n'})\n\treturn bytes.ReplaceAll(replaced, []byte{'\\r'}, []byte{'\\n'})\n}\n\nfunc isObjectNotFound(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tresp := minio.ToErrorResponse(err)\n\tif resp.StatusCode == http.StatusNotFound {\n\t\treturn true\n\t}\n\tswitch resp.Code {\n\tcase \"NoSuchKey\", \"NotFound\", \"NoSuchBucket\":\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/store/postgresstore.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t_ \"github.com/jackc/pgx/v5/stdlib\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tdefaultConfigTable = \"config_store\"\n\tdefaultAuthTable   = \"auth_store\"\n\tdefaultConfigKey   = \"config\"\n)\n\n// PostgresStoreConfig captures configuration required to initialize a Postgres-backed store.\ntype PostgresStoreConfig struct {\n\tDSN         string\n\tSchema      string\n\tConfigTable string\n\tAuthTable   string\n\tSpoolDir    string\n}\n\n// PostgresStore persists configuration and authentication metadata using PostgreSQL as backend\n// while mirroring data to a local workspace so existing file-based workflows continue to operate.\ntype PostgresStore struct {\n\tdb         *sql.DB\n\tcfg        PostgresStoreConfig\n\tspoolRoot  string\n\tconfigPath string\n\tauthDir    string\n\tmu         sync.Mutex\n}\n\n// NewPostgresStore establishes a connection to PostgreSQL and prepares the local workspace.\nfunc NewPostgresStore(ctx context.Context, cfg PostgresStoreConfig) (*PostgresStore, error) {\n\ttrimmedDSN := strings.TrimSpace(cfg.DSN)\n\tif trimmedDSN == \"\" {\n\t\treturn nil, fmt.Errorf(\"postgres store: DSN is required\")\n\t}\n\tcfg.DSN = trimmedDSN\n\tif cfg.ConfigTable == \"\" {\n\t\tcfg.ConfigTable = defaultConfigTable\n\t}\n\tif cfg.AuthTable == \"\" {\n\t\tcfg.AuthTable = defaultAuthTable\n\t}\n\n\tspoolRoot := strings.TrimSpace(cfg.SpoolDir)\n\tif spoolRoot == \"\" {\n\t\tif cwd, err := os.Getwd(); err == nil {\n\t\t\tspoolRoot = filepath.Join(cwd, \"pgstore\")\n\t\t} else {\n\t\t\tspoolRoot = filepath.Join(os.TempDir(), \"pgstore\")\n\t\t}\n\t}\n\tabsSpool, err := filepath.Abs(spoolRoot)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: resolve spool directory: %w\", err)\n\t}\n\tconfigDir := filepath.Join(absSpool, \"config\")\n\tauthDir := filepath.Join(absSpool, \"auths\")\n\tif err = os.MkdirAll(configDir, 0o700); err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: create config directory: %w\", err)\n\t}\n\tif err = os.MkdirAll(authDir, 0o700); err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: create auth directory: %w\", err)\n\t}\n\n\tdb, err := sql.Open(\"pgx\", cfg.DSN)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: open database connection: %w\", err)\n\t}\n\tif err = db.PingContext(ctx); err != nil {\n\t\t_ = db.Close()\n\t\treturn nil, fmt.Errorf(\"postgres store: ping database: %w\", err)\n\t}\n\n\tstore := &PostgresStore{\n\t\tdb:         db,\n\t\tcfg:        cfg,\n\t\tspoolRoot:  absSpool,\n\t\tconfigPath: filepath.Join(configDir, \"config.yaml\"),\n\t\tauthDir:    authDir,\n\t}\n\treturn store, nil\n}\n\n// Close releases the underlying database connection.\nfunc (s *PostgresStore) Close() error {\n\tif s == nil || s.db == nil {\n\t\treturn nil\n\t}\n\treturn s.db.Close()\n}\n\n// EnsureSchema creates the required tables (and schema when provided).\nfunc (s *PostgresStore) EnsureSchema(ctx context.Context) error {\n\tif s == nil || s.db == nil {\n\t\treturn fmt.Errorf(\"postgres store: not initialized\")\n\t}\n\tif schema := strings.TrimSpace(s.cfg.Schema); schema != \"\" {\n\t\tquery := fmt.Sprintf(\"CREATE SCHEMA IF NOT EXISTS %s\", quoteIdentifier(schema))\n\t\tif _, err := s.db.ExecContext(ctx, query); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: create schema: %w\", err)\n\t\t}\n\t}\n\tconfigTable := s.fullTableName(s.cfg.ConfigTable)\n\tif _, err := s.db.ExecContext(ctx, fmt.Sprintf(`\n\t\tCREATE TABLE IF NOT EXISTS %s (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcontent TEXT NOT NULL,\n\t\t\tcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\t\t\tupdated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n\t\t)\n\t`, configTable)); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: create config table: %w\", err)\n\t}\n\tauthTable := s.fullTableName(s.cfg.AuthTable)\n\tif _, err := s.db.ExecContext(ctx, fmt.Sprintf(`\n\t\tCREATE TABLE IF NOT EXISTS %s (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcontent JSONB NOT NULL,\n\t\t\tcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\t\t\tupdated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n\t\t)\n\t`, authTable)); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: create auth table: %w\", err)\n\t}\n\treturn nil\n}\n\n// Bootstrap synchronizes configuration and auth records between PostgreSQL and the local workspace.\nfunc (s *PostgresStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {\n\tif err := s.EnsureSchema(ctx); err != nil {\n\t\treturn err\n\t}\n\tif err := s.syncConfigFromDatabase(ctx, exampleConfigPath); err != nil {\n\t\treturn err\n\t}\n\tif err := s.syncAuthFromDatabase(ctx); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ConfigPath returns the managed configuration file path inside the spool directory.\nfunc (s *PostgresStore) ConfigPath() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.configPath\n}\n\n// AuthDir returns the local directory containing mirrored auth files.\nfunc (s *PostgresStore) AuthDir() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.authDir\n}\n\n// WorkDir exposes the root spool directory used for mirroring.\nfunc (s *PostgresStore) WorkDir() string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn s.spoolRoot\n}\n\n// SetBaseDir implements the optional interface used by authenticators; it is a no-op because\n// the Postgres-backed store controls its own workspace.\nfunc (s *PostgresStore) SetBaseDir(string) {}\n\n// Save persists authentication metadata to disk and PostgreSQL.\nfunc (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: auth is nil\")\n\t}\n\n\tpath, err := s.resolveAuthPath(auth)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif path == \"\" {\n\t\treturn \"\", fmt.Errorf(\"postgres store: missing file path attribute for %s\", auth.ID)\n\t}\n\n\tif auth.Disabled {\n\t\tif _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: create auth directory: %w\", err)\n\t}\n\n\tswitch {\n\tcase auth.Storage != nil:\n\t\tif err = auth.Storage.SaveTokenToFile(path); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\tcase auth.Metadata != nil:\n\t\traw, errMarshal := json.Marshal(auth.Metadata)\n\t\tif errMarshal != nil {\n\t\t\treturn \"\", fmt.Errorf(\"postgres store: marshal metadata: %w\", errMarshal)\n\t\t}\n\t\tif existing, errRead := os.ReadFile(path); errRead == nil {\n\t\t\tif jsonEqual(existing, raw) {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {\n\t\t\treturn \"\", fmt.Errorf(\"postgres store: read existing metadata: %w\", errRead)\n\t\t}\n\t\ttmp := path + \".tmp\"\n\t\tif errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {\n\t\t\treturn \"\", fmt.Errorf(\"postgres store: write temp auth file: %w\", errWrite)\n\t\t}\n\t\tif errRename := os.Rename(tmp, path); errRename != nil {\n\t\t\treturn \"\", fmt.Errorf(\"postgres store: rename auth file: %w\", errRename)\n\t\t}\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"postgres store: nothing to persist for %s\", auth.ID)\n\t}\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tauth.Attributes[\"path\"] = path\n\n\tif strings.TrimSpace(auth.FileName) == \"\" {\n\t\tauth.FileName = auth.ID\n\t}\n\n\trelID, err := s.relativeAuthID(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err = s.upsertAuthRecord(ctx, relID, path); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn path, nil\n}\n\n// List enumerates all auth records stored in PostgreSQL.\nfunc (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {\n\tquery := fmt.Sprintf(\"SELECT id, content, created_at, updated_at FROM %s ORDER BY id\", s.fullTableName(s.cfg.AuthTable))\n\trows, err := s.db.QueryContext(ctx, query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: list auth: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tauths := make([]*cliproxyauth.Auth, 0, 32)\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tid        string\n\t\t\tpayload   string\n\t\t\tcreatedAt time.Time\n\t\t\tupdatedAt time.Time\n\t\t)\n\t\tif err = rows.Scan(&id, &payload, &createdAt, &updatedAt); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"postgres store: scan auth row: %w\", err)\n\t\t}\n\t\tpath, errPath := s.absoluteAuthPath(id)\n\t\tif errPath != nil {\n\t\t\tlog.WithError(errPath).Warnf(\"postgres store: skipping auth %s outside spool\", id)\n\t\t\tcontinue\n\t\t}\n\t\tmetadata := make(map[string]any)\n\t\tif err = json.Unmarshal([]byte(payload), &metadata); err != nil {\n\t\t\tlog.WithError(err).Warnf(\"postgres store: skipping auth %s with invalid json\", id)\n\t\t\tcontinue\n\t\t}\n\t\tprovider := strings.TrimSpace(valueAsString(metadata[\"type\"]))\n\t\tif provider == \"\" {\n\t\t\tprovider = \"unknown\"\n\t\t}\n\t\tattr := map[string]string{\"path\": path}\n\t\tif email := strings.TrimSpace(valueAsString(metadata[\"email\"])); email != \"\" {\n\t\t\tattr[\"email\"] = email\n\t\t}\n\t\tauth := &cliproxyauth.Auth{\n\t\t\tID:               normalizeAuthID(id),\n\t\t\tProvider:         provider,\n\t\t\tFileName:         normalizeAuthID(id),\n\t\t\tLabel:            labelFor(metadata),\n\t\t\tStatus:           cliproxyauth.StatusActive,\n\t\t\tAttributes:       attr,\n\t\t\tMetadata:         metadata,\n\t\t\tCreatedAt:        createdAt,\n\t\t\tUpdatedAt:        updatedAt,\n\t\t\tLastRefreshedAt:  time.Time{},\n\t\t\tNextRefreshAfter: time.Time{},\n\t\t}\n\t\tauths = append(auths, auth)\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"postgres store: iterate auth rows: %w\", err)\n\t}\n\treturn auths, nil\n}\n\n// Delete removes an auth file and the corresponding database record.\nfunc (s *PostgresStore) Delete(ctx context.Context, id string) error {\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn fmt.Errorf(\"postgres store: id is empty\")\n\t}\n\tpath, err := s.resolveDeletePath(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn fmt.Errorf(\"postgres store: delete auth file: %w\", err)\n\t}\n\trelID, err := s.relativeAuthID(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.deleteAuthRecord(ctx, relID)\n}\n\n// PersistAuthFiles stores the provided auth file changes in PostgreSQL.\nfunc (s *PostgresStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {\n\tif len(paths) == 0 {\n\t\treturn nil\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tfor _, p := range paths {\n\t\ttrimmed := strings.TrimSpace(p)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trelID, err := s.relativeAuthID(trimmed)\n\t\tif err != nil {\n\t\t\t// Attempt to resolve absolute path under authDir.\n\t\t\tabs := trimmed\n\t\t\tif !filepath.IsAbs(abs) {\n\t\t\t\tabs = filepath.Join(s.authDir, trimmed)\n\t\t\t}\n\t\t\trelID, err = s.relativeAuthID(abs)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).Warnf(\"postgres store: ignoring auth path %s\", trimmed)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttrimmed = abs\n\t\t}\n\t\tif err = s.syncAuthFile(ctx, relID, trimmed); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// PersistConfig mirrors the local configuration file to PostgreSQL.\nfunc (s *PostgresStore) PersistConfig(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tdata, err := os.ReadFile(s.configPath)\n\tif err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn s.deleteConfigRecord(ctx)\n\t\t}\n\t\treturn fmt.Errorf(\"postgres store: read config file: %w\", err)\n\t}\n\treturn s.persistConfig(ctx, data)\n}\n\n// syncConfigFromDatabase writes the database-stored config to disk or seeds the database from template.\nfunc (s *PostgresStore) syncConfigFromDatabase(ctx context.Context, exampleConfigPath string) error {\n\tquery := fmt.Sprintf(\"SELECT content FROM %s WHERE id = $1\", s.fullTableName(s.cfg.ConfigTable))\n\tvar content string\n\terr := s.db.QueryRowContext(ctx, query, defaultConfigKey).Scan(&content)\n\tswitch {\n\tcase errors.Is(err, sql.ErrNoRows):\n\t\tif _, errStat := os.Stat(s.configPath); errors.Is(errStat, fs.ErrNotExist) {\n\t\t\tif exampleConfigPath != \"\" {\n\t\t\t\tif errCopy := misc.CopyConfigTemplate(exampleConfigPath, s.configPath); errCopy != nil {\n\t\t\t\t\treturn fmt.Errorf(\"postgres store: copy example config: %w\", errCopy)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {\n\t\t\t\t\treturn fmt.Errorf(\"postgres store: prepare config directory: %w\", errCreate)\n\t\t\t\t}\n\t\t\t\tif errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {\n\t\t\t\t\treturn fmt.Errorf(\"postgres store: create empty config: %w\", errWrite)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tdata, errRead := os.ReadFile(s.configPath)\n\t\tif errRead != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: read local config: %w\", errRead)\n\t\t}\n\t\tif errPersist := s.persistConfig(ctx, data); errPersist != nil {\n\t\t\treturn errPersist\n\t\t}\n\tcase err != nil:\n\t\treturn fmt.Errorf(\"postgres store: load config from database: %w\", err)\n\tdefault:\n\t\tif err = os.MkdirAll(filepath.Dir(s.configPath), 0o700); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: prepare config directory: %w\", err)\n\t\t}\n\t\tnormalized := normalizeLineEndings(content)\n\t\tif err = os.WriteFile(s.configPath, []byte(normalized), 0o600); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: write config to spool: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\n// syncAuthFromDatabase populates the local auth directory from PostgreSQL data.\nfunc (s *PostgresStore) syncAuthFromDatabase(ctx context.Context) error {\n\tquery := fmt.Sprintf(\"SELECT id, content FROM %s\", s.fullTableName(s.cfg.AuthTable))\n\trows, err := s.db.QueryContext(ctx, query)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"postgres store: load auth from database: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tif err = os.RemoveAll(s.authDir); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: reset auth directory: %w\", err)\n\t}\n\tif err = os.MkdirAll(s.authDir, 0o700); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: recreate auth directory: %w\", err)\n\t}\n\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tid      string\n\t\t\tpayload string\n\t\t)\n\t\tif err = rows.Scan(&id, &payload); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: scan auth row: %w\", err)\n\t\t}\n\t\tpath, errPath := s.absoluteAuthPath(id)\n\t\tif errPath != nil {\n\t\t\tlog.WithError(errPath).Warnf(\"postgres store: skipping auth %s outside spool\", id)\n\t\t\tcontinue\n\t\t}\n\t\tif err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: create auth subdir: %w\", err)\n\t\t}\n\t\tif err = os.WriteFile(path, []byte(payload), 0o600); err != nil {\n\t\t\treturn fmt.Errorf(\"postgres store: write auth file: %w\", err)\n\t\t}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: iterate auth rows: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *PostgresStore) syncAuthFile(ctx context.Context, relID, path string) error {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn s.deleteAuthRecord(ctx, relID)\n\t\t}\n\t\treturn fmt.Errorf(\"postgres store: read auth file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn s.deleteAuthRecord(ctx, relID)\n\t}\n\treturn s.persistAuth(ctx, relID, data)\n}\n\nfunc (s *PostgresStore) upsertAuthRecord(ctx context.Context, relID, path string) error {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"postgres store: read auth file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn s.deleteAuthRecord(ctx, relID)\n\t}\n\treturn s.persistAuth(ctx, relID, data)\n}\n\nfunc (s *PostgresStore) persistAuth(ctx context.Context, relID string, data []byte) error {\n\tjsonPayload := json.RawMessage(data)\n\tquery := fmt.Sprintf(`\n\t\tINSERT INTO %s (id, content, created_at, updated_at)\n\t\tVALUES ($1, $2, NOW(), NOW())\n\t\tON CONFLICT (id)\n\t\tDO UPDATE SET content = EXCLUDED.content, updated_at = NOW()\n\t`, s.fullTableName(s.cfg.AuthTable))\n\tif _, err := s.db.ExecContext(ctx, query, relID, jsonPayload); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: upsert auth record: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *PostgresStore) deleteAuthRecord(ctx context.Context, relID string) error {\n\tquery := fmt.Sprintf(\"DELETE FROM %s WHERE id = $1\", s.fullTableName(s.cfg.AuthTable))\n\tif _, err := s.db.ExecContext(ctx, query, relID); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: delete auth record: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *PostgresStore) persistConfig(ctx context.Context, data []byte) error {\n\tquery := fmt.Sprintf(`\n\t\tINSERT INTO %s (id, content, created_at, updated_at)\n\t\tVALUES ($1, $2, NOW(), NOW())\n\t\tON CONFLICT (id)\n\t\tDO UPDATE SET content = EXCLUDED.content, updated_at = NOW()\n\t`, s.fullTableName(s.cfg.ConfigTable))\n\tnormalized := normalizeLineEndings(string(data))\n\tif _, err := s.db.ExecContext(ctx, query, defaultConfigKey, normalized); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: upsert config: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *PostgresStore) deleteConfigRecord(ctx context.Context) error {\n\tquery := fmt.Sprintf(\"DELETE FROM %s WHERE id = $1\", s.fullTableName(s.cfg.ConfigTable))\n\tif _, err := s.db.ExecContext(ctx, query, defaultConfigKey); err != nil {\n\t\treturn fmt.Errorf(\"postgres store: delete config: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *PostgresStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: auth is nil\")\n\t}\n\tif auth.Attributes != nil {\n\t\tif p := strings.TrimSpace(auth.Attributes[\"path\"]); p != \"\" {\n\t\t\treturn p, nil\n\t\t}\n\t}\n\tif fileName := strings.TrimSpace(auth.FileName); fileName != \"\" {\n\t\tif filepath.IsAbs(fileName) {\n\t\t\treturn fileName, nil\n\t\t}\n\t\treturn filepath.Join(s.authDir, fileName), nil\n\t}\n\tif auth.ID == \"\" {\n\t\treturn \"\", fmt.Errorf(\"postgres store: missing id\")\n\t}\n\tif filepath.IsAbs(auth.ID) {\n\t\treturn auth.ID, nil\n\t}\n\treturn filepath.Join(s.authDir, filepath.FromSlash(auth.ID)), nil\n}\n\nfunc (s *PostgresStore) resolveDeletePath(id string) (string, error) {\n\tif strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {\n\t\treturn id, nil\n\t}\n\treturn filepath.Join(s.authDir, filepath.FromSlash(id)), nil\n}\n\nfunc (s *PostgresStore) relativeAuthID(path string) (string, error) {\n\tif s == nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: store not initialized\")\n\t}\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(s.authDir, path)\n\t}\n\tclean := filepath.Clean(path)\n\trel, err := filepath.Rel(s.authDir, clean)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: compute relative path: %w\", err)\n\t}\n\tif strings.HasPrefix(rel, \"..\") {\n\t\treturn \"\", fmt.Errorf(\"postgres store: path %s outside managed directory\", path)\n\t}\n\treturn filepath.ToSlash(rel), nil\n}\n\nfunc (s *PostgresStore) absoluteAuthPath(id string) (string, error) {\n\tif s == nil {\n\t\treturn \"\", fmt.Errorf(\"postgres store: store not initialized\")\n\t}\n\tclean := filepath.Clean(filepath.FromSlash(id))\n\tif strings.HasPrefix(clean, \"..\") {\n\t\treturn \"\", fmt.Errorf(\"postgres store: invalid auth identifier %s\", id)\n\t}\n\tpath := filepath.Join(s.authDir, clean)\n\trel, err := filepath.Rel(s.authDir, path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif strings.HasPrefix(rel, \"..\") {\n\t\treturn \"\", fmt.Errorf(\"postgres store: resolved auth path escapes auth directory\")\n\t}\n\treturn path, nil\n}\n\nfunc (s *PostgresStore) fullTableName(name string) string {\n\tif strings.TrimSpace(s.cfg.Schema) == \"\" {\n\t\treturn quoteIdentifier(name)\n\t}\n\treturn quoteIdentifier(s.cfg.Schema) + \".\" + quoteIdentifier(name)\n}\n\nfunc quoteIdentifier(identifier string) string {\n\treplaced := strings.ReplaceAll(identifier, \"\\\"\", \"\\\"\\\"\")\n\treturn \"\\\"\" + replaced + \"\\\"\"\n}\n\nfunc valueAsString(v any) string {\n\tswitch t := v.(type) {\n\tcase string:\n\t\treturn t\n\tcase fmt.Stringer:\n\t\treturn t.String()\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc labelFor(metadata map[string]any) string {\n\tif metadata == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(valueAsString(metadata[\"label\"])); v != \"\" {\n\t\treturn v\n\t}\n\tif v := strings.TrimSpace(valueAsString(metadata[\"email\"])); v != \"\" {\n\t\treturn v\n\t}\n\tif v := strings.TrimSpace(valueAsString(metadata[\"project_id\"])); v != \"\" {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc normalizeAuthID(id string) string {\n\treturn filepath.ToSlash(filepath.Clean(id))\n}\n\nfunc normalizeLineEndings(s string) string {\n\tif s == \"\" {\n\t\treturn s\n\t}\n\ts = strings.ReplaceAll(s, \"\\r\\n\", \"\\n\")\n\ts = strings.ReplaceAll(s, \"\\r\", \"\\n\")\n\treturn s\n}\n"
  },
  {
    "path": "internal/thinking/apply.go",
    "content": "// Package thinking provides unified thinking configuration processing.\npackage thinking\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\n// providerAppliers maps provider names to their ProviderApplier implementations.\nvar providerAppliers = map[string]ProviderApplier{\n\t\"gemini\":      nil,\n\t\"gemini-cli\":  nil,\n\t\"claude\":      nil,\n\t\"openai\":      nil,\n\t\"codex\":       nil,\n\t\"iflow\":       nil,\n\t\"antigravity\": nil,\n\t\"kimi\":        nil,\n}\n\n// GetProviderApplier returns the ProviderApplier for the given provider name.\n// Returns nil if the provider is not registered.\nfunc GetProviderApplier(provider string) ProviderApplier {\n\treturn providerAppliers[provider]\n}\n\n// RegisterProvider registers a provider applier by name.\nfunc RegisterProvider(name string, applier ProviderApplier) {\n\tproviderAppliers[name] = applier\n}\n\n// IsUserDefinedModel reports whether the model is a user-defined model that should\n// have thinking configuration passed through without validation.\n//\n// User-defined models are configured via config file's models[] array\n// (e.g., openai-compatibility.*.models[], *-api-key.models[]). These models\n// are marked with UserDefined=true at registration time.\n//\n// User-defined models should have their thinking configuration applied directly,\n// letting the upstream service validate the configuration.\nfunc IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {\n\tif modelInfo == nil {\n\t\treturn true\n\t}\n\treturn modelInfo.UserDefined\n}\n\n// ApplyThinking applies thinking configuration to a request body.\n//\n// This is the unified entry point for all providers. It follows the processing\n// order defined in FR25: route check → model capability query → config extraction\n// → validation → application.\n//\n// Suffix Priority: When the model name includes a thinking suffix (e.g., \"gemini-2.5-pro(8192)\"),\n// the suffix configuration takes priority over any thinking parameters in the request body.\n// This enables users to override thinking settings via the model name without modifying their\n// request payload.\n//\n// Parameters:\n//   - body: Original request body JSON\n//   - model: Model name, optionally with thinking suffix (e.g., \"claude-sonnet-4-5(16384)\")\n//   - fromFormat: Source request format (e.g., openai, codex, gemini)\n//   - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, iflow)\n//   - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai)\n//\n// Returns:\n//   - Modified request body JSON with thinking configuration applied\n//   - Error if validation fails (ThinkingError). On error, the original body\n//     is returned (not nil) to enable defensive programming patterns.\n//\n// Passthrough behavior (returns original body without error):\n//   - Unknown provider (not in providerAppliers map)\n//   - modelInfo.Thinking is nil (model doesn't support thinking)\n//\n// Note: Unknown models (modelInfo is nil) are treated as user-defined models: we skip\n// validation and still apply the thinking config so the upstream can validate it.\n//\n// Example:\n//\n//\t// With suffix - suffix config takes priority\n//\tresult, err := thinking.ApplyThinking(body, \"gemini-2.5-pro(8192)\", \"gemini\", \"gemini\", \"gemini\")\n//\n//\t// Without suffix - uses body config\n//\tresult, err := thinking.ApplyThinking(body, \"gemini-2.5-pro\", \"gemini\", \"gemini\", \"gemini\")\nfunc ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) {\n\tproviderFormat := strings.ToLower(strings.TrimSpace(toFormat))\n\tproviderKey = strings.ToLower(strings.TrimSpace(providerKey))\n\tif providerKey == \"\" {\n\t\tproviderKey = providerFormat\n\t}\n\tfromFormat = strings.ToLower(strings.TrimSpace(fromFormat))\n\tif fromFormat == \"\" {\n\t\tfromFormat = providerFormat\n\t}\n\t// 1. Route check: Get provider applier\n\tapplier := GetProviderApplier(providerFormat)\n\tif applier == nil {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    model,\n\t\t}).Debug(\"thinking: unknown provider, passthrough |\")\n\t\treturn body, nil\n\t}\n\n\t// 2. Parse suffix and get modelInfo\n\tsuffixResult := ParseSuffix(model)\n\tbaseModel := suffixResult.ModelName\n\t// Use provider-specific lookup to handle capability differences across providers.\n\tmodelInfo := registry.LookupModelInfo(baseModel, providerKey)\n\n\t// 3. Model capability check\n\t// Unknown models are treated as user-defined so thinking config can still be applied.\n\t// The upstream service is responsible for validating the configuration.\n\tif IsUserDefinedModel(modelInfo) {\n\t\treturn applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\tconfig := extractThinkingConfig(body, providerFormat)\n\t\tif hasThinkingConfig(config) {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"model\":    baseModel,\n\t\t\t\t\"provider\": providerFormat,\n\t\t\t}).Debug(\"thinking: model does not support thinking, stripping config |\")\n\t\t\treturn StripThinkingConfig(body, providerFormat), nil\n\t\t}\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    baseModel,\n\t\t}).Debug(\"thinking: model does not support thinking, passthrough |\")\n\t\treturn body, nil\n\t}\n\n\t// 4. Get config: suffix priority over body\n\tvar config ThinkingConfig\n\tif suffixResult.HasSuffix {\n\t\tconfig = parseSuffixToConfig(suffixResult.RawSuffix, providerFormat, model)\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    model,\n\t\t\t\"mode\":     config.Mode,\n\t\t\t\"budget\":   config.Budget,\n\t\t\t\"level\":    config.Level,\n\t\t}).Debug(\"thinking: config from model suffix |\")\n\t} else {\n\t\tconfig = extractThinkingConfig(body, providerFormat)\n\t\tif hasThinkingConfig(config) {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"provider\": providerFormat,\n\t\t\t\t\"model\":    modelInfo.ID,\n\t\t\t\t\"mode\":     config.Mode,\n\t\t\t\t\"budget\":   config.Budget,\n\t\t\t\t\"level\":    config.Level,\n\t\t\t}).Debug(\"thinking: original config from request |\")\n\t\t}\n\t}\n\n\tif !hasThinkingConfig(config) {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    modelInfo.ID,\n\t\t}).Debug(\"thinking: no config found, passthrough |\")\n\t\treturn body, nil\n\t}\n\n\t// 5. Validate and normalize configuration\n\tvalidated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat, suffixResult.HasSuffix)\n\tif err != nil {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    modelInfo.ID,\n\t\t\t\"error\":    err.Error(),\n\t\t}).Warn(\"thinking: validation failed |\")\n\t\t// Return original body on validation failure (defensive programming).\n\t\t// This ensures callers who ignore the error won't receive nil body.\n\t\t// The upstream service will decide how to handle the unmodified request.\n\t\treturn body, err\n\t}\n\n\t// Defensive check: ValidateConfig should never return (nil, nil)\n\tif validated == nil {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\": providerFormat,\n\t\t\t\"model\":    modelInfo.ID,\n\t\t}).Warn(\"thinking: ValidateConfig returned nil config without error, passthrough |\")\n\t\treturn body, nil\n\t}\n\n\tlog.WithFields(log.Fields{\n\t\t\"provider\": providerFormat,\n\t\t\"model\":    modelInfo.ID,\n\t\t\"mode\":     validated.Mode,\n\t\t\"budget\":   validated.Budget,\n\t\t\"level\":    validated.Level,\n\t}).Debug(\"thinking: processed config to apply |\")\n\n\t// 6. Apply configuration using provider-specific applier\n\treturn applier.Apply(body, *validated, modelInfo)\n}\n\n// parseSuffixToConfig converts a raw suffix string to ThinkingConfig.\n//\n// Parsing priority:\n//  1. Special values: \"none\" → ModeNone, \"auto\"/\"-1\" → ModeAuto\n//  2. Level names: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\" → ModeLevel\n//  3. Numeric values: positive integers → ModeBudget, 0 → ModeNone\n//\n// If none of the above match, returns empty ThinkingConfig (treated as no config).\nfunc parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {\n\t// 1. Try special values first (none, auto, -1)\n\tif mode, ok := ParseSpecialSuffix(rawSuffix); ok {\n\t\tswitch mode {\n\t\tcase ModeNone:\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\tcase ModeAuto:\n\t\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t\t}\n\t}\n\n\t// 2. Try level parsing (minimal, low, medium, high, xhigh)\n\tif level, ok := ParseLevelSuffix(rawSuffix); ok {\n\t\treturn ThinkingConfig{Mode: ModeLevel, Level: level}\n\t}\n\n\t// 3. Try numeric parsing\n\tif budget, ok := ParseNumericSuffix(rawSuffix); ok {\n\t\tif budget == 0 {\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\t}\n\t\treturn ThinkingConfig{Mode: ModeBudget, Budget: budget}\n\t}\n\n\t// Unknown suffix format - return empty config\n\tlog.WithFields(log.Fields{\n\t\t\"provider\":   provider,\n\t\t\"model\":      model,\n\t\t\"raw_suffix\": rawSuffix,\n\t}).Debug(\"thinking: unknown suffix format, treating as no config |\")\n\treturn ThinkingConfig{}\n}\n\n// applyUserDefinedModel applies thinking configuration for user-defined models\n// without ThinkingSupport validation.\nfunc applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult) ([]byte, error) {\n\t// Get model ID for logging\n\tmodelID := \"\"\n\tif modelInfo != nil {\n\t\tmodelID = modelInfo.ID\n\t} else {\n\t\tmodelID = suffixResult.ModelName\n\t}\n\n\t// Get config: suffix priority over body\n\tvar config ThinkingConfig\n\tif suffixResult.HasSuffix {\n\t\tconfig = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID)\n\t} else {\n\t\tconfig = extractThinkingConfig(body, fromFormat)\n\t\tif !hasThinkingConfig(config) && fromFormat != toFormat {\n\t\t\tconfig = extractThinkingConfig(body, toFormat)\n\t\t}\n\t}\n\n\tif !hasThinkingConfig(config) {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"model\":    modelID,\n\t\t\t\"provider\": toFormat,\n\t\t}).Debug(\"thinking: user-defined model, passthrough (no config) |\")\n\t\treturn body, nil\n\t}\n\n\tapplier := GetProviderApplier(toFormat)\n\tif applier == nil {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"model\":    modelID,\n\t\t\t\"provider\": toFormat,\n\t\t}).Debug(\"thinking: user-defined model, passthrough (unknown provider) |\")\n\t\treturn body, nil\n\t}\n\n\tlog.WithFields(log.Fields{\n\t\t\"provider\": toFormat,\n\t\t\"model\":    modelID,\n\t\t\"mode\":     config.Mode,\n\t\t\"budget\":   config.Budget,\n\t\t\"level\":    config.Level,\n\t}).Debug(\"thinking: applying config for user-defined model (skip validation)\")\n\n\tconfig = normalizeUserDefinedConfig(config, fromFormat, toFormat)\n\treturn applier.Apply(body, config, modelInfo)\n}\n\nfunc normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig {\n\tif config.Mode != ModeLevel {\n\t\treturn config\n\t}\n\tif toFormat == \"claude\" {\n\t\treturn config\n\t}\n\tif !isBudgetCapableProvider(toFormat) {\n\t\treturn config\n\t}\n\tbudget, ok := ConvertLevelToBudget(string(config.Level))\n\tif !ok {\n\t\treturn config\n\t}\n\tconfig.Mode = ModeBudget\n\tconfig.Budget = budget\n\tconfig.Level = \"\"\n\treturn config\n}\n\n// extractThinkingConfig extracts provider-specific thinking config from request body.\nfunc extractThinkingConfig(body []byte, provider string) ThinkingConfig {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\treturn ThinkingConfig{}\n\t}\n\n\tswitch provider {\n\tcase \"claude\":\n\t\treturn extractClaudeConfig(body)\n\tcase \"gemini\", \"gemini-cli\", \"antigravity\":\n\t\treturn extractGeminiConfig(body, provider)\n\tcase \"openai\":\n\t\treturn extractOpenAIConfig(body)\n\tcase \"codex\":\n\t\treturn extractCodexConfig(body)\n\tcase \"iflow\":\n\t\tconfig := extractIFlowConfig(body)\n\t\tif hasThinkingConfig(config) {\n\t\t\treturn config\n\t\t}\n\t\treturn extractOpenAIConfig(body)\n\tcase \"kimi\":\n\t\t// Kimi uses OpenAI-compatible reasoning_effort format\n\t\treturn extractOpenAIConfig(body)\n\tdefault:\n\t\treturn ThinkingConfig{}\n\t}\n}\n\nfunc hasThinkingConfig(config ThinkingConfig) bool {\n\treturn config.Mode != ModeBudget || config.Budget != 0 || config.Level != \"\"\n}\n\n// extractClaudeConfig extracts thinking configuration from Claude format request body.\n//\n// Claude API format:\n//   - thinking.type: \"enabled\" or \"disabled\"\n//   - thinking.budget_tokens: integer (-1=auto, 0=disabled, >0=budget)\n//\n// Priority: thinking.type=\"disabled\" takes precedence over budget_tokens.\n// When type=\"enabled\" without budget_tokens, returns ModeAuto to indicate\n// the user wants thinking enabled but didn't specify a budget.\nfunc extractClaudeConfig(body []byte) ThinkingConfig {\n\tthinkingType := gjson.GetBytes(body, \"thinking.type\").String()\n\tif thinkingType == \"disabled\" {\n\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t}\n\tif thinkingType == \"adaptive\" || thinkingType == \"auto\" {\n\t\t// Claude adaptive thinking uses output_config.effort (low/medium/high/max).\n\t\t// We only treat it as a thinking config when effort is explicitly present;\n\t\t// otherwise we passthrough and let upstream defaults apply.\n\t\tif effort := gjson.GetBytes(body, \"output_config.effort\"); effort.Exists() && effort.Type == gjson.String {\n\t\t\tvalue := strings.ToLower(strings.TrimSpace(effort.String()))\n\t\t\tif value == \"\" {\n\t\t\t\treturn ThinkingConfig{}\n\t\t\t}\n\t\t\tswitch value {\n\t\t\tcase \"none\":\n\t\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\t\tcase \"auto\":\n\t\t\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t\t\tdefault:\n\t\t\t\treturn ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}\n\t\t\t}\n\t\t}\n\t\treturn ThinkingConfig{}\n\t}\n\n\t// Check budget_tokens\n\tif budget := gjson.GetBytes(body, \"thinking.budget_tokens\"); budget.Exists() {\n\t\tvalue := int(budget.Int())\n\t\tswitch value {\n\t\tcase 0:\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\tcase -1:\n\t\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t\tdefault:\n\t\t\treturn ThinkingConfig{Mode: ModeBudget, Budget: value}\n\t\t}\n\t}\n\n\t// If type=\"enabled\" but no budget_tokens, treat as auto (user wants thinking but no budget specified)\n\tif thinkingType == \"enabled\" {\n\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t}\n\n\treturn ThinkingConfig{}\n}\n\n// extractGeminiConfig extracts thinking configuration from Gemini format request body.\n//\n// Gemini API format:\n//   - generationConfig.thinkingConfig.thinkingLevel: \"none\", \"auto\", or level name (Gemini 3)\n//   - generationConfig.thinkingConfig.thinkingBudget: integer (Gemini 2.5)\n//\n// For gemini-cli and antigravity providers, the path is prefixed with \"request.\".\n//\n// Priority: thinkingLevel is checked first (Gemini 3 format), then thinkingBudget (Gemini 2.5 format).\n// This allows newer Gemini 3 level-based configs to take precedence.\nfunc extractGeminiConfig(body []byte, provider string) ThinkingConfig {\n\tprefix := \"generationConfig.thinkingConfig\"\n\tif provider == \"gemini-cli\" || provider == \"antigravity\" {\n\t\tprefix = \"request.generationConfig.thinkingConfig\"\n\t}\n\n\t// Check thinkingLevel first (Gemini 3 format takes precedence)\n\tlevel := gjson.GetBytes(body, prefix+\".thinkingLevel\")\n\tif !level.Exists() {\n\t\t// Google official Gemini Python SDK sends snake_case field names\n\t\tlevel = gjson.GetBytes(body, prefix+\".thinking_level\")\n\t}\n\tif level.Exists() {\n\t\tvalue := level.String()\n\t\tswitch value {\n\t\tcase \"none\":\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\tcase \"auto\":\n\t\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t\tdefault:\n\t\t\treturn ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}\n\t\t}\n\t}\n\n\t// Check thinkingBudget (Gemini 2.5 format)\n\tbudget := gjson.GetBytes(body, prefix+\".thinkingBudget\")\n\tif !budget.Exists() {\n\t\t// Google official Gemini Python SDK sends snake_case field names\n\t\tbudget = gjson.GetBytes(body, prefix+\".thinking_budget\")\n\t}\n\tif budget.Exists() {\n\t\tvalue := int(budget.Int())\n\t\tswitch value {\n\t\tcase 0:\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\tcase -1:\n\t\t\treturn ThinkingConfig{Mode: ModeAuto, Budget: -1}\n\t\tdefault:\n\t\t\treturn ThinkingConfig{Mode: ModeBudget, Budget: value}\n\t\t}\n\t}\n\n\treturn ThinkingConfig{}\n}\n\n// extractOpenAIConfig extracts thinking configuration from OpenAI format request body.\n//\n// OpenAI API format:\n//   - reasoning_effort: \"none\", \"low\", \"medium\", \"high\" (discrete levels)\n//\n// OpenAI uses level-based thinking configuration only, no numeric budget support.\n// The \"none\" value is treated specially to return ModeNone.\nfunc extractOpenAIConfig(body []byte) ThinkingConfig {\n\t// Check reasoning_effort (OpenAI Chat Completions format)\n\tif effort := gjson.GetBytes(body, \"reasoning_effort\"); effort.Exists() {\n\t\tvalue := effort.String()\n\t\tif value == \"none\" {\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\t}\n\t\treturn ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}\n\t}\n\n\treturn ThinkingConfig{}\n}\n\n// extractCodexConfig extracts thinking configuration from Codex format request body.\n//\n// Codex API format (OpenAI Responses API):\n//   - reasoning.effort: \"none\", \"low\", \"medium\", \"high\"\n//\n// This is similar to OpenAI but uses nested field \"reasoning.effort\" instead of \"reasoning_effort\".\nfunc extractCodexConfig(body []byte) ThinkingConfig {\n\t// Check reasoning.effort (Codex / OpenAI Responses API format)\n\tif effort := gjson.GetBytes(body, \"reasoning.effort\"); effort.Exists() {\n\t\tvalue := effort.String()\n\t\tif value == \"none\" {\n\t\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t\t}\n\t\treturn ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}\n\t}\n\n\treturn ThinkingConfig{}\n}\n\n// extractIFlowConfig extracts thinking configuration from iFlow format request body.\n//\n// iFlow API format (supports multiple model families):\n//   - GLM format: chat_template_kwargs.enable_thinking (boolean)\n//   - MiniMax format: reasoning_split (boolean)\n//\n// Returns ModeBudget with Budget=1 as a sentinel value indicating \"enabled\".\n// The actual budget/configuration is determined by the iFlow applier based on model capabilities.\n// Budget=1 is used because iFlow models don't use numeric budgets; they only support on/off.\nfunc extractIFlowConfig(body []byte) ThinkingConfig {\n\t// GLM format: chat_template_kwargs.enable_thinking\n\tif enabled := gjson.GetBytes(body, \"chat_template_kwargs.enable_thinking\"); enabled.Exists() {\n\t\tif enabled.Bool() {\n\t\t\t// Budget=1 is a sentinel meaning \"enabled\" (iFlow doesn't use numeric budgets)\n\t\t\treturn ThinkingConfig{Mode: ModeBudget, Budget: 1}\n\t\t}\n\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t}\n\n\t// MiniMax format: reasoning_split\n\tif split := gjson.GetBytes(body, \"reasoning_split\"); split.Exists() {\n\t\tif split.Bool() {\n\t\t\t// Budget=1 is a sentinel meaning \"enabled\" (iFlow doesn't use numeric budgets)\n\t\t\treturn ThinkingConfig{Mode: ModeBudget, Budget: 1}\n\t\t}\n\t\treturn ThinkingConfig{Mode: ModeNone, Budget: 0}\n\t}\n\n\treturn ThinkingConfig{}\n}\n"
  },
  {
    "path": "internal/thinking/apply_user_defined_test.go",
    "content": "package thinking_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestApplyThinking_UserDefinedClaudePreservesAdaptiveLevel(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\tclientID := \"test-user-defined-claude-\" + t.Name()\n\tmodelID := \"custom-claude-4-6\"\n\treg.RegisterClient(clientID, \"claude\", []*registry.ModelInfo{{ID: modelID, UserDefined: true}})\n\tt.Cleanup(func() {\n\t\treg.UnregisterClient(clientID)\n\t})\n\n\ttests := []struct {\n\t\tname  string\n\t\tmodel string\n\t\tbody  []byte\n\t}{\n\t\t{\n\t\t\tname:  \"claude adaptive effort body\",\n\t\t\tmodel: modelID,\n\t\t\tbody:  []byte(`{\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`),\n\t\t},\n\t\t{\n\t\t\tname:  \"suffix level\",\n\t\t\tmodel: modelID + \"(high)\",\n\t\t\tbody:  []byte(`{}`),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tout, err := thinking.ApplyThinking(tt.body, tt.model, \"openai\", \"claude\", \"claude\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"ApplyThinking() error = %v\", err)\n\t\t\t}\n\t\t\tif got := gjson.GetBytes(out, \"thinking.type\").String(); got != \"adaptive\" {\n\t\t\t\tt.Fatalf(\"thinking.type = %q, want %q, body=%s\", got, \"adaptive\", string(out))\n\t\t\t}\n\t\t\tif got := gjson.GetBytes(out, \"output_config.effort\").String(); got != \"high\" {\n\t\t\t\tt.Fatalf(\"output_config.effort = %q, want %q, body=%s\", got, \"high\", string(out))\n\t\t\t}\n\t\t\tif gjson.GetBytes(out, \"thinking.budget_tokens\").Exists() {\n\t\t\t\tt.Fatalf(\"thinking.budget_tokens should be removed, body=%s\", string(out))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/thinking/convert.go",
    "content": "package thinking\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n)\n\n// levelToBudgetMap defines the standard Level → Budget mapping.\n// All keys are lowercase; lookups should use strings.ToLower.\nvar levelToBudgetMap = map[string]int{\n\t\"none\":    0,\n\t\"auto\":    -1,\n\t\"minimal\": 512,\n\t\"low\":     1024,\n\t\"medium\":  8192,\n\t\"high\":    24576,\n\t\"xhigh\":   32768,\n\t// \"max\" is used by Claude adaptive thinking effort. We map it to a large budget\n\t// and rely on per-model clamping when converting to budget-only providers.\n\t\"max\": 128000,\n}\n\n// ConvertLevelToBudget converts a thinking level to a budget value.\n//\n// This is a semantic conversion that maps discrete levels to numeric budgets.\n// Level matching is case-insensitive.\n//\n// Level → Budget mapping:\n//   - none    → 0\n//   - auto    → -1\n//   - minimal → 512\n//   - low     → 1024\n//   - medium  → 8192\n//   - high    → 24576\n//   - xhigh   → 32768\n//   - max     → 128000\n//\n// Returns:\n//   - budget: The converted budget value\n//   - ok: true if level is valid, false otherwise\nfunc ConvertLevelToBudget(level string) (int, bool) {\n\tbudget, ok := levelToBudgetMap[strings.ToLower(level)]\n\treturn budget, ok\n}\n\n// BudgetThreshold constants define the upper bounds for each thinking level.\n// These are used by ConvertBudgetToLevel for range-based mapping.\nconst (\n\t// ThresholdMinimal is the upper bound for \"minimal\" level (1-512)\n\tThresholdMinimal = 512\n\t// ThresholdLow is the upper bound for \"low\" level (513-1024)\n\tThresholdLow = 1024\n\t// ThresholdMedium is the upper bound for \"medium\" level (1025-8192)\n\tThresholdMedium = 8192\n\t// ThresholdHigh is the upper bound for \"high\" level (8193-24576)\n\tThresholdHigh = 24576\n)\n\n// ConvertBudgetToLevel converts a budget value to the nearest thinking level.\n//\n// This is a semantic conversion that maps numeric budgets to discrete levels.\n// Uses threshold-based mapping for range conversion.\n//\n// Budget → Level thresholds:\n//   - -1        → auto\n//   - 0         → none\n//   - 1-512     → minimal\n//   - 513-1024  → low\n//   - 1025-8192 → medium\n//   - 8193-24576 → high\n//   - 24577+    → xhigh\n//\n// Returns:\n//   - level: The converted thinking level string\n//   - ok: true if budget is valid, false for invalid negatives (< -1)\nfunc ConvertBudgetToLevel(budget int) (string, bool) {\n\tswitch {\n\tcase budget < -1:\n\t\t// Invalid negative values\n\t\treturn \"\", false\n\tcase budget == -1:\n\t\treturn string(LevelAuto), true\n\tcase budget == 0:\n\t\treturn string(LevelNone), true\n\tcase budget <= ThresholdMinimal:\n\t\treturn string(LevelMinimal), true\n\tcase budget <= ThresholdLow:\n\t\treturn string(LevelLow), true\n\tcase budget <= ThresholdMedium:\n\t\treturn string(LevelMedium), true\n\tcase budget <= ThresholdHigh:\n\t\treturn string(LevelHigh), true\n\tdefault:\n\t\treturn string(LevelXHigh), true\n\t}\n}\n\n// HasLevel reports whether the given target level exists in the levels slice.\n// Matching is case-insensitive with leading/trailing whitespace trimmed.\nfunc HasLevel(levels []string, target string) bool {\n\tfor _, level := range levels {\n\t\tif strings.EqualFold(strings.TrimSpace(level), target) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive\n// thinking effort value (low/medium/high/max).\n//\n// supportsMax indicates whether the target model supports \"max\" effort.\n// Returns the mapped effort and true if the level is valid, or (\"\", false) otherwise.\nfunc MapToClaudeEffort(level string, supportsMax bool) (string, bool) {\n\tlevel = strings.ToLower(strings.TrimSpace(level))\n\tswitch level {\n\tcase \"\":\n\t\treturn \"\", false\n\tcase \"minimal\":\n\t\treturn \"low\", true\n\tcase \"low\", \"medium\", \"high\":\n\t\treturn level, true\n\tcase \"xhigh\", \"max\":\n\t\tif supportsMax {\n\t\t\treturn \"max\", true\n\t\t}\n\t\treturn \"high\", true\n\tcase \"auto\":\n\t\treturn \"high\", true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\n// ModelCapability describes the thinking format support of a model.\ntype ModelCapability int\n\nconst (\n\t// CapabilityUnknown indicates modelInfo is nil (passthrough behavior, internal use).\n\tCapabilityUnknown ModelCapability = iota - 1\n\t// CapabilityNone indicates model doesn't support thinking (Thinking is nil).\n\tCapabilityNone\n\t// CapabilityBudgetOnly indicates the model supports numeric budgets only.\n\tCapabilityBudgetOnly\n\t// CapabilityLevelOnly indicates the model supports discrete levels only.\n\tCapabilityLevelOnly\n\t// CapabilityHybrid indicates the model supports both budgets and levels.\n\tCapabilityHybrid\n)\n\n// detectModelCapability determines the thinking format capability of a model.\n//\n// This is an internal function used by validation and conversion helpers.\n// It analyzes the model's ThinkingSupport configuration to classify the model:\n//   - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking)\n//   - CapabilityBudgetOnly: Has Min/Max but no Levels (Claude, Gemini 2.5)\n//   - CapabilityLevelOnly: Has Levels but no Min/Max (OpenAI, iFlow)\n//   - CapabilityHybrid: Has both Min/Max and Levels (Gemini 3)\n//\n// Note: Returns a special sentinel value when modelInfo itself is nil (unknown model).\nfunc detectModelCapability(modelInfo *registry.ModelInfo) ModelCapability {\n\tif modelInfo == nil {\n\t\treturn CapabilityUnknown // sentinel for \"passthrough\" behavior\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn CapabilityNone\n\t}\n\tsupport := modelInfo.Thinking\n\thasBudget := support.Min > 0 || support.Max > 0\n\thasLevels := len(support.Levels) > 0\n\n\tswitch {\n\tcase hasBudget && hasLevels:\n\t\treturn CapabilityHybrid\n\tcase hasBudget:\n\t\treturn CapabilityBudgetOnly\n\tcase hasLevels:\n\t\treturn CapabilityLevelOnly\n\tdefault:\n\t\treturn CapabilityNone\n\t}\n}\n"
  },
  {
    "path": "internal/thinking/errors.go",
    "content": "// Package thinking provides unified thinking configuration processing logic.\npackage thinking\n\nimport \"net/http\"\n\n// ErrorCode represents the type of thinking configuration error.\ntype ErrorCode string\n\n// Error codes for thinking configuration processing.\nconst (\n\t// ErrInvalidSuffix indicates the suffix format cannot be parsed.\n\t// Example: \"model(abc\" (missing closing parenthesis)\n\tErrInvalidSuffix ErrorCode = \"INVALID_SUFFIX\"\n\n\t// ErrUnknownLevel indicates the level value is not in the valid list.\n\t// Example: \"model(ultra)\" where \"ultra\" is not a valid level\n\tErrUnknownLevel ErrorCode = \"UNKNOWN_LEVEL\"\n\n\t// ErrThinkingNotSupported indicates the model does not support thinking.\n\t// Example: claude-haiku-4-5 does not have thinking capability\n\tErrThinkingNotSupported ErrorCode = \"THINKING_NOT_SUPPORTED\"\n\n\t// ErrLevelNotSupported indicates the model does not support level mode.\n\t// Example: using level with a budget-only model\n\tErrLevelNotSupported ErrorCode = \"LEVEL_NOT_SUPPORTED\"\n\n\t// ErrBudgetOutOfRange indicates the budget value is outside model range.\n\t// Example: budget 64000 exceeds max 20000\n\tErrBudgetOutOfRange ErrorCode = \"BUDGET_OUT_OF_RANGE\"\n\n\t// ErrProviderMismatch indicates the provider does not match the model.\n\t// Example: applying Claude format to a Gemini model\n\tErrProviderMismatch ErrorCode = \"PROVIDER_MISMATCH\"\n)\n\n// ThinkingError represents an error that occurred during thinking configuration processing.\n//\n// This error type provides structured information about the error, including:\n//   - Code: A machine-readable error code for programmatic handling\n//   - Message: A human-readable description of the error\n//   - Model: The model name related to the error (optional)\n//   - Details: Additional context information (optional)\ntype ThinkingError struct {\n\t// Code is the machine-readable error code\n\tCode ErrorCode\n\t// Message is the human-readable error description.\n\t// Should be lowercase, no trailing period, with context if applicable.\n\tMessage string\n\t// Model is the model name related to this error (optional)\n\tModel string\n\t// Details contains additional context information (optional)\n\tDetails map[string]interface{}\n}\n\n// Error implements the error interface.\n// Returns the message directly without code prefix.\n// Use Code field for programmatic error handling.\nfunc (e *ThinkingError) Error() string {\n\treturn e.Message\n}\n\n// NewThinkingError creates a new ThinkingError with the given code and message.\nfunc NewThinkingError(code ErrorCode, message string) *ThinkingError {\n\treturn &ThinkingError{\n\t\tCode:    code,\n\t\tMessage: message,\n\t}\n}\n\n// NewThinkingErrorWithModel creates a new ThinkingError with model context.\nfunc NewThinkingErrorWithModel(code ErrorCode, message, model string) *ThinkingError {\n\treturn &ThinkingError{\n\t\tCode:    code,\n\t\tMessage: message,\n\t\tModel:   model,\n\t}\n}\n\n// StatusCode implements a portable status code interface for HTTP handlers.\nfunc (e *ThinkingError) StatusCode() int {\n\treturn http.StatusBadRequest\n}\n"
  },
  {
    "path": "internal/thinking/provider/antigravity/apply.go",
    "content": "// Package antigravity implements thinking configuration for Antigravity API format.\n//\n// Antigravity uses request.generationConfig.thinkingConfig.* path (same as gemini-cli)\n// but requires additional normalization for Claude models:\n//   - Ensure thinking budget < max_tokens\n//   - Remove thinkingConfig if budget < minimum allowed\npackage antigravity\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier applies thinking configuration for Antigravity API format.\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new Antigravity thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"antigravity\", NewApplier())\n}\n\n// Apply applies thinking configuration to Antigravity request body.\n//\n// For Claude models, additional constraints are applied:\n//   - Ensure thinking budget < max_tokens\n//   - Remove thinkingConfig if budget < minimum allowed\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn a.applyCompatible(body, config, modelInfo)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tisClaude := strings.Contains(strings.ToLower(modelInfo.ID), \"claude\")\n\n\t// ModeAuto: Always use Budget format with thinkingBudget=-1\n\tif config.Mode == thinking.ModeAuto {\n\t\treturn a.applyBudgetFormat(body, config, modelInfo, isClaude)\n\t}\n\tif config.Mode == thinking.ModeBudget {\n\t\treturn a.applyBudgetFormat(body, config, modelInfo, isClaude)\n\t}\n\n\t// For non-auto modes, choose format based on model capabilities\n\tsupport := modelInfo.Thinking\n\tif len(support.Levels) > 0 {\n\t\treturn a.applyLevelFormat(body, config)\n\t}\n\treturn a.applyBudgetFormat(body, config, modelInfo, isClaude)\n}\n\nfunc (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tisClaude := false\n\tif modelInfo != nil {\n\t\tisClaude = strings.Contains(strings.ToLower(modelInfo.ID), \"claude\")\n\t}\n\n\tif config.Mode == thinking.ModeAuto {\n\t\treturn a.applyBudgetFormat(body, config, modelInfo, isClaude)\n\t}\n\n\tif config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != \"\") {\n\t\treturn a.applyLevelFormat(body, config)\n\t}\n\n\treturn a.applyBudgetFormat(body, config, modelInfo, isClaude)\n}\n\nfunc (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"request.generationConfig.thinkingConfig.thinkingBudget\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_budget\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_level\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.include_thoughts\")\n\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", false)\n\t\tif config.Level != \"\" {\n\t\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingLevel\", string(config.Level))\n\t\t}\n\t\treturn result, nil\n\t}\n\n\t// Only handle ModeLevel - budget conversion should be done by upper layer\n\tif config.Mode != thinking.ModeLevel {\n\t\treturn body, nil\n\t}\n\n\tlevel := string(config.Level)\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingLevel\", level)\n\n\t// Respect user's explicit includeThoughts setting from original body; default to true if not set\n\t// Support both camelCase and snake_case variants\n\tincludeThoughts := true\n\tif inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t} else if inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t}\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n\nfunc (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"request.generationConfig.thinkingConfig.thinkingLevel\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_level\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_budget\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.include_thoughts\")\n\n\tbudget := config.Budget\n\n\t// Apply Claude-specific constraints first to get the final budget value\n\tif isClaude && modelInfo != nil {\n\t\tbudget, result = a.normalizeClaudeBudget(budget, result, modelInfo)\n\t\t// Check if budget was removed entirely\n\t\tif budget == -2 {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\n\t// For ModeNone, always set includeThoughts to false regardless of user setting.\n\t// This ensures that when user requests budget=0 (disable thinking output),\n\t// the includeThoughts is correctly set to false even if budget is clamped to min.\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", false)\n\t\treturn result, nil\n\t}\n\n\t// Determine includeThoughts: respect user's explicit setting from original body if provided\n\t// Support both camelCase and snake_case variants\n\tvar includeThoughts bool\n\tvar userSetIncludeThoughts bool\n\tif inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t} else if inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t}\n\n\tif !userSetIncludeThoughts {\n\t\t// No explicit setting, use default logic based on mode\n\t\tswitch config.Mode {\n\t\tcase thinking.ModeAuto:\n\t\t\tincludeThoughts = true\n\t\tdefault:\n\t\t\tincludeThoughts = budget > 0\n\t\t}\n\t}\n\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n\n// normalizeClaudeBudget applies Claude-specific constraints to thinking budget.\n//\n// It handles:\n//   - Ensuring thinking budget < max_tokens\n//   - Removing thinkingConfig if budget < minimum allowed\n//\n// Returns the normalized budget and updated payload.\n// Returns budget=-2 as a sentinel indicating thinkingConfig was removed entirely.\nfunc (a *Applier) normalizeClaudeBudget(budget int, payload []byte, modelInfo *registry.ModelInfo) (int, []byte) {\n\tif modelInfo == nil {\n\t\treturn budget, payload\n\t}\n\n\t// Get effective max tokens\n\teffectiveMax, setDefaultMax := a.effectiveMaxTokens(payload, modelInfo)\n\tif effectiveMax > 0 && budget >= effectiveMax {\n\t\tbudget = effectiveMax - 1\n\t}\n\n\t// Check minimum budget\n\tminBudget := 0\n\tif modelInfo.Thinking != nil {\n\t\tminBudget = modelInfo.Thinking.Min\n\t}\n\tif minBudget > 0 && budget >= 0 && budget < minBudget {\n\t\t// Budget is below minimum, remove thinking config entirely\n\t\tpayload, _ = sjson.DeleteBytes(payload, \"request.generationConfig.thinkingConfig\")\n\t\treturn -2, payload\n\t}\n\n\t// Set default max tokens if needed\n\tif setDefaultMax && effectiveMax > 0 {\n\t\tpayload, _ = sjson.SetBytes(payload, \"request.generationConfig.maxOutputTokens\", effectiveMax)\n\t}\n\n\treturn budget, payload\n}\n\n// effectiveMaxTokens returns the max tokens to cap thinking:\n// prefer request-provided maxOutputTokens; otherwise fall back to model default.\n// The boolean indicates whether the value came from the model default (and thus should be written back).\nfunc (a *Applier) effectiveMaxTokens(payload []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {\n\tif maxTok := gjson.GetBytes(payload, \"request.generationConfig.maxOutputTokens\"); maxTok.Exists() && maxTok.Int() > 0 {\n\t\treturn int(maxTok.Int()), false\n\t}\n\tif modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {\n\t\treturn modelInfo.MaxCompletionTokens, true\n\t}\n\treturn 0, false\n}\n"
  },
  {
    "path": "internal/thinking/provider/claude/apply.go",
    "content": "// Package claude implements thinking configuration scaffolding for Claude models.\n//\n// Claude models support two thinking control styles:\n//   - Manual thinking: thinking.type=\"enabled\" with thinking.budget_tokens (token budget)\n//   - Adaptive thinking (Claude 4.6): thinking.type=\"adaptive\" with output_config.effort (low/medium/high/max)\n//\n// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not.\n// See: _bmad-output/planning-artifacts/architecture.md#Epic-6\npackage claude\n\nimport (\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier implements thinking.ProviderApplier for Claude models.\n// This applier is stateless and holds no configuration.\ntype Applier struct{}\n\n// NewApplier creates a new Claude thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"claude\", NewApplier())\n}\n\n// Apply applies thinking configuration to Claude request body.\n//\n// IMPORTANT: This method expects config to be pre-validated by thinking.ValidateConfig.\n// ValidateConfig handles:\n//   - Mode conversion (Level→Budget, Auto→Budget)\n//   - Budget clamping to model range\n//   - ZeroAllowed constraint enforcement\n//\n// Apply processes:\n//   - ModeBudget: manual thinking budget_tokens\n//   - ModeLevel: adaptive thinking effort (Claude 4.6)\n//   - ModeAuto: provider default adaptive/manual behavior\n//   - ModeNone: disabled\n//\n// Expected output format when enabled:\n//\n//\t{\n//\t  \"thinking\": {\n//\t    \"type\": \"enabled\",\n//\t    \"budget_tokens\": 16384\n//\t  }\n//\t}\n//\n// Expected output format for adaptive:\n//\n//\t{\n//\t  \"thinking\": {\n//\t    \"type\": \"adaptive\"\n//\t  },\n//\t  \"output_config\": {\n//\t    \"effort\": \"high\"\n//\t  }\n//\t}\n//\n// Expected output format when disabled:\n//\n//\t{\n//\t  \"thinking\": {\n//\t    \"type\": \"disabled\"\n//\t  }\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn applyCompatibleClaude(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tsupportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0\n\n\tswitch config.Mode {\n\tcase thinking.ModeNone:\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"disabled\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t\treturn result, nil\n\n\tcase thinking.ModeLevel:\n\t\t// Adaptive thinking effort is only valid when the model advertises discrete levels.\n\t\t// (Claude 4.6 uses output_config.effort.)\n\t\tif supportsAdaptive && config.Level != \"\" {\n\t\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"adaptive\")\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\t\tresult, _ = sjson.SetBytes(result, \"output_config.effort\", string(config.Level))\n\t\t\treturn result, nil\n\t\t}\n\n\t\t// Fallback for non-adaptive Claude models: convert level to budget_tokens.\n\t\tif budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok {\n\t\t\tconfig.Mode = thinking.ModeBudget\n\t\t\tconfig.Budget = budget\n\t\t\tconfig.Level = \"\"\n\t\t} else {\n\t\t\treturn body, nil\n\t\t}\n\t\tfallthrough\n\n\tcase thinking.ModeBudget:\n\t\t// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).\n\t\t// Decide enabled/disabled based on budget value.\n\t\tif config.Budget == 0 {\n\t\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"disabled\")\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t\t}\n\t\t\treturn result, nil\n\t\t}\n\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"enabled\")\n\t\tresult, _ = sjson.SetBytes(result, \"thinking.budget_tokens\", config.Budget)\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\n\t\t// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint).\n\t\tresult = a.normalizeClaudeBudget(result, config.Budget, modelInfo)\n\t\treturn result, nil\n\n\tcase thinking.ModeAuto:\n\t\t// For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults.\n\t\tif supportsAdaptive {\n\t\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"adaptive\")\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\t\t// Explicit effort is optional for adaptive thinking; omit it to allow upstream default.\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t\t}\n\t\t\treturn result, nil\n\t\t}\n\n\t\t// Legacy fallback: enable thinking without specifying budget_tokens.\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"enabled\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t\treturn result, nil\n\n\tdefault:\n\t\treturn body, nil\n\t}\n}\n\n// normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens.\n// Anthropic API requires this constraint; violating it returns a 400 error.\nfunc (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo *registry.ModelInfo) []byte {\n\tif budgetTokens <= 0 {\n\t\treturn body\n\t}\n\n\t// Ensure the request satisfies Claude constraints:\n\t//  1) Determine effective max_tokens (request overrides model default)\n\t//  2) If budget_tokens >= max_tokens, reduce budget_tokens to max_tokens-1\n\t//  3) If the adjusted budget falls below the model minimum, leave the request unchanged\n\t//  4) If max_tokens came from model default, write it back into the request\n\n\teffectiveMax, setDefaultMax := a.effectiveMaxTokens(body, modelInfo)\n\tif setDefaultMax && effectiveMax > 0 {\n\t\tbody, _ = sjson.SetBytes(body, \"max_tokens\", effectiveMax)\n\t}\n\n\t// Compute the budget we would apply after enforcing budget_tokens < max_tokens.\n\tadjustedBudget := budgetTokens\n\tif effectiveMax > 0 && adjustedBudget >= effectiveMax {\n\t\tadjustedBudget = effectiveMax - 1\n\t}\n\n\tminBudget := 0\n\tif modelInfo != nil && modelInfo.Thinking != nil {\n\t\tminBudget = modelInfo.Thinking.Min\n\t}\n\tif minBudget > 0 && adjustedBudget > 0 && adjustedBudget < minBudget {\n\t\t// If enforcing the max_tokens constraint would push the budget below the model minimum,\n\t\t// leave the request unchanged.\n\t\treturn body\n\t}\n\n\tif adjustedBudget != budgetTokens {\n\t\tbody, _ = sjson.SetBytes(body, \"thinking.budget_tokens\", adjustedBudget)\n\t}\n\n\treturn body\n}\n\n// effectiveMaxTokens returns the max tokens to cap thinking:\n// prefer request-provided max_tokens; otherwise fall back to model default.\n// The boolean indicates whether the value came from the model default (and thus should be written back).\nfunc (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {\n\tif maxTok := gjson.GetBytes(body, \"max_tokens\"); maxTok.Exists() && maxTok.Int() > 0 {\n\t\treturn int(maxTok.Int()), false\n\t}\n\tif modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {\n\t\treturn modelInfo.MaxCompletionTokens, true\n\t}\n\treturn 0, false\n}\n\nfunc applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tswitch config.Mode {\n\tcase thinking.ModeNone:\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"disabled\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t\treturn result, nil\n\tcase thinking.ModeAuto:\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"enabled\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t\treturn result, nil\n\tcase thinking.ModeLevel:\n\t\t// For user-defined models, interpret ModeLevel as Claude adaptive thinking effort.\n\t\t// Upstream is responsible for validating whether the target model supports it.\n\t\tif config.Level == \"\" {\n\t\t\treturn body, nil\n\t\t}\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"adaptive\")\n\t\tresult, _ = sjson.DeleteBytes(result, \"thinking.budget_tokens\")\n\t\tresult, _ = sjson.SetBytes(result, \"output_config.effort\", string(config.Level))\n\t\treturn result, nil\n\tdefault:\n\t\tresult, _ := sjson.SetBytes(body, \"thinking.type\", \"enabled\")\n\t\tresult, _ = sjson.SetBytes(result, \"thinking.budget_tokens\", config.Budget)\n\t\tresult, _ = sjson.DeleteBytes(result, \"output_config.effort\")\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t\treturn result, nil\n\t}\n}\n"
  },
  {
    "path": "internal/thinking/provider/codex/apply.go",
    "content": "// Package codex implements thinking configuration for Codex (OpenAI Responses API) models.\n//\n// Codex models use the reasoning.effort format with discrete levels\n// (low/medium/high). This is similar to OpenAI but uses nested field\n// \"reasoning.effort\" instead of \"reasoning_effort\".\n// See: _bmad-output/planning-artifacts/architecture.md#Epic-8\npackage codex\n\nimport (\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier implements thinking.ProviderApplier for Codex models.\n//\n// Codex-specific behavior:\n//   - Output format: reasoning.effort (string: low/medium/high/xhigh)\n//   - Level-only mode: no numeric budget support\n//   - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new Codex thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"codex\", NewApplier())\n}\n\n// Apply applies thinking configuration to Codex request body.\n//\n// Expected output format:\n//\n//\t{\n//\t  \"reasoning\": {\n//\t    \"effort\": \"high\"\n//\t  }\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn applyCompatibleCodex(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\t// Only handle ModeLevel and ModeNone; other modes pass through unchanged.\n\tif config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tif config.Mode == thinking.ModeLevel {\n\t\tresult, _ := sjson.SetBytes(body, \"reasoning.effort\", string(config.Level))\n\t\treturn result, nil\n\t}\n\n\teffort := \"\"\n\tsupport := modelInfo.Thinking\n\tif config.Budget == 0 {\n\t\tif support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {\n\t\t\teffort = string(thinking.LevelNone)\n\t\t}\n\t}\n\tif effort == \"\" && config.Level != \"\" {\n\t\teffort = string(config.Level)\n\t}\n\tif effort == \"\" && len(support.Levels) > 0 {\n\t\teffort = support.Levels[0]\n\t}\n\tif effort == \"\" {\n\t\treturn body, nil\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"reasoning.effort\", effort)\n\treturn result, nil\n}\n\nfunc applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tvar effort string\n\tswitch config.Mode {\n\tcase thinking.ModeLevel:\n\t\tif config.Level == \"\" {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = string(config.Level)\n\tcase thinking.ModeNone:\n\t\teffort = string(thinking.LevelNone)\n\t\tif config.Level != \"\" {\n\t\t\teffort = string(config.Level)\n\t\t}\n\tcase thinking.ModeAuto:\n\t\t// Auto mode for user-defined models: pass through as \"auto\"\n\t\teffort = string(thinking.LevelAuto)\n\tcase thinking.ModeBudget:\n\t\t// Budget mode: convert budget to level using threshold mapping\n\t\tlevel, ok := thinking.ConvertBudgetToLevel(config.Budget)\n\t\tif !ok {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = level\n\tdefault:\n\t\treturn body, nil\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"reasoning.effort\", effort)\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/thinking/provider/gemini/apply.go",
    "content": "// Package gemini implements thinking configuration for Gemini models.\n//\n// Gemini models have two formats:\n//   - Gemini 2.5: Uses thinkingBudget (numeric)\n//   - Gemini 3.x: Uses thinkingLevel (string: minimal/low/medium/high)\n//     or thinkingBudget=-1 for auto/dynamic mode\n//\n// Output format is determined by ThinkingConfig.Mode and ThinkingSupport.Levels:\n//   - ModeAuto: Always uses thinkingBudget=-1 (both Gemini 2.5 and 3.x)\n//   - len(Levels) > 0: Uses thinkingLevel (Gemini 3.x discrete levels)\n//   - len(Levels) == 0: Uses thinkingBudget (Gemini 2.5)\npackage gemini\n\nimport (\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier applies thinking configuration for Gemini models.\n//\n// Gemini-specific behavior:\n//   - Gemini 2.5: thinkingBudget format, flash series supports ZeroAllowed\n//   - Gemini 3.x: thinkingLevel format, cannot be disabled\n//   - Use ThinkingSupport.Levels to decide output format\ntype Applier struct{}\n\n// NewApplier creates a new Gemini thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"gemini\", NewApplier())\n}\n\n// Apply applies thinking configuration to Gemini request body.\n//\n// Expected output format (Gemini 2.5):\n//\n//\t{\n//\t  \"generationConfig\": {\n//\t    \"thinkingConfig\": {\n//\t      \"thinkingBudget\": 8192,\n//\t      \"includeThoughts\": true\n//\t    }\n//\t  }\n//\t}\n//\n// Expected output format (Gemini 3.x):\n//\n//\t{\n//\t  \"generationConfig\": {\n//\t    \"thinkingConfig\": {\n//\t      \"thinkingLevel\": \"high\",\n//\t      \"includeThoughts\": true\n//\t    }\n//\t  }\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn a.applyCompatible(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\t// Choose format based on config.Mode and model capabilities:\n\t// - ModeLevel: use Level format (validation will reject unsupported levels)\n\t// - ModeNone: use Level format if model has Levels, else Budget format\n\t// - ModeBudget/ModeAuto: use Budget format\n\tswitch config.Mode {\n\tcase thinking.ModeLevel:\n\t\treturn a.applyLevelFormat(body, config)\n\tcase thinking.ModeNone:\n\t\t// ModeNone: route based on model capability (has Levels or not)\n\t\tif len(modelInfo.Thinking.Levels) > 0 {\n\t\t\treturn a.applyLevelFormat(body, config)\n\t\t}\n\t\treturn a.applyBudgetFormat(body, config)\n\tdefault:\n\t\treturn a.applyBudgetFormat(body, config)\n\t}\n}\n\nfunc (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tif config.Mode == thinking.ModeAuto {\n\t\treturn a.applyBudgetFormat(body, config)\n\t}\n\n\tif config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != \"\") {\n\t\treturn a.applyLevelFormat(body, config)\n\t}\n\n\treturn a.applyBudgetFormat(body, config)\n}\n\nfunc (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\t// ModeNone semantics:\n\t//   - ModeNone + Budget=0: completely disable thinking (not possible for Level-only models)\n\t//   - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)\n\t// ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0.\n\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"generationConfig.thinkingConfig.thinkingBudget\")\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.thinking_budget\")\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.thinking_level\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.include_thoughts\")\n\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.includeThoughts\", false)\n\t\tif config.Level != \"\" {\n\t\t\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.thinkingLevel\", string(config.Level))\n\t\t}\n\t\treturn result, nil\n\t}\n\n\t// Only handle ModeLevel - budget conversion should be done by upper layer\n\tif config.Mode != thinking.ModeLevel {\n\t\treturn body, nil\n\t}\n\n\tlevel := string(config.Level)\n\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.thinkingLevel\", level)\n\n\t// Respect user's explicit includeThoughts setting from original body; default to true if not set\n\t// Support both camelCase and snake_case variants\n\tincludeThoughts := true\n\tif inc := gjson.GetBytes(body, \"generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t} else if inc := gjson.GetBytes(body, \"generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t}\n\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n\nfunc (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"generationConfig.thinkingConfig.thinkingLevel\")\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.thinking_level\")\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.thinking_budget\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"generationConfig.thinkingConfig.include_thoughts\")\n\n\tbudget := config.Budget\n\n\t// For ModeNone, always set includeThoughts to false regardless of user setting.\n\t// This ensures that when user requests budget=0 (disable thinking output),\n\t// the includeThoughts is correctly set to false even if budget is clamped to min.\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.includeThoughts\", false)\n\t\treturn result, nil\n\t}\n\n\t// Determine includeThoughts: respect user's explicit setting from original body if provided\n\t// Support both camelCase and snake_case variants\n\tvar includeThoughts bool\n\tvar userSetIncludeThoughts bool\n\tif inc := gjson.GetBytes(body, \"generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t} else if inc := gjson.GetBytes(body, \"generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t}\n\n\tif !userSetIncludeThoughts {\n\t\t// No explicit setting, use default logic based on mode\n\t\tswitch config.Mode {\n\t\tcase thinking.ModeAuto:\n\t\t\tincludeThoughts = true\n\t\tdefault:\n\t\t\tincludeThoughts = budget > 0\n\t\t}\n\t}\n\n\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.thinkingBudget\", budget)\n\tresult, _ = sjson.SetBytes(result, \"generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/thinking/provider/geminicli/apply.go",
    "content": "// Package geminicli implements thinking configuration for Gemini CLI API format.\n//\n// Gemini CLI uses request.generationConfig.thinkingConfig.* path instead of\n// generationConfig.thinkingConfig.* used by standard Gemini API.\npackage geminicli\n\nimport (\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier applies thinking configuration for Gemini CLI API format.\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new Gemini CLI thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"gemini-cli\", NewApplier())\n}\n\n// Apply applies thinking configuration to Gemini CLI request body.\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn a.applyCompatible(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\t// ModeAuto: Always use Budget format with thinkingBudget=-1\n\tif config.Mode == thinking.ModeAuto {\n\t\treturn a.applyBudgetFormat(body, config)\n\t}\n\tif config.Mode == thinking.ModeBudget {\n\t\treturn a.applyBudgetFormat(body, config)\n\t}\n\n\t// For non-auto modes, choose format based on model capabilities\n\tsupport := modelInfo.Thinking\n\tif len(support.Levels) > 0 {\n\t\treturn a.applyLevelFormat(body, config)\n\t}\n\treturn a.applyBudgetFormat(body, config)\n}\n\nfunc (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tif config.Mode == thinking.ModeAuto {\n\t\treturn a.applyBudgetFormat(body, config)\n\t}\n\n\tif config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != \"\") {\n\t\treturn a.applyLevelFormat(body, config)\n\t}\n\n\treturn a.applyBudgetFormat(body, config)\n}\n\nfunc (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"request.generationConfig.thinkingConfig.thinkingBudget\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_budget\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_level\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.include_thoughts\")\n\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", false)\n\t\tif config.Level != \"\" {\n\t\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingLevel\", string(config.Level))\n\t\t}\n\t\treturn result, nil\n\t}\n\n\t// Only handle ModeLevel - budget conversion should be done by upper layer\n\tif config.Mode != thinking.ModeLevel {\n\t\treturn body, nil\n\t}\n\n\tlevel := string(config.Level)\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingLevel\", level)\n\n\t// Respect user's explicit includeThoughts setting from original body; default to true if not set\n\t// Support both camelCase and snake_case variants\n\tincludeThoughts := true\n\tif inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t} else if inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t}\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n\nfunc (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\t// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output\n\tresult, _ := sjson.DeleteBytes(body, \"request.generationConfig.thinkingConfig.thinkingLevel\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_level\")\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.thinking_budget\")\n\t// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.\n\tresult, _ = sjson.DeleteBytes(result, \"request.generationConfig.thinkingConfig.include_thoughts\")\n\n\tbudget := config.Budget\n\n\t// For ModeNone, always set includeThoughts to false regardless of user setting.\n\t// This ensures that when user requests budget=0 (disable thinking output),\n\t// the includeThoughts is correctly set to false even if budget is clamped to min.\n\tif config.Mode == thinking.ModeNone {\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", false)\n\t\treturn result, nil\n\t}\n\n\t// Determine includeThoughts: respect user's explicit setting from original body if provided\n\t// Support both camelCase and snake_case variants\n\tvar includeThoughts bool\n\tvar userSetIncludeThoughts bool\n\tif inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.includeThoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t} else if inc := gjson.GetBytes(body, \"request.generationConfig.thinkingConfig.include_thoughts\"); inc.Exists() {\n\t\tincludeThoughts = inc.Bool()\n\t\tuserSetIncludeThoughts = true\n\t}\n\n\tif !userSetIncludeThoughts {\n\t\t// No explicit setting, use default logic based on mode\n\t\tswitch config.Mode {\n\t\tcase thinking.ModeAuto:\n\t\t\tincludeThoughts = true\n\t\tdefault:\n\t\t\tincludeThoughts = budget > 0\n\t\t}\n\t}\n\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\tresult, _ = sjson.SetBytes(result, \"request.generationConfig.thinkingConfig.includeThoughts\", includeThoughts)\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/thinking/provider/iflow/apply.go",
    "content": "// Package iflow implements thinking configuration for iFlow models.\n//\n// iFlow models use boolean toggle semantics:\n//   - Models using chat_template_kwargs.enable_thinking (boolean toggle)\n//   - MiniMax models: reasoning_split (boolean)\n//\n// Level values are converted to boolean: none=false, all others=true\n// See: _bmad-output/planning-artifacts/architecture.md#Epic-9\npackage iflow\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier implements thinking.ProviderApplier for iFlow models.\n//\n// iFlow-specific behavior:\n//   - enable_thinking toggle models: enable_thinking boolean\n//   - GLM models: enable_thinking boolean + clear_thinking=false\n//   - MiniMax models: reasoning_split boolean\n//   - Level to boolean: none=false, others=true\n//   - No quantized support (only on/off)\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new iFlow thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"iflow\", NewApplier())\n}\n\n// Apply applies thinking configuration to iFlow request body.\n//\n// Expected output format (GLM):\n//\n//\t{\n//\t  \"chat_template_kwargs\": {\n//\t    \"enable_thinking\": true,\n//\t    \"clear_thinking\": false\n//\t  }\n//\t}\n//\n// Expected output format (MiniMax):\n//\n//\t{\n//\t  \"reasoning_split\": true\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn body, nil\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif isEnableThinkingModel(modelInfo.ID) {\n\t\treturn applyEnableThinking(body, config, isGLMModel(modelInfo.ID)), nil\n\t}\n\n\tif isMiniMaxModel(modelInfo.ID) {\n\t\treturn applyMiniMax(body, config), nil\n\t}\n\n\treturn body, nil\n}\n\n// configToBoolean converts ThinkingConfig to boolean for iFlow models.\n//\n// Conversion rules:\n//   - ModeNone: false\n//   - ModeAuto: true\n//   - ModeBudget + Budget=0: false\n//   - ModeBudget + Budget>0: true\n//   - ModeLevel + Level=\"none\": false\n//   - ModeLevel + any other level: true\n//   - Default (unknown mode): true\nfunc configToBoolean(config thinking.ThinkingConfig) bool {\n\tswitch config.Mode {\n\tcase thinking.ModeNone:\n\t\treturn false\n\tcase thinking.ModeAuto:\n\t\treturn true\n\tcase thinking.ModeBudget:\n\t\treturn config.Budget > 0\n\tcase thinking.ModeLevel:\n\t\treturn config.Level != thinking.LevelNone\n\tdefault:\n\t\treturn true\n\t}\n}\n\n// applyEnableThinking applies thinking configuration for models that use\n// chat_template_kwargs.enable_thinking format.\n//\n// Output format when enabled:\n//\n//\t{\"chat_template_kwargs\": {\"enable_thinking\": true, \"clear_thinking\": false}}\n//\n// Output format when disabled:\n//\n//\t{\"chat_template_kwargs\": {\"enable_thinking\": false}}\n//\n// Note: clear_thinking is only set for GLM models when thinking is enabled.\nfunc applyEnableThinking(body []byte, config thinking.ThinkingConfig, setClearThinking bool) []byte {\n\tenableThinking := configToBoolean(config)\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"chat_template_kwargs.enable_thinking\", enableThinking)\n\n\t// clear_thinking is a GLM-only knob, strip it for other models.\n\tresult, _ = sjson.DeleteBytes(result, \"chat_template_kwargs.clear_thinking\")\n\n\t// clear_thinking only needed when thinking is enabled\n\tif enableThinking && setClearThinking {\n\t\tresult, _ = sjson.SetBytes(result, \"chat_template_kwargs.clear_thinking\", false)\n\t}\n\n\treturn result\n}\n\n// applyMiniMax applies thinking configuration for MiniMax models.\n//\n// Output format:\n//\n//\t{\"reasoning_split\": true/false}\nfunc applyMiniMax(body []byte, config thinking.ThinkingConfig) []byte {\n\treasoningSplit := configToBoolean(config)\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"reasoning_split\", reasoningSplit)\n\n\treturn result\n}\n\n// isEnableThinkingModel determines if the model uses chat_template_kwargs.enable_thinking format.\nfunc isEnableThinkingModel(modelID string) bool {\n\tif isGLMModel(modelID) {\n\t\treturn true\n\t}\n\tid := strings.ToLower(modelID)\n\tswitch id {\n\tcase \"qwen3-max-preview\", \"deepseek-v3.2\", \"deepseek-v3.1\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// isGLMModel determines if the model is a GLM series model.\nfunc isGLMModel(modelID string) bool {\n\treturn strings.HasPrefix(strings.ToLower(modelID), \"glm\")\n}\n\n// isMiniMaxModel determines if the model is a MiniMax series model.\n// MiniMax models use reasoning_split format.\nfunc isMiniMaxModel(modelID string) bool {\n\treturn strings.HasPrefix(strings.ToLower(modelID), \"minimax\")\n}\n"
  },
  {
    "path": "internal/thinking/provider/kimi/apply.go",
    "content": "// Package kimi implements thinking configuration for Kimi (Moonshot AI) models.\n//\n// Kimi models use the OpenAI-compatible reasoning_effort format for enabled thinking\n// levels, but use thinking.type=disabled when thinking is explicitly turned off.\npackage kimi\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier implements thinking.ProviderApplier for Kimi models.\n//\n// Kimi-specific behavior:\n//   - Enabled thinking: reasoning_effort (string levels)\n//   - Disabled thinking: thinking.type=\"disabled\"\n//   - Supports budget-to-level conversion\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new Kimi thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"kimi\", NewApplier())\n}\n\n// Apply applies thinking configuration to Kimi request body.\n//\n// Expected output format (enabled):\n//\n//\t{\n//\t  \"reasoning_effort\": \"high\"\n//\t}\n//\n// Expected output format (disabled):\n//\n//\t{\n//\t  \"thinking\": {\n//\t    \"type\": \"disabled\"\n//\t  }\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn applyCompatibleKimi(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tvar effort string\n\tswitch config.Mode {\n\tcase thinking.ModeLevel:\n\t\tif config.Level == \"\" {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = string(config.Level)\n\tcase thinking.ModeNone:\n\t\t// Respect clamped fallback level for models that cannot disable thinking.\n\t\tif config.Level != \"\" && config.Level != thinking.LevelNone {\n\t\t\teffort = string(config.Level)\n\t\t\tbreak\n\t\t}\n\t\t// Kimi requires explicit disabled thinking object.\n\t\treturn applyDisabledThinking(body)\n\tcase thinking.ModeBudget:\n\t\t// Convert budget to level using threshold mapping\n\t\tlevel, ok := thinking.ConvertBudgetToLevel(config.Budget)\n\t\tif !ok {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = level\n\tcase thinking.ModeAuto:\n\t\t// Auto mode maps to \"auto\" effort\n\t\teffort = string(thinking.LevelAuto)\n\tdefault:\n\t\treturn body, nil\n\t}\n\n\tif effort == \"\" {\n\t\treturn body, nil\n\t}\n\treturn applyReasoningEffort(body, effort)\n}\n\n// applyCompatibleKimi applies thinking config for user-defined Kimi models.\nfunc applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tvar effort string\n\tswitch config.Mode {\n\tcase thinking.ModeLevel:\n\t\tif config.Level == \"\" {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = string(config.Level)\n\tcase thinking.ModeNone:\n\t\tif config.Level == \"\" || config.Level == thinking.LevelNone {\n\t\t\treturn applyDisabledThinking(body)\n\t\t}\n\t\tif config.Level != \"\" {\n\t\t\teffort = string(config.Level)\n\t\t}\n\tcase thinking.ModeAuto:\n\t\teffort = string(thinking.LevelAuto)\n\tcase thinking.ModeBudget:\n\t\t// Convert budget to level\n\t\tlevel, ok := thinking.ConvertBudgetToLevel(config.Budget)\n\t\tif !ok {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = level\n\tdefault:\n\t\treturn body, nil\n\t}\n\n\treturn applyReasoningEffort(body, effort)\n}\n\nfunc applyReasoningEffort(body []byte, effort string) ([]byte, error) {\n\tresult, errDeleteThinking := sjson.DeleteBytes(body, \"thinking\")\n\tif errDeleteThinking != nil {\n\t\treturn body, fmt.Errorf(\"kimi thinking: failed to clear thinking object: %w\", errDeleteThinking)\n\t}\n\tresult, errSetEffort := sjson.SetBytes(result, \"reasoning_effort\", effort)\n\tif errSetEffort != nil {\n\t\treturn body, fmt.Errorf(\"kimi thinking: failed to set reasoning_effort: %w\", errSetEffort)\n\t}\n\treturn result, nil\n}\n\nfunc applyDisabledThinking(body []byte) ([]byte, error) {\n\tresult, errDeleteThinking := sjson.DeleteBytes(body, \"thinking\")\n\tif errDeleteThinking != nil {\n\t\treturn body, fmt.Errorf(\"kimi thinking: failed to clear thinking object: %w\", errDeleteThinking)\n\t}\n\tresult, errDeleteEffort := sjson.DeleteBytes(result, \"reasoning_effort\")\n\tif errDeleteEffort != nil {\n\t\treturn body, fmt.Errorf(\"kimi thinking: failed to clear reasoning_effort: %w\", errDeleteEffort)\n\t}\n\tresult, errSetType := sjson.SetBytes(result, \"thinking.type\", \"disabled\")\n\tif errSetType != nil {\n\t\treturn body, fmt.Errorf(\"kimi thinking: failed to set thinking.type: %w\", errSetType)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/thinking/provider/kimi/apply_test.go",
    "content": "package kimi\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestApply_ModeNone_UsesDisabledThinking(t *testing.T) {\n\tapplier := NewApplier()\n\tmodelInfo := &registry.ModelInfo{\n\t\tID:       \"kimi-k2.5\",\n\t\tThinking: &registry.ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},\n\t}\n\tbody := []byte(`{\"model\":\"kimi-k2.5\",\"reasoning_effort\":\"none\",\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":2048}}`)\n\n\tout, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeNone}, modelInfo)\n\tif errApply != nil {\n\t\tt.Fatalf(\"Apply() error = %v\", errApply)\n\t}\n\tif got := gjson.GetBytes(out, \"thinking.type\").String(); got != \"disabled\" {\n\t\tt.Fatalf(\"thinking.type = %q, want %q, body=%s\", got, \"disabled\", string(out))\n\t}\n\tif gjson.GetBytes(out, \"thinking.budget_tokens\").Exists() {\n\t\tt.Fatalf(\"thinking.budget_tokens should be removed, body=%s\", string(out))\n\t}\n\tif gjson.GetBytes(out, \"reasoning_effort\").Exists() {\n\t\tt.Fatalf(\"reasoning_effort should be removed in ModeNone, body=%s\", string(out))\n\t}\n}\n\nfunc TestApply_ModeLevel_UsesReasoningEffort(t *testing.T) {\n\tapplier := NewApplier()\n\tmodelInfo := &registry.ModelInfo{\n\t\tID:       \"kimi-k2.5\",\n\t\tThinking: &registry.ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},\n\t}\n\tbody := []byte(`{\"model\":\"kimi-k2.5\",\"thinking\":{\"type\":\"disabled\"}}`)\n\n\tout, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, modelInfo)\n\tif errApply != nil {\n\t\tt.Fatalf(\"Apply() error = %v\", errApply)\n\t}\n\tif got := gjson.GetBytes(out, \"reasoning_effort\").String(); got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %q, want %q, body=%s\", got, \"high\", string(out))\n\t}\n\tif gjson.GetBytes(out, \"thinking\").Exists() {\n\t\tt.Fatalf(\"thinking should be removed when reasoning_effort is used, body=%s\", string(out))\n\t}\n}\n\nfunc TestApply_UserDefinedModeNone_UsesDisabledThinking(t *testing.T) {\n\tapplier := NewApplier()\n\tmodelInfo := &registry.ModelInfo{\n\t\tID:          \"custom-kimi-model\",\n\t\tUserDefined: true,\n\t}\n\tbody := []byte(`{\"model\":\"custom-kimi-model\",\"reasoning_effort\":\"none\"}`)\n\n\tout, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeNone}, modelInfo)\n\tif errApply != nil {\n\t\tt.Fatalf(\"Apply() error = %v\", errApply)\n\t}\n\tif got := gjson.GetBytes(out, \"thinking.type\").String(); got != \"disabled\" {\n\t\tt.Fatalf(\"thinking.type = %q, want %q, body=%s\", got, \"disabled\", string(out))\n\t}\n\tif gjson.GetBytes(out, \"reasoning_effort\").Exists() {\n\t\tt.Fatalf(\"reasoning_effort should be removed in ModeNone, body=%s\", string(out))\n\t}\n}\n"
  },
  {
    "path": "internal/thinking/provider/openai/apply.go",
    "content": "// Package openai implements thinking configuration for OpenAI/Codex models.\n//\n// OpenAI models use the reasoning_effort format with discrete levels\n// (low/medium/high). Some models support xhigh and none levels.\n// See: _bmad-output/planning-artifacts/architecture.md#Epic-8\npackage openai\n\nimport (\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Applier implements thinking.ProviderApplier for OpenAI models.\n//\n// OpenAI-specific behavior:\n//   - Output format: reasoning_effort (string: low/medium/high/xhigh)\n//   - Level-only mode: no numeric budget support\n//   - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)\ntype Applier struct{}\n\nvar _ thinking.ProviderApplier = (*Applier)(nil)\n\n// NewApplier creates a new OpenAI thinking applier.\nfunc NewApplier() *Applier {\n\treturn &Applier{}\n}\n\nfunc init() {\n\tthinking.RegisterProvider(\"openai\", NewApplier())\n}\n\n// Apply applies thinking configuration to OpenAI request body.\n//\n// Expected output format:\n//\n//\t{\n//\t  \"reasoning_effort\": \"high\"\n//\t}\nfunc (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {\n\tif thinking.IsUserDefinedModel(modelInfo) {\n\t\treturn applyCompatibleOpenAI(body, config)\n\t}\n\tif modelInfo.Thinking == nil {\n\t\treturn body, nil\n\t}\n\n\t// Only handle ModeLevel and ModeNone; other modes pass through unchanged.\n\tif config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {\n\t\treturn body, nil\n\t}\n\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tif config.Mode == thinking.ModeLevel {\n\t\tresult, _ := sjson.SetBytes(body, \"reasoning_effort\", string(config.Level))\n\t\treturn result, nil\n\t}\n\n\teffort := \"\"\n\tsupport := modelInfo.Thinking\n\tif config.Budget == 0 {\n\t\tif support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {\n\t\t\teffort = string(thinking.LevelNone)\n\t\t}\n\t}\n\tif effort == \"\" && config.Level != \"\" {\n\t\teffort = string(config.Level)\n\t}\n\tif effort == \"\" && len(support.Levels) > 0 {\n\t\teffort = support.Levels[0]\n\t}\n\tif effort == \"\" {\n\t\treturn body, nil\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"reasoning_effort\", effort)\n\treturn result, nil\n}\n\nfunc applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, error) {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\tbody = []byte(`{}`)\n\t}\n\n\tvar effort string\n\tswitch config.Mode {\n\tcase thinking.ModeLevel:\n\t\tif config.Level == \"\" {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = string(config.Level)\n\tcase thinking.ModeNone:\n\t\teffort = string(thinking.LevelNone)\n\t\tif config.Level != \"\" {\n\t\t\teffort = string(config.Level)\n\t\t}\n\tcase thinking.ModeAuto:\n\t\t// Auto mode for user-defined models: pass through as \"auto\"\n\t\teffort = string(thinking.LevelAuto)\n\tcase thinking.ModeBudget:\n\t\t// Budget mode: convert budget to level using threshold mapping\n\t\tlevel, ok := thinking.ConvertBudgetToLevel(config.Budget)\n\t\tif !ok {\n\t\t\treturn body, nil\n\t\t}\n\t\teffort = level\n\tdefault:\n\t\treturn body, nil\n\t}\n\n\tresult, _ := sjson.SetBytes(body, \"reasoning_effort\", effort)\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/thinking/strip.go",
    "content": "// Package thinking provides unified thinking configuration processing.\npackage thinking\n\nimport (\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// StripThinkingConfig removes thinking configuration fields from request body.\n//\n// This function is used when a model doesn't support thinking but the request\n// contains thinking configuration. The configuration is silently removed to\n// prevent upstream API errors.\n//\n// Parameters:\n//   - body: Original request body JSON\n//   - provider: Provider name (determines which fields to strip)\n//\n// Returns:\n//   - Modified request body JSON with thinking configuration removed\n//   - Original body is returned unchanged if:\n//   - body is empty or invalid JSON\n//   - provider is unknown\n//   - no thinking configuration found\nfunc StripThinkingConfig(body []byte, provider string) []byte {\n\tif len(body) == 0 || !gjson.ValidBytes(body) {\n\t\treturn body\n\t}\n\n\tvar paths []string\n\tswitch provider {\n\tcase \"claude\":\n\t\tpaths = []string{\"thinking\", \"output_config.effort\"}\n\tcase \"gemini\":\n\t\tpaths = []string{\"generationConfig.thinkingConfig\"}\n\tcase \"gemini-cli\", \"antigravity\":\n\t\tpaths = []string{\"request.generationConfig.thinkingConfig\"}\n\tcase \"openai\":\n\t\tpaths = []string{\"reasoning_effort\"}\n\tcase \"kimi\":\n\t\tpaths = []string{\n\t\t\t\"reasoning_effort\",\n\t\t\t\"thinking\",\n\t\t}\n\tcase \"codex\":\n\t\tpaths = []string{\"reasoning.effort\"}\n\tcase \"iflow\":\n\t\tpaths = []string{\n\t\t\t\"chat_template_kwargs.enable_thinking\",\n\t\t\t\"chat_template_kwargs.clear_thinking\",\n\t\t\t\"reasoning_split\",\n\t\t\t\"reasoning_effort\",\n\t\t}\n\tdefault:\n\t\treturn body\n\t}\n\n\tresult := body\n\tfor _, path := range paths {\n\t\tresult, _ = sjson.DeleteBytes(result, path)\n\t}\n\n\t// Avoid leaving an empty output_config object for Claude when effort was the only field.\n\tif provider == \"claude\" {\n\t\tif oc := gjson.GetBytes(result, \"output_config\"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {\n\t\t\tresult, _ = sjson.DeleteBytes(result, \"output_config\")\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/thinking/suffix.go",
    "content": "// Package thinking provides unified thinking configuration processing.\n//\n// This file implements suffix parsing functionality for extracting\n// thinking configuration from model names in the format model(value).\npackage thinking\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n)\n\n// ParseSuffix extracts thinking suffix from a model name.\n//\n// The suffix format is: model-name(value)\n// Examples:\n//   - \"claude-sonnet-4-5(16384)\" -> ModelName=\"claude-sonnet-4-5\", RawSuffix=\"16384\"\n//   - \"gpt-5.2(high)\" -> ModelName=\"gpt-5.2\", RawSuffix=\"high\"\n//   - \"gemini-2.5-pro\" -> ModelName=\"gemini-2.5-pro\", HasSuffix=false\n//\n// This function only extracts the suffix; it does not validate or interpret\n// the suffix content. Use ParseNumericSuffix, ParseLevelSuffix, etc. for\n// content interpretation.\nfunc ParseSuffix(model string) SuffixResult {\n\t// Find the last opening parenthesis\n\tlastOpen := strings.LastIndex(model, \"(\")\n\tif lastOpen == -1 {\n\t\treturn SuffixResult{ModelName: model, HasSuffix: false}\n\t}\n\n\t// Check if the string ends with a closing parenthesis\n\tif !strings.HasSuffix(model, \")\") {\n\t\treturn SuffixResult{ModelName: model, HasSuffix: false}\n\t}\n\n\t// Extract components\n\tmodelName := model[:lastOpen]\n\trawSuffix := model[lastOpen+1 : len(model)-1]\n\n\treturn SuffixResult{\n\t\tModelName: modelName,\n\t\tHasSuffix: true,\n\t\tRawSuffix: rawSuffix,\n\t}\n}\n\n// ParseNumericSuffix attempts to parse a raw suffix as a numeric budget value.\n//\n// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as an integer.\n// Only non-negative integers are considered valid numeric suffixes.\n//\n// Platform note: The budget value uses Go's int type, which is 32-bit on 32-bit\n// systems and 64-bit on 64-bit systems. Values exceeding the platform's int range\n// will return ok=false.\n//\n// Leading zeros are accepted: \"08192\" parses as 8192.\n//\n// Examples:\n//   - \"8192\" -> budget=8192, ok=true\n//   - \"0\" -> budget=0, ok=true (represents ModeNone)\n//   - \"08192\" -> budget=8192, ok=true (leading zeros accepted)\n//   - \"-1\" -> budget=0, ok=false (negative numbers are not valid numeric suffixes)\n//   - \"high\" -> budget=0, ok=false (not a number)\n//   - \"9223372036854775808\" -> budget=0, ok=false (overflow on 64-bit systems)\n//\n// For special handling of -1 as auto mode, use ParseSpecialSuffix instead.\nfunc ParseNumericSuffix(rawSuffix string) (budget int, ok bool) {\n\tif rawSuffix == \"\" {\n\t\treturn 0, false\n\t}\n\n\tvalue, err := strconv.Atoi(rawSuffix)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\n\t// Negative numbers are not valid numeric suffixes\n\t// -1 should be handled by special value parsing as \"auto\"\n\tif value < 0 {\n\t\treturn 0, false\n\t}\n\n\treturn value, true\n}\n\n// ParseSpecialSuffix attempts to parse a raw suffix as a special thinking mode value.\n//\n// This function handles special strings that represent a change in thinking mode:\n//   - \"none\" -> ModeNone (disables thinking)\n//   - \"auto\" -> ModeAuto (automatic/dynamic thinking)\n//   - \"-1\"   -> ModeAuto (numeric representation of auto mode)\n//\n// String values are case-insensitive.\nfunc ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) {\n\tif rawSuffix == \"\" {\n\t\treturn ModeBudget, false\n\t}\n\n\t// Case-insensitive matching\n\tswitch strings.ToLower(rawSuffix) {\n\tcase \"none\":\n\t\treturn ModeNone, true\n\tcase \"auto\", \"-1\":\n\t\treturn ModeAuto, true\n\tdefault:\n\t\treturn ModeBudget, false\n\t}\n}\n\n// ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level.\n//\n// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level.\n// Only discrete effort levels are valid: minimal, low, medium, high, xhigh, max.\n// Level matching is case-insensitive.\n//\n// Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix\n// instead. This separation allows callers to prioritize special value handling.\n//\n// Examples:\n//   - \"high\" -> level=LevelHigh, ok=true\n//   - \"HIGH\" -> level=LevelHigh, ok=true (case insensitive)\n//   - \"medium\" -> level=LevelMedium, ok=true\n//   - \"none\" -> level=\"\", ok=false (special value, use ParseSpecialSuffix)\n//   - \"auto\" -> level=\"\", ok=false (special value, use ParseSpecialSuffix)\n//   - \"8192\" -> level=\"\", ok=false (numeric, use ParseNumericSuffix)\n//   - \"ultra\" -> level=\"\", ok=false (unknown level)\nfunc ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) {\n\tif rawSuffix == \"\" {\n\t\treturn \"\", false\n\t}\n\n\t// Case-insensitive matching\n\tswitch strings.ToLower(rawSuffix) {\n\tcase \"minimal\":\n\t\treturn LevelMinimal, true\n\tcase \"low\":\n\t\treturn LevelLow, true\n\tcase \"medium\":\n\t\treturn LevelMedium, true\n\tcase \"high\":\n\t\treturn LevelHigh, true\n\tcase \"xhigh\":\n\t\treturn LevelXHigh, true\n\tcase \"max\":\n\t\treturn LevelMax, true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n"
  },
  {
    "path": "internal/thinking/text.go",
    "content": "package thinking\n\nimport (\n\t\"github.com/tidwall/gjson\"\n)\n\n// GetThinkingText extracts the thinking text from a content part.\n// Handles various formats:\n// - Simple string: { \"thinking\": \"text\" } or { \"text\": \"text\" }\n// - Wrapped object: { \"thinking\": { \"text\": \"text\", \"cache_control\": {...} } }\n// - Gemini-style: { \"thought\": true, \"text\": \"text\" }\n// Returns the extracted text string.\nfunc GetThinkingText(part gjson.Result) string {\n\t// Try direct text field first (Gemini-style)\n\tif text := part.Get(\"text\"); text.Exists() && text.Type == gjson.String {\n\t\treturn text.String()\n\t}\n\n\t// Try thinking field\n\tthinkingField := part.Get(\"thinking\")\n\tif !thinkingField.Exists() {\n\t\treturn \"\"\n\t}\n\n\t// thinking is a string\n\tif thinkingField.Type == gjson.String {\n\t\treturn thinkingField.String()\n\t}\n\n\t// thinking is an object with inner text/thinking\n\tif thinkingField.IsObject() {\n\t\tif inner := thinkingField.Get(\"text\"); inner.Exists() && inner.Type == gjson.String {\n\t\t\treturn inner.String()\n\t\t}\n\t\tif inner := thinkingField.Get(\"thinking\"); inner.Exists() && inner.Type == gjson.String {\n\t\t\treturn inner.String()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/thinking/types.go",
    "content": "// Package thinking provides unified thinking configuration processing.\n//\n// This package offers a unified interface for parsing, validating, and applying\n// thinking configurations across various AI providers (Claude, Gemini, OpenAI, iFlow).\npackage thinking\n\nimport \"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\n// ThinkingMode represents the type of thinking configuration mode.\ntype ThinkingMode int\n\nconst (\n\t// ModeBudget indicates using a numeric budget (corresponds to suffix \"(1000)\" etc.)\n\tModeBudget ThinkingMode = iota\n\t// ModeLevel indicates using a discrete level (corresponds to suffix \"(high)\" etc.)\n\tModeLevel\n\t// ModeNone indicates thinking is disabled (corresponds to suffix \"(none)\" or budget=0)\n\tModeNone\n\t// ModeAuto indicates automatic/dynamic thinking (corresponds to suffix \"(auto)\" or budget=-1)\n\tModeAuto\n)\n\n// String returns the string representation of ThinkingMode.\nfunc (m ThinkingMode) String() string {\n\tswitch m {\n\tcase ModeBudget:\n\t\treturn \"budget\"\n\tcase ModeLevel:\n\t\treturn \"level\"\n\tcase ModeNone:\n\t\treturn \"none\"\n\tcase ModeAuto:\n\t\treturn \"auto\"\n\tdefault:\n\t\treturn \"unknown\"\n\t}\n}\n\n// ThinkingLevel represents a discrete thinking level.\ntype ThinkingLevel string\n\nconst (\n\t// LevelNone disables thinking\n\tLevelNone ThinkingLevel = \"none\"\n\t// LevelAuto enables automatic/dynamic thinking\n\tLevelAuto ThinkingLevel = \"auto\"\n\t// LevelMinimal sets minimal thinking effort\n\tLevelMinimal ThinkingLevel = \"minimal\"\n\t// LevelLow sets low thinking effort\n\tLevelLow ThinkingLevel = \"low\"\n\t// LevelMedium sets medium thinking effort\n\tLevelMedium ThinkingLevel = \"medium\"\n\t// LevelHigh sets high thinking effort\n\tLevelHigh ThinkingLevel = \"high\"\n\t// LevelXHigh sets extra-high thinking effort\n\tLevelXHigh ThinkingLevel = \"xhigh\"\n\t// LevelMax sets maximum thinking effort.\n\t// This is currently used by Claude 4.6 adaptive thinking (opus supports \"max\").\n\tLevelMax ThinkingLevel = \"max\"\n)\n\n// ThinkingConfig represents a unified thinking configuration.\n//\n// This struct is used to pass thinking configuration information between components.\n// Depending on Mode, either Budget or Level field is effective:\n//   - ModeNone: Budget=0, Level is ignored\n//   - ModeAuto: Budget=-1, Level is ignored\n//   - ModeBudget: Budget is a positive integer, Level is ignored\n//   - ModeLevel: Budget is ignored, Level is a valid level\ntype ThinkingConfig struct {\n\t// Mode specifies the configuration mode\n\tMode ThinkingMode\n\t// Budget is the thinking budget (token count), only effective when Mode is ModeBudget.\n\t// Special values: 0 means disabled, -1 means automatic\n\tBudget int\n\t// Level is the thinking level, only effective when Mode is ModeLevel\n\tLevel ThinkingLevel\n}\n\n// SuffixResult represents the result of parsing a model name for thinking suffix.\n//\n// A thinking suffix is specified in the format model-name(value), where value\n// can be a numeric budget (e.g., \"16384\") or a level name (e.g., \"high\").\ntype SuffixResult struct {\n\t// ModelName is the model name with the suffix removed.\n\t// If no suffix was found, this equals the original input.\n\tModelName string\n\n\t// HasSuffix indicates whether a valid suffix was found.\n\tHasSuffix bool\n\n\t// RawSuffix is the content inside the parentheses, without the parentheses.\n\t// Empty string if HasSuffix is false.\n\tRawSuffix string\n}\n\n// ProviderApplier defines the interface for provider-specific thinking configuration application.\n//\n// Types implementing this interface are responsible for converting a unified ThinkingConfig\n// into provider-specific format and applying it to the request body.\n//\n// Implementation requirements:\n//   - Apply method must be idempotent\n//   - Must not modify the input config or modelInfo\n//   - Returns a modified copy of the request body\n//   - Returns appropriate ThinkingError for unsupported configurations\ntype ProviderApplier interface {\n\t// Apply applies the thinking configuration to the request body.\n\t//\n\t// Parameters:\n\t//   - body: Original request body JSON\n\t//   - config: Unified thinking configuration\n\t//   - modelInfo: Model registry information containing ThinkingSupport properties\n\t//\n\t// Returns:\n\t//   - Modified request body JSON\n\t//   - ThinkingError if the configuration is invalid or unsupported\n\tApply(body []byte, config ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error)\n}\n"
  },
  {
    "path": "internal/thinking/validate.go",
    "content": "// Package thinking provides unified thinking configuration processing logic.\npackage thinking\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ValidateConfig validates a thinking configuration against model capabilities.\n//\n// This function performs comprehensive validation:\n//   - Checks if the model supports thinking\n//   - Auto-converts between Budget and Level formats based on model capability\n//   - Validates that requested level is in the model's supported levels list\n//   - Clamps budget values to model's allowed range\n//   - When converting Budget -> Level for level-only models, clamps the derived standard level to the nearest supported level\n//     (special values none/auto are preserved)\n//   - When config comes from a model suffix, strict budget validation is disabled (we clamp instead of error)\n//\n// Parameters:\n//   - config: The thinking configuration to validate\n//   - support: Model's ThinkingSupport properties (nil means no thinking support)\n//   - fromFormat: Source provider format (used to determine strict validation rules)\n//   - toFormat: Target provider format\n//   - fromSuffix: Whether config was sourced from model suffix\n//\n// Returns:\n//   - Normalized ThinkingConfig with clamped values\n//   - ThinkingError if validation fails (ErrThinkingNotSupported, ErrLevelNotSupported, etc.)\n//\n// Auto-conversion behavior:\n//   - Budget-only model + Level config → Level converted to Budget\n//   - Level-only model + Budget config → Budget converted to Level\n//   - Hybrid model → preserve original format\nfunc ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string, fromSuffix bool) (*ThinkingConfig, error) {\n\tfromFormat, toFormat = strings.ToLower(strings.TrimSpace(fromFormat)), strings.ToLower(strings.TrimSpace(toFormat))\n\tmodel := \"unknown\"\n\tsupport := (*registry.ThinkingSupport)(nil)\n\tif modelInfo != nil {\n\t\tif modelInfo.ID != \"\" {\n\t\t\tmodel = modelInfo.ID\n\t\t}\n\t\tsupport = modelInfo.Thinking\n\t}\n\n\tif support == nil {\n\t\tif config.Mode != ModeNone {\n\t\t\treturn nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, \"thinking not supported for this model\", model)\n\t\t}\n\t\treturn &config, nil\n\t}\n\n\t// allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error.\n\t// This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target\n\t// model supports discrete levels. Same-family conversions require strict validation.\n\ttoCapability := detectModelCapability(modelInfo)\n\ttoHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid\n\tallowClampUnsupported := toHasLevelSupport && !isSameProviderFamily(fromFormat, toFormat)\n\n\t// strictBudget determines whether to enforce strict budget range validation.\n\t// This applies when: (1) config comes from request body (not suffix), (2) source format is known,\n\t// and (3) source and target are in the same provider family. Cross-family or suffix-based configs\n\t// are clamped instead of rejected to improve interoperability.\n\tstrictBudget := !fromSuffix && fromFormat != \"\" && isSameProviderFamily(fromFormat, toFormat)\n\tbudgetDerivedFromLevel := false\n\n\tcapability := detectModelCapability(modelInfo)\n\tswitch capability {\n\tcase CapabilityBudgetOnly:\n\t\tif config.Mode == ModeLevel {\n\t\t\tif config.Level == LevelAuto {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tbudget, ok := ConvertLevelToBudget(string(config.Level))\n\t\t\tif !ok {\n\t\t\t\treturn nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf(\"unknown level: %s\", config.Level))\n\t\t\t}\n\t\t\tconfig.Mode = ModeBudget\n\t\t\tconfig.Budget = budget\n\t\t\tconfig.Level = \"\"\n\t\t\tbudgetDerivedFromLevel = true\n\t\t}\n\tcase CapabilityLevelOnly:\n\t\tif config.Mode == ModeBudget {\n\t\t\tlevel, ok := ConvertBudgetToLevel(config.Budget)\n\t\t\tif !ok {\n\t\t\t\treturn nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf(\"budget %d cannot be converted to a valid level\", config.Budget))\n\t\t\t}\n\t\t\t// When converting Budget -> Level for level-only models, clamp the derived standard level\n\t\t\t// to the nearest supported level. Special values (none/auto) are preserved.\n\t\t\tconfig.Mode = ModeLevel\n\t\t\tconfig.Level = clampLevel(ThinkingLevel(level), modelInfo, toFormat)\n\t\t\tconfig.Budget = 0\n\t\t}\n\tcase CapabilityHybrid:\n\t}\n\n\tif config.Mode == ModeLevel && config.Level == LevelNone {\n\t\tconfig.Mode = ModeNone\n\t\tconfig.Budget = 0\n\t\tconfig.Level = \"\"\n\t}\n\tif config.Mode == ModeLevel && config.Level == LevelAuto {\n\t\tconfig.Mode = ModeAuto\n\t\tconfig.Budget = -1\n\t\tconfig.Level = \"\"\n\t}\n\tif config.Mode == ModeBudget && config.Budget == 0 {\n\t\tconfig.Mode = ModeNone\n\t\tconfig.Level = \"\"\n\t}\n\n\tif len(support.Levels) > 0 && config.Mode == ModeLevel {\n\t\tif !isLevelSupported(string(config.Level), support.Levels) {\n\t\t\tif allowClampUnsupported {\n\t\t\t\tconfig.Level = clampLevel(config.Level, modelInfo, toFormat)\n\t\t\t}\n\t\t\tif !isLevelSupported(string(config.Level), support.Levels) {\n\t\t\t\t// User explicitly specified an unsupported level - return error\n\t\t\t\t// (budget-derived levels may be clamped based on source format)\n\t\t\t\tvalidLevels := normalizeLevels(support.Levels)\n\t\t\t\tmessage := fmt.Sprintf(\"level %q not supported, valid levels: %s\", strings.ToLower(string(config.Level)), strings.Join(validLevels, \", \"))\n\t\t\t\treturn nil, NewThinkingError(ErrLevelNotSupported, message)\n\t\t\t}\n\t\t}\n\t}\n\n\tif strictBudget && config.Mode == ModeBudget && !budgetDerivedFromLevel {\n\t\tmin, max := support.Min, support.Max\n\t\tif min != 0 || max != 0 {\n\t\t\tif config.Budget < min || config.Budget > max || (config.Budget == 0 && !support.ZeroAllowed) {\n\t\t\t\tmessage := fmt.Sprintf(\"budget %d out of range [%d,%d]\", config.Budget, min, max)\n\t\t\t\treturn nil, NewThinkingError(ErrBudgetOutOfRange, message)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Convert ModeAuto to mid-range if dynamic not allowed\n\tif config.Mode == ModeAuto && !support.DynamicAllowed {\n\t\tconfig = convertAutoToMidRange(config, support, toFormat, model)\n\t}\n\n\tif config.Mode == ModeNone && toFormat == \"claude\" {\n\t\t// Claude supports explicit disable via thinking.type=\"disabled\".\n\t\t// Keep Budget=0 so applier can omit budget_tokens.\n\t\tconfig.Budget = 0\n\t\tconfig.Level = \"\"\n\t} else {\n\t\tswitch config.Mode {\n\t\tcase ModeBudget, ModeAuto, ModeNone:\n\t\t\tconfig.Budget = clampBudget(config.Budget, modelInfo, toFormat)\n\t\t}\n\n\t\t// ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models\n\t\t// This ensures Apply layer doesn't need to access support.Levels\n\t\tif config.Mode == ModeNone && config.Budget > 0 && len(support.Levels) > 0 {\n\t\t\tconfig.Level = ThinkingLevel(support.Levels[0])\n\t\t}\n\t}\n\n\treturn &config, nil\n}\n\n// convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed.\n//\n// This function handles the case where a model does not support dynamic/auto thinking.\n// The auto mode is silently converted to a fixed value based on model capability:\n//   - Level-only models: convert to ModeLevel with LevelMedium\n//   - Budget models: convert to ModeBudget with mid = (Min + Max) / 2\n//\n// Logging:\n//   - Debug level when conversion occurs\n//   - Fields: original_mode, clamped_to, reason\nfunc convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupport, provider, model string) ThinkingConfig {\n\t// For level-only models (has Levels but no Min/Max range), use ModeLevel with medium\n\tif len(support.Levels) > 0 && support.Min == 0 && support.Max == 0 {\n\t\tconfig.Mode = ModeLevel\n\t\tconfig.Level = LevelMedium\n\t\tconfig.Budget = 0\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\":      provider,\n\t\t\t\"model\":         model,\n\t\t\t\"original_mode\": \"auto\",\n\t\t\t\"clamped_to\":    string(LevelMedium),\n\t\t}).Debug(\"thinking: mode converted, dynamic not allowed, using medium level |\")\n\t\treturn config\n\t}\n\n\t// For budget models, use mid-range budget\n\tmid := (support.Min + support.Max) / 2\n\tif mid <= 0 && support.ZeroAllowed {\n\t\tconfig.Mode = ModeNone\n\t\tconfig.Budget = 0\n\t} else if mid <= 0 {\n\t\tconfig.Mode = ModeBudget\n\t\tconfig.Budget = support.Min\n\t} else {\n\t\tconfig.Mode = ModeBudget\n\t\tconfig.Budget = mid\n\t}\n\tlog.WithFields(log.Fields{\n\t\t\"provider\":      provider,\n\t\t\"model\":         model,\n\t\t\"original_mode\": \"auto\",\n\t\t\"clamped_to\":    config.Budget,\n\t}).Debug(\"thinking: mode converted, dynamic not allowed |\")\n\treturn config\n}\n\n// standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.\nvar standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax}\n\n// clampLevel clamps the given level to the nearest supported level.\n// On tie, prefers the lower level.\nfunc clampLevel(level ThinkingLevel, modelInfo *registry.ModelInfo, provider string) ThinkingLevel {\n\tmodel := \"unknown\"\n\tvar supported []string\n\tif modelInfo != nil {\n\t\tif modelInfo.ID != \"\" {\n\t\t\tmodel = modelInfo.ID\n\t\t}\n\t\tif modelInfo.Thinking != nil {\n\t\t\tsupported = modelInfo.Thinking.Levels\n\t\t}\n\t}\n\n\tif len(supported) == 0 || isLevelSupported(string(level), supported) {\n\t\treturn level\n\t}\n\n\tpos := levelIndex(string(level))\n\tif pos == -1 {\n\t\treturn level\n\t}\n\tbestIdx, bestDist := -1, len(standardLevelOrder)+1\n\n\tfor _, s := range supported {\n\t\tif idx := levelIndex(strings.TrimSpace(s)); idx != -1 {\n\t\t\tif dist := abs(pos - idx); dist < bestDist || (dist == bestDist && idx < bestIdx) {\n\t\t\t\tbestIdx, bestDist = idx, dist\n\t\t\t}\n\t\t}\n\t}\n\n\tif bestIdx >= 0 {\n\t\tclamped := standardLevelOrder[bestIdx]\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\":       provider,\n\t\t\t\"model\":          model,\n\t\t\t\"original_value\": string(level),\n\t\t\t\"clamped_to\":     string(clamped),\n\t\t}).Debug(\"thinking: level clamped |\")\n\t\treturn clamped\n\t}\n\treturn level\n}\n\n// clampBudget clamps a budget value to the model's supported range.\nfunc clampBudget(value int, modelInfo *registry.ModelInfo, provider string) int {\n\tmodel := \"unknown\"\n\tsupport := (*registry.ThinkingSupport)(nil)\n\tif modelInfo != nil {\n\t\tif modelInfo.ID != \"\" {\n\t\t\tmodel = modelInfo.ID\n\t\t}\n\t\tsupport = modelInfo.Thinking\n\t}\n\tif support == nil {\n\t\treturn value\n\t}\n\n\t// Auto value (-1) passes through without clamping.\n\tif value == -1 {\n\t\treturn value\n\t}\n\n\tmin, max := support.Min, support.Max\n\tif value == 0 && !support.ZeroAllowed {\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"provider\":       provider,\n\t\t\t\"model\":          model,\n\t\t\t\"original_value\": value,\n\t\t\t\"clamped_to\":     min,\n\t\t\t\"min\":            min,\n\t\t\t\"max\":            max,\n\t\t}).Warn(\"thinking: budget zero not allowed |\")\n\t\treturn min\n\t}\n\n\t// Some models are level-only and do not define numeric budget ranges.\n\tif min == 0 && max == 0 {\n\t\treturn value\n\t}\n\n\tif value < min {\n\t\tif value == 0 && support.ZeroAllowed {\n\t\t\treturn 0\n\t\t}\n\t\tlogClamp(provider, model, value, min, min, max)\n\t\treturn min\n\t}\n\tif value > max {\n\t\tlogClamp(provider, model, value, max, min, max)\n\t\treturn max\n\t}\n\treturn value\n}\n\nfunc isLevelSupported(level string, supported []string) bool {\n\tfor _, s := range supported {\n\t\tif strings.EqualFold(level, strings.TrimSpace(s)) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc levelIndex(level string) int {\n\tfor i, l := range standardLevelOrder {\n\t\tif strings.EqualFold(level, string(l)) {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\nfunc normalizeLevels(levels []string) []string {\n\tout := make([]string, len(levels))\n\tfor i, l := range levels {\n\t\tout[i] = strings.ToLower(strings.TrimSpace(l))\n\t}\n\treturn out\n}\n\n// isBudgetCapableProvider returns true if the provider supports budget-based thinking.\n// These providers may also support level-based thinking (hybrid models).\nfunc isBudgetCapableProvider(provider string) bool {\n\tswitch provider {\n\tcase \"gemini\", \"gemini-cli\", \"antigravity\", \"claude\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isGeminiFamily(provider string) bool {\n\tswitch provider {\n\tcase \"gemini\", \"gemini-cli\", \"antigravity\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isOpenAIFamily(provider string) bool {\n\tswitch provider {\n\tcase \"openai\", \"openai-response\", \"codex\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isSameProviderFamily(from, to string) bool {\n\tif from == to {\n\t\treturn true\n\t}\n\treturn (isGeminiFamily(from) && isGeminiFamily(to)) ||\n\t\t(isOpenAIFamily(from) && isOpenAIFamily(to))\n}\n\nfunc abs(x int) int {\n\tif x < 0 {\n\t\treturn -x\n\t}\n\treturn x\n}\n\nfunc logClamp(provider, model string, original, clampedTo, min, max int) {\n\tlog.WithFields(log.Fields{\n\t\t\"provider\":       provider,\n\t\t\"model\":          model,\n\t\t\"original_value\": original,\n\t\t\"min\":            min,\n\t\t\"max\":            max,\n\t\t\"clamped_to\":     clampedTo,\n\t}).Debug(\"thinking: budget clamped |\")\n}\n"
  },
  {
    "path": "internal/translator/antigravity/claude/antigravity_claude_request.go",
    "content": "// Package claude provides request translation functionality for Claude Code API compatibility.\n// This package handles the conversion of Claude Code API requests into Gemini CLI-compatible\n// JSON format, transforming message contents, system instructions, and tool declarations\n// into the format expected by Gemini CLI API clients. It performs JSON data transformation\n// to ensure compatibility between Claude Code API format and Gemini CLI API's expected format.\npackage claude\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/cache\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini CLI API.\n// The function performs the following transformations:\n// 1. Extracts the model information from the request\n// 2. Restructures the JSON to match Gemini CLI API format\n// 3. Converts system instructions to the expected format\n// 4. Maps message contents with proper role transformations\n// 5. Handles tool declarations and tool choices\n// 6. Maps generation configuration parameters\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Claude Code API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini CLI API format\nfunc ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {\n\tenableThoughtTranslate := true\n\trawJSON := inputRawJSON\n\n\t// system instruction\n\tsystemInstructionJSON := \"\"\n\thasSystemInstruction := false\n\tsystemResult := gjson.GetBytes(rawJSON, \"system\")\n\tif systemResult.IsArray() {\n\t\tsystemResults := systemResult.Array()\n\t\tsystemInstructionJSON = `{\"role\":\"user\",\"parts\":[]}`\n\t\tfor i := 0; i < len(systemResults); i++ {\n\t\t\tsystemPromptResult := systemResults[i]\n\t\t\tsystemTypePromptResult := systemPromptResult.Get(\"type\")\n\t\t\tif systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == \"text\" {\n\t\t\t\tsystemPrompt := systemPromptResult.Get(\"text\").String()\n\t\t\t\tpartJSON := `{}`\n\t\t\t\tif systemPrompt != \"\" {\n\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", systemPrompt)\n\t\t\t\t}\n\t\t\t\tsystemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, \"parts.-1\", partJSON)\n\t\t\t\thasSystemInstruction = true\n\t\t\t}\n\t\t}\n\t} else if systemResult.Type == gjson.String {\n\t\tsystemInstructionJSON = `{\"role\":\"user\",\"parts\":[{\"text\":\"\"}]}`\n\t\tsystemInstructionJSON, _ = sjson.Set(systemInstructionJSON, \"parts.0.text\", systemResult.String())\n\t\thasSystemInstruction = true\n\t}\n\n\t// contents\n\tcontentsJSON := \"[]\"\n\thasContents := false\n\n\t// tool_use_id → tool_name lookup, populated incrementally during the main loop.\n\t// Claude's tool_result references tool_use by ID; Gemini requires functionResponse.name.\n\ttoolNameByID := make(map[string]string)\n\n\tmessagesResult := gjson.GetBytes(rawJSON, \"messages\")\n\tif messagesResult.IsArray() {\n\t\tmessageResults := messagesResult.Array()\n\t\tnumMessages := len(messageResults)\n\t\tfor i := 0; i < numMessages; i++ {\n\t\t\tmessageResult := messageResults[i]\n\t\t\troleResult := messageResult.Get(\"role\")\n\t\t\tif roleResult.Type != gjson.String {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\toriginalRole := roleResult.String()\n\t\t\trole := originalRole\n\t\t\tif role == \"assistant\" {\n\t\t\t\trole = \"model\"\n\t\t\t}\n\t\t\tclientContentJSON := `{\"role\":\"\",\"parts\":[]}`\n\t\t\tclientContentJSON, _ = sjson.Set(clientContentJSON, \"role\", role)\n\t\t\tcontentsResult := messageResult.Get(\"content\")\n\t\t\tif contentsResult.IsArray() {\n\t\t\t\tcontentResults := contentsResult.Array()\n\t\t\t\tnumContents := len(contentResults)\n\t\t\t\tvar currentMessageThinkingSignature string\n\t\t\t\tfor j := 0; j < numContents; j++ {\n\t\t\t\t\tcontentResult := contentResults[j]\n\t\t\t\t\tcontentTypeResult := contentResult.Get(\"type\")\n\t\t\t\t\tif contentTypeResult.Type == gjson.String && contentTypeResult.String() == \"thinking\" {\n\t\t\t\t\t\t// Use GetThinkingText to handle wrapped thinking objects\n\t\t\t\t\t\tthinkingText := thinking.GetThinkingText(contentResult)\n\n\t\t\t\t\t\t// Always try cached signature first (more reliable than client-provided)\n\t\t\t\t\t\t// Client may send stale or invalid signatures from different sessions\n\t\t\t\t\t\tsignature := \"\"\n\t\t\t\t\t\tif thinkingText != \"\" {\n\t\t\t\t\t\t\tif cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != \"\" {\n\t\t\t\t\t\t\t\tsignature = cachedSig\n\t\t\t\t\t\t\t\t// log.Debugf(\"Using cached signature for thinking block\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Fallback to client signature only if cache miss and client signature is valid\n\t\t\t\t\t\tif signature == \"\" {\n\t\t\t\t\t\t\tsignatureResult := contentResult.Get(\"signature\")\n\t\t\t\t\t\t\tclientSignature := \"\"\n\t\t\t\t\t\t\tif signatureResult.Exists() && signatureResult.String() != \"\" {\n\t\t\t\t\t\t\t\tarrayClientSignatures := strings.SplitN(signatureResult.String(), \"#\", 2)\n\t\t\t\t\t\t\t\tif len(arrayClientSignatures) == 2 {\n\t\t\t\t\t\t\t\t\tif cache.GetModelGroup(modelName) == arrayClientSignatures[0] {\n\t\t\t\t\t\t\t\t\t\tclientSignature = arrayClientSignatures[1]\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif cache.HasValidSignature(modelName, clientSignature) {\n\t\t\t\t\t\t\t\tsignature = clientSignature\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// log.Debugf(\"Using client-provided signature for thinking block\")\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Store for subsequent tool_use in the same message\n\t\t\t\t\t\tif cache.HasValidSignature(modelName, signature) {\n\t\t\t\t\t\t\tcurrentMessageThinkingSignature = signature\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Skip trailing unsigned thinking blocks on last assistant message\n\t\t\t\t\t\tisUnsigned := !cache.HasValidSignature(modelName, signature)\n\n\t\t\t\t\t\t// If unsigned, skip entirely (don't convert to text)\n\t\t\t\t\t\t// Claude requires assistant messages to start with thinking blocks when thinking is enabled\n\t\t\t\t\t\t// Converting to text would break this requirement\n\t\t\t\t\t\tif isUnsigned {\n\t\t\t\t\t\t\t// log.Debugf(\"Dropping unsigned thinking block (no valid signature)\")\n\t\t\t\t\t\t\tenableThoughtTranslate = false\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Valid signature, send as thought block\n\t\t\t\t\t\tpartJSON := `{}`\n\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"thought\", true)\n\t\t\t\t\t\tif thinkingText != \"\" {\n\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", thinkingText)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif signature != \"\" {\n\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"thoughtSignature\", signature)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\t\t} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == \"text\" {\n\t\t\t\t\t\tprompt := contentResult.Get(\"text\").String()\n\t\t\t\t\t\t// Skip empty text parts to avoid Gemini API error:\n\t\t\t\t\t\t// \"required oneof field 'data' must have one initialized field\"\n\t\t\t\t\t\tif prompt == \"\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpartJSON := `{}`\n\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", prompt)\n\t\t\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\t\t} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == \"tool_use\" {\n\t\t\t\t\t\t// NOTE: Do NOT inject dummy thinking blocks here.\n\t\t\t\t\t\t// Antigravity API validates signatures, so dummy values are rejected.\n\n\t\t\t\t\t\tfunctionName := contentResult.Get(\"name\").String()\n\t\t\t\t\t\targsResult := contentResult.Get(\"input\")\n\t\t\t\t\t\tfunctionID := contentResult.Get(\"id\").String()\n\n\t\t\t\t\t\tif functionID != \"\" && functionName != \"\" {\n\t\t\t\t\t\t\ttoolNameByID[functionID] = functionName\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Handle both object and string input formats\n\t\t\t\t\t\tvar argsRaw string\n\t\t\t\t\t\tif argsResult.IsObject() {\n\t\t\t\t\t\t\targsRaw = argsResult.Raw\n\t\t\t\t\t\t} else if argsResult.Type == gjson.String {\n\t\t\t\t\t\t\t// Input is a JSON string, parse and validate it\n\t\t\t\t\t\t\tparsed := gjson.Parse(argsResult.String())\n\t\t\t\t\t\t\tif parsed.IsObject() {\n\t\t\t\t\t\t\t\targsRaw = parsed.Raw\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif argsRaw != \"\" {\n\t\t\t\t\t\t\tpartJSON := `{}`\n\n\t\t\t\t\t\t\t// Use skip_thought_signature_validator for tool calls without valid thinking signature\n\t\t\t\t\t\t\t// This is the approach used in opencode-google-antigravity-auth for Gemini\n\t\t\t\t\t\t\t// and also works for Claude through Antigravity API\n\t\t\t\t\t\t\tconst skipSentinel = \"skip_thought_signature_validator\"\n\t\t\t\t\t\t\tif cache.HasValidSignature(modelName, currentMessageThinkingSignature) {\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"thoughtSignature\", currentMessageThinkingSignature)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// No valid signature - use skip sentinel to bypass validation\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"thoughtSignature\", skipSentinel)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tif functionID != \"\" {\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"functionCall.id\", functionID)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"functionCall.name\", functionName)\n\t\t\t\t\t\t\tpartJSON, _ = sjson.SetRaw(partJSON, \"functionCall.args\", argsRaw)\n\t\t\t\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == \"tool_result\" {\n\t\t\t\t\t\ttoolCallID := contentResult.Get(\"tool_use_id\").String()\n\t\t\t\t\t\tif toolCallID != \"\" {\n\t\t\t\t\t\t\tfuncName, ok := toolNameByID[toolCallID]\n\t\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\t\t// Fallback: derive a semantic name from the ID by stripping\n\t\t\t\t\t\t\t\t// the last two dash-separated segments (e.g. \"get_weather-call-123\" → \"get_weather\").\n\t\t\t\t\t\t\t\t// Only use the raw ID as a last resort when the heuristic produces an empty string.\n\t\t\t\t\t\t\t\tparts := strings.Split(toolCallID, \"-\")\n\t\t\t\t\t\t\t\tif len(parts) > 2 {\n\t\t\t\t\t\t\t\t\tfuncName = strings.Join(parts[:len(parts)-2], \"-\")\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif funcName == \"\" {\n\t\t\t\t\t\t\t\t\tfuncName = toolCallID\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tlog.Warnf(\"antigravity claude request: tool_result references unknown tool_use_id=%s, derived function name=%s\", toolCallID, funcName)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfunctionResponseResult := contentResult.Get(\"content\")\n\n\t\t\t\t\t\t\tfunctionResponseJSON := `{}`\n\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"id\", toolCallID)\n\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"name\", funcName)\n\n\t\t\t\t\t\t\tresponseData := \"\"\n\t\t\t\t\t\t\tif functionResponseResult.Type == gjson.String {\n\t\t\t\t\t\t\t\tresponseData = functionResponseResult.String()\n\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"response.result\", responseData)\n\t\t\t\t\t\t\t} else if functionResponseResult.IsArray() {\n\t\t\t\t\t\t\t\tfrResults := functionResponseResult.Array()\n\t\t\t\t\t\t\t\tnonImageCount := 0\n\t\t\t\t\t\t\t\tlastNonImageRaw := \"\"\n\t\t\t\t\t\t\t\tfilteredJSON := \"[]\"\n\t\t\t\t\t\t\t\timagePartsJSON := \"[]\"\n\t\t\t\t\t\t\t\tfor _, fr := range frResults {\n\t\t\t\t\t\t\t\t\tif fr.Get(\"type\").String() == \"image\" && fr.Get(\"source.type\").String() == \"base64\" {\n\t\t\t\t\t\t\t\t\t\tinlineDataJSON := `{}`\n\t\t\t\t\t\t\t\t\t\tif mimeType := fr.Get(\"source.media_type\").String(); mimeType != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"mimeType\", mimeType)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tif data := fr.Get(\"source.data\").String(); data != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"data\", data)\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\timagePartJSON := `{}`\n\t\t\t\t\t\t\t\t\t\timagePartJSON, _ = sjson.SetRaw(imagePartJSON, \"inlineData\", inlineDataJSON)\n\t\t\t\t\t\t\t\t\t\timagePartsJSON, _ = sjson.SetRaw(imagePartsJSON, \"-1\", imagePartJSON)\n\t\t\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\tnonImageCount++\n\t\t\t\t\t\t\t\t\tlastNonImageRaw = fr.Raw\n\t\t\t\t\t\t\t\t\tfilteredJSON, _ = sjson.SetRaw(filteredJSON, \"-1\", fr.Raw)\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\tif nonImageCount == 1 {\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"response.result\", lastNonImageRaw)\n\t\t\t\t\t\t\t\t} else if nonImageCount > 1 {\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"response.result\", filteredJSON)\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"response.result\", \"\")\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t// Place image data inside functionResponse.parts as inlineData\n\t\t\t\t\t\t\t\t// instead of as sibling parts in the outer content, to avoid\n\t\t\t\t\t\t\t\t// base64 data bloating the text context.\n\t\t\t\t\t\t\t\tif gjson.Get(imagePartsJSON, \"#\").Int() > 0 {\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"parts\", imagePartsJSON)\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t} else if functionResponseResult.IsObject() {\n\t\t\t\t\t\t\t\tif functionResponseResult.Get(\"type\").String() == \"image\" && functionResponseResult.Get(\"source.type\").String() == \"base64\" {\n\t\t\t\t\t\t\t\t\tinlineDataJSON := `{}`\n\t\t\t\t\t\t\t\t\tif mimeType := functionResponseResult.Get(\"source.media_type\").String(); mimeType != \"\" {\n\t\t\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"mimeType\", mimeType)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tif data := functionResponseResult.Get(\"source.data\").String(); data != \"\" {\n\t\t\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"data\", data)\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\timagePartJSON := `{}`\n\t\t\t\t\t\t\t\t\timagePartJSON, _ = sjson.SetRaw(imagePartJSON, \"inlineData\", inlineDataJSON)\n\t\t\t\t\t\t\t\t\timagePartsJSON := \"[]\"\n\t\t\t\t\t\t\t\t\timagePartsJSON, _ = sjson.SetRaw(imagePartsJSON, \"-1\", imagePartJSON)\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"parts\", imagePartsJSON)\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"response.result\", \"\")\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"response.result\", functionResponseResult.Raw)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} else if functionResponseResult.Raw != \"\" {\n\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, \"response.result\", functionResponseResult.Raw)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// Content field is missing entirely — .Raw is empty which\n\t\t\t\t\t\t\t\t// causes sjson.SetRaw to produce invalid JSON (e.g. \"result\":}).\n\t\t\t\t\t\t\t\tfunctionResponseJSON, _ = sjson.Set(functionResponseJSON, \"response.result\", \"\")\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tpartJSON := `{}`\n\t\t\t\t\t\t\tpartJSON, _ = sjson.SetRaw(partJSON, \"functionResponse\", functionResponseJSON)\n\t\t\t\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == \"image\" {\n\t\t\t\t\t\tsourceResult := contentResult.Get(\"source\")\n\t\t\t\t\t\tif sourceResult.Get(\"type\").String() == \"base64\" {\n\t\t\t\t\t\t\tinlineDataJSON := `{}`\n\t\t\t\t\t\t\tif mimeType := sourceResult.Get(\"media_type\").String(); mimeType != \"\" {\n\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"mimeType\", mimeType)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif data := sourceResult.Get(\"data\").String(); data != \"\" {\n\t\t\t\t\t\t\t\tinlineDataJSON, _ = sjson.Set(inlineDataJSON, \"data\", data)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tpartJSON := `{}`\n\t\t\t\t\t\t\tpartJSON, _ = sjson.SetRaw(partJSON, \"inlineData\", inlineDataJSON)\n\t\t\t\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Reorder parts for 'model' role to ensure thinking block is first\n\t\t\t\tif role == \"model\" {\n\t\t\t\t\tpartsResult := gjson.Get(clientContentJSON, \"parts\")\n\t\t\t\t\tif partsResult.IsArray() {\n\t\t\t\t\t\tparts := partsResult.Array()\n\t\t\t\t\t\tvar thinkingParts []gjson.Result\n\t\t\t\t\t\tvar otherParts []gjson.Result\n\t\t\t\t\t\tfor _, part := range parts {\n\t\t\t\t\t\t\tif part.Get(\"thought\").Bool() {\n\t\t\t\t\t\t\t\tthinkingParts = append(thinkingParts, part)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\totherParts = append(otherParts, part)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif len(thinkingParts) > 0 {\n\t\t\t\t\t\t\tfirstPartIsThinking := parts[0].Get(\"thought\").Bool()\n\t\t\t\t\t\t\tif !firstPartIsThinking || len(thinkingParts) > 1 {\n\t\t\t\t\t\t\t\tvar newParts []interface{}\n\t\t\t\t\t\t\t\tfor _, p := range thinkingParts {\n\t\t\t\t\t\t\t\t\tnewParts = append(newParts, p.Value())\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tfor _, p := range otherParts {\n\t\t\t\t\t\t\t\t\tnewParts = append(newParts, p.Value())\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclientContentJSON, _ = sjson.Set(clientContentJSON, \"parts\", newParts)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Skip messages with empty parts array to avoid Gemini API error:\n\t\t\t\t// \"required oneof field 'data' must have one initialized field\"\n\t\t\t\tpartsCheck := gjson.Get(clientContentJSON, \"parts\")\n\t\t\t\tif !partsCheck.IsArray() || len(partsCheck.Array()) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tcontentsJSON, _ = sjson.SetRaw(contentsJSON, \"-1\", clientContentJSON)\n\t\t\t\thasContents = true\n\t\t\t} else if contentsResult.Type == gjson.String {\n\t\t\t\tprompt := contentsResult.String()\n\t\t\t\tpartJSON := `{}`\n\t\t\t\tif prompt != \"\" {\n\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", prompt)\n\t\t\t\t}\n\t\t\t\tclientContentJSON, _ = sjson.SetRaw(clientContentJSON, \"parts.-1\", partJSON)\n\t\t\t\tcontentsJSON, _ = sjson.SetRaw(contentsJSON, \"-1\", clientContentJSON)\n\t\t\t\thasContents = true\n\t\t\t}\n\t\t}\n\t}\n\n\t// tools\n\ttoolsJSON := \"\"\n\ttoolDeclCount := 0\n\tallowedToolKeys := []string{\"name\", \"description\", \"behavior\", \"parameters\", \"parametersJsonSchema\", \"response\", \"responseJsonSchema\"}\n\ttoolsResult := gjson.GetBytes(rawJSON, \"tools\")\n\tif toolsResult.IsArray() {\n\t\ttoolsJSON = `[{\"functionDeclarations\":[]}]`\n\t\ttoolsResults := toolsResult.Array()\n\t\tfor i := 0; i < len(toolsResults); i++ {\n\t\t\ttoolResult := toolsResults[i]\n\t\t\tinputSchemaResult := toolResult.Get(\"input_schema\")\n\t\t\tif inputSchemaResult.Exists() && inputSchemaResult.IsObject() {\n\t\t\t\t// Sanitize the input schema for Antigravity API compatibility\n\t\t\t\tinputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw)\n\t\t\t\ttool, _ := sjson.Delete(toolResult.Raw, \"input_schema\")\n\t\t\t\ttool, _ = sjson.SetRaw(tool, \"parametersJsonSchema\", inputSchema)\n\t\t\t\tfor toolKey := range gjson.Parse(tool).Map() {\n\t\t\t\t\tif util.InArray(allowedToolKeys, toolKey) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\ttool, _ = sjson.Delete(tool, toolKey)\n\t\t\t\t}\n\t\t\t\ttoolsJSON, _ = sjson.SetRaw(toolsJSON, \"0.functionDeclarations.-1\", tool)\n\t\t\t\ttoolDeclCount++\n\t\t\t}\n\t\t}\n\t}\n\n\t// Build output Gemini CLI request JSON\n\tout := `{\"model\":\"\",\"request\":{\"contents\":[]}}`\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Inject interleaved thinking hint when both tools and thinking are active\n\thasTools := toolDeclCount > 0\n\tthinkingResult := gjson.GetBytes(rawJSON, \"thinking\")\n\tthinkingType := thinkingResult.Get(\"type\").String()\n\thasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == \"enabled\" || thinkingType == \"adaptive\" || thinkingType == \"auto\")\n\tisClaudeThinking := util.IsClaudeThinkingModel(modelName)\n\n\tif hasTools && hasThinking && isClaudeThinking {\n\t\tinterleavedHint := \"Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them.\"\n\n\t\tif hasSystemInstruction {\n\t\t\t// Append hint as a new part to existing system instruction\n\t\t\thintPart := `{\"text\":\"\"}`\n\t\t\thintPart, _ = sjson.Set(hintPart, \"text\", interleavedHint)\n\t\t\tsystemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, \"parts.-1\", hintPart)\n\t\t} else {\n\t\t\t// Create new system instruction with hint\n\t\t\tsystemInstructionJSON = `{\"role\":\"user\",\"parts\":[]}`\n\t\t\thintPart := `{\"text\":\"\"}`\n\t\t\thintPart, _ = sjson.Set(hintPart, \"text\", interleavedHint)\n\t\t\tsystemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, \"parts.-1\", hintPart)\n\t\t\thasSystemInstruction = true\n\t\t}\n\t}\n\n\tif hasSystemInstruction {\n\t\tout, _ = sjson.SetRaw(out, \"request.systemInstruction\", systemInstructionJSON)\n\t}\n\tif hasContents {\n\t\tout, _ = sjson.SetRaw(out, \"request.contents\", contentsJSON)\n\t}\n\tif toolDeclCount > 0 {\n\t\tout, _ = sjson.SetRaw(out, \"request.tools\", toolsJSON)\n\t}\n\n\t// tool_choice\n\ttoolChoiceResult := gjson.GetBytes(rawJSON, \"tool_choice\")\n\tif toolChoiceResult.Exists() {\n\t\ttoolChoiceType := \"\"\n\t\ttoolChoiceName := \"\"\n\t\tif toolChoiceResult.IsObject() {\n\t\t\ttoolChoiceType = toolChoiceResult.Get(\"type\").String()\n\t\t\ttoolChoiceName = toolChoiceResult.Get(\"name\").String()\n\t\t} else if toolChoiceResult.Type == gjson.String {\n\t\t\ttoolChoiceType = toolChoiceResult.String()\n\t\t}\n\n\t\tswitch toolChoiceType {\n\t\tcase \"auto\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"AUTO\")\n\t\tcase \"none\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"NONE\")\n\t\tcase \"any\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\tcase \"tool\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\t\tif toolChoiceName != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\", []string{toolChoiceName})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled\n\tif t := gjson.GetBytes(rawJSON, \"thinking\"); enableThoughtTranslate && t.Exists() && t.IsObject() {\n\t\tswitch t.Get(\"type\").String() {\n\t\tcase \"enabled\":\n\t\t\tif b := t.Get(\"budget_tokens\"); b.Exists() && b.Type == gjson.Number {\n\t\t\t\tbudget := int(b.Int())\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t\t}\n\t\tcase \"adaptive\", \"auto\":\n\t\t\t// For adaptive thinking:\n\t\t\t// - If output_config.effort is explicitly present, pass through as thinkingLevel.\n\t\t\t// - Otherwise, treat it as \"enabled with target-model maximum\" and emit high.\n\t\t\t// ApplyThinking handles clamping to target model's supported levels.\n\t\t\teffort := \"\"\n\t\t\tif v := gjson.GetBytes(rawJSON, \"output_config.effort\"); v.Exists() && v.Type == gjson.String {\n\t\t\t\teffort = strings.ToLower(strings.TrimSpace(v.String()))\n\t\t\t}\n\t\t\tif effort != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingLevel\", effort)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingLevel\", \"high\")\n\t\t\t}\n\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t}\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"temperature\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.temperature\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_p\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.topP\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_k\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.topK\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"max_tokens\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.maxOutputTokens\", v.Num)\n\t}\n\n\toutBytes := []byte(out)\n\toutBytes = common.AttachDefaultSafetySettings(outBytes, \"request.safetySettings\")\n\n\treturn outBytes\n}\n"
  },
  {
    "path": "internal/translator/antigravity/claude/antigravity_claude_request_test.go",
    "content": "package claude\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/cache\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Hello\"}\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t\"system\": [\n\t\t\t{\"type\": \"text\", \"text\": \"You are helpful\"}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check model\n\tif gjson.Get(outputStr, \"model\").String() != \"claude-sonnet-4-5\" {\n\t\tt.Errorf(\"Expected model 'claude-sonnet-4-5', got '%s'\", gjson.Get(outputStr, \"model\").String())\n\t}\n\n\t// Check contents exist\n\tcontents := gjson.Get(outputStr, \"request.contents\")\n\tif !contents.Exists() || !contents.IsArray() {\n\t\tt.Error(\"request.contents should exist and be an array\")\n\t}\n\n\t// Check role mapping (assistant -> model)\n\tfirstContent := gjson.Get(outputStr, \"request.contents.0\")\n\tif firstContent.Get(\"role\").String() != \"user\" {\n\t\tt.Errorf(\"Expected role 'user', got '%s'\", firstContent.Get(\"role\").String())\n\t}\n\n\t// Check systemInstruction\n\tsysInstruction := gjson.Get(outputStr, \"request.systemInstruction\")\n\tif !sysInstruction.Exists() {\n\t\tt.Error(\"systemInstruction should exist\")\n\t}\n\tif sysInstruction.Get(\"parts.0.text\").String() != \"You are helpful\" {\n\t\tt.Error(\"systemInstruction text mismatch\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hi\"}]},\n\t\t\t{\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// assistant should be mapped to model\n\tsecondContent := gjson.Get(outputStr, \"request.contents.1\")\n\tif secondContent.Get(\"role\").String() != \"model\" {\n\t\tt.Errorf(\"Expected role 'model' (mapped from 'assistant'), got '%s'\", secondContent.Get(\"role\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\t// Valid signature must be at least 50 characters\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tthinkingText := \"Let me think...\"\n\n\t// Pre-cache the signature (simulating a previous response for the same thinking text)\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Test user message\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"` + thinkingText + `\", \"signature\": \"` + validSignature + `\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Answer\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tcache.CacheSignature(\"claude-sonnet-4-5-thinking\", thinkingText, validSignature)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check thinking block conversion (now in contents.1 due to user message)\n\tfirstPart := gjson.Get(outputStr, \"request.contents.1.parts.0\")\n\tif !firstPart.Get(\"thought\").Bool() {\n\t\tt.Error(\"thinking block should have thought: true\")\n\t}\n\tif firstPart.Get(\"text\").String() != thinkingText {\n\t\tt.Error(\"thinking text mismatch\")\n\t}\n\tif firstPart.Get(\"thoughtSignature\").String() != validSignature {\n\t\tt.Errorf(\"Expected thoughtSignature '%s', got '%s'\", validSignature, firstPart.Get(\"thoughtSignature\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\t// Unsigned thinking blocks should be removed entirely (not converted to text)\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Let me think...\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Answer\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Without signature, thinking block should be removed (not converted to text)\n\tparts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(parts) != 1 {\n\t\tt.Fatalf(\"Expected 1 part (thinking removed), got %d\", len(parts))\n\t}\n\n\t// Only text part should remain\n\tif parts[0].Get(\"thought\").Bool() {\n\t\tt.Error(\"Thinking block should be removed, not preserved\")\n\t}\n\tif parts[0].Get(\"text\").String() != \"Answer\" {\n\t\tt.Errorf(\"Expected text 'Answer', got '%s'\", parts[0].Get(\"text\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"test_tool\",\n\t\t\t\t\"description\": \"A test tool\",\n\t\t\t\t\"input_schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\"name\": {\"type\": \"string\"}\n\t\t\t\t\t},\n\t\t\t\t\t\"required\": [\"name\"]\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"gemini-1.5-pro\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check tools structure\n\ttools := gjson.Get(outputStr, \"request.tools\")\n\tif !tools.Exists() {\n\t\tt.Error(\"Tools should exist in output\")\n\t}\n\n\tfuncDecl := gjson.Get(outputStr, \"request.tools.0.functionDeclarations.0\")\n\tif funcDecl.Get(\"name\").String() != \"test_tool\" {\n\t\tt.Errorf(\"Expected tool name 'test_tool', got '%s'\", funcDecl.Get(\"name\").String())\n\t}\n\n\t// Check input_schema renamed to parametersJsonSchema\n\tif funcDecl.Get(\"parametersJsonSchema\").Exists() {\n\t\tt.Log(\"parametersJsonSchema exists (expected)\")\n\t}\n\tif funcDecl.Get(\"input_schema\").Exists() {\n\t\tt.Error(\"input_schema should be removed\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-flash-preview\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"hi\"}\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"json\",\n\t\t\t\t\"description\": \"A JSON tool\",\n\t\t\t\t\"input_schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {}\n\t\t\t\t}\n\t\t\t}\n\t\t],\n\t\t\"tool_choice\": {\"type\": \"tool\", \"name\": \"json\"}\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"gemini-3-flash-preview\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif got := gjson.Get(outputStr, \"request.toolConfig.functionCallingConfig.mode\").String(); got != \"ANY\" {\n\t\tt.Fatalf(\"Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'\", got)\n\t}\n\tallowed := gjson.Get(outputStr, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\").Array()\n\tif len(allowed) != 1 || allowed[0].String() != \"json\" {\n\t\tt.Fatalf(\"Expected allowedFunctionNames ['json'], got %s\", gjson.Get(outputStr, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\").Raw)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"call_123\",\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"input\": \"{\\\"location\\\": \\\"Paris\\\"}\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Now we expect only 1 part (tool_use), no dummy thinking block injected\n\tparts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(parts) != 1 {\n\t\tt.Fatalf(\"Expected 1 part (tool only, no dummy injection), got %d\", len(parts))\n\t}\n\n\t// Check function call conversion at parts[0]\n\tfuncCall := parts[0].Get(\"functionCall\")\n\tif !funcCall.Exists() {\n\t\tt.Error(\"functionCall should exist at parts[0]\")\n\t}\n\tif funcCall.Get(\"name\").String() != \"get_weather\" {\n\t\tt.Errorf(\"Expected function name 'get_weather', got '%s'\", funcCall.Get(\"name\").String())\n\t}\n\tif funcCall.Get(\"id\").String() != \"call_123\" {\n\t\tt.Errorf(\"Expected function id 'call_123', got '%s'\", funcCall.Get(\"id\").String())\n\t}\n\t// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)\n\texpectedSig := \"skip_thought_signature_validator\"\n\tactualSig := parts[0].Get(\"thoughtSignature\").String()\n\tif actualSig != expectedSig {\n\t\tt.Errorf(\"Expected thoughtSignature '%s', got '%s'\", expectedSig, actualSig)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tthinkingText := \"Let me think...\"\n\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Test user message\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"` + thinkingText + `\", \"signature\": \"` + validSignature + `\"},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"call_123\",\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"input\": \"{\\\"location\\\": \\\"Paris\\\"}\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tcache.CacheSignature(\"claude-sonnet-4-5-thinking\", thinkingText, validSignature)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check function call has the signature from the preceding thinking block (now in contents.1)\n\tpart := gjson.Get(outputStr, \"request.contents.1.parts.1\")\n\tif part.Get(\"functionCall.name\").String() != \"get_weather\" {\n\t\tt.Errorf(\"Expected functionCall, got %s\", part.Raw)\n\t}\n\tif part.Get(\"thoughtSignature\").String() != validSignature {\n\t\tt.Errorf(\"Expected thoughtSignature '%s' on tool_use, got '%s'\", validSignature, part.Get(\"thoughtSignature\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\t// Case: text block followed by thinking block -> should be reordered to thinking first\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tthinkingText := \"Planning...\"\n\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Test user message\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Here is the plan.\"},\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"` + thinkingText + `\", \"signature\": \"` + validSignature + `\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tcache.CacheSignature(\"claude-sonnet-4-5-thinking\", thinkingText, validSignature)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Verify order: Thinking block MUST be first (now in contents.1 due to user message)\n\tparts := gjson.Get(outputStr, \"request.contents.1.parts\").Array()\n\tif len(parts) != 2 {\n\t\tt.Fatalf(\"Expected 2 parts, got %d\", len(parts))\n\t}\n\n\tif !parts[0].Get(\"thought\").Bool() {\n\t\tt.Error(\"First part should be thinking block after reordering\")\n\t}\n\tif parts[1].Get(\"text\").String() != \"Here is the plan.\" {\n\t\tt.Error(\"Second part should be text block\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"get_weather-call-123\",\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"input\": {\"location\": \"Paris\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"get_weather-call-123\",\n\t\t\t\t\t\t\"content\": \"22C sunny\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check function response conversion\n\tfuncResp := gjson.Get(outputStr, \"request.contents.1.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Error(\"functionResponse should exist\")\n\t}\n\tif funcResp.Get(\"id\").String() != \"get_weather-call-123\" {\n\t\tt.Errorf(\"Expected function id, got '%s'\", funcResp.Get(\"id\").String())\n\t}\n\tif funcResp.Get(\"name\").String() != \"get_weather\" {\n\t\tt.Errorf(\"Expected function name 'get_weather', got '%s'\", funcResp.Get(\"name\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultName_TouluFormat(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-haiku-4-5-20251001\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"toolu_tool-48fca351f12844eabf49dad8b63886d2\",\n\t\t\t\t\t\t\"name\": \"Glob\",\n\t\t\t\t\t\t\"input\": {\"pattern\": \"**/*.py\"}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"toolu_tool-cf2d061f75f845c49aacc18ee75ee708\",\n\t\t\t\t\t\t\"name\": \"Bash\",\n\t\t\t\t\t\t\"input\": {\"command\": \"ls\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"toolu_tool-48fca351f12844eabf49dad8b63886d2\",\n\t\t\t\t\t\t\"content\": \"file1.py\\nfile2.py\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"toolu_tool-cf2d061f75f845c49aacc18ee75ee708\",\n\t\t\t\t\t\t\"content\": \"total 10\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-haiku-4-5-20251001\", inputJSON, false)\n\toutputStr := string(output)\n\n\tfuncResp0 := gjson.Get(outputStr, \"request.contents.1.parts.0.functionResponse\")\n\tif !funcResp0.Exists() {\n\t\tt.Fatal(\"first functionResponse should exist\")\n\t}\n\tif got := funcResp0.Get(\"name\").String(); got != \"Glob\" {\n\t\tt.Errorf(\"Expected name 'Glob' for toolu_ format, got '%s'\", got)\n\t}\n\n\tfuncResp1 := gjson.Get(outputStr, \"request.contents.1.parts.1.functionResponse\")\n\tif !funcResp1.Exists() {\n\t\tt.Fatal(\"second functionResponse should exist\")\n\t}\n\tif got := funcResp1.Get(\"name\").String(); got != \"Bash\" {\n\t\tt.Errorf(\"Expected name 'Bash' for toolu_ format, got '%s'\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultName_CustomFormat(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-haiku-4-5-20251001\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"Read-1773420180464065165-1327\",\n\t\t\t\t\t\t\"name\": \"Read\",\n\t\t\t\t\t\t\"input\": {\"file_path\": \"/tmp/test.py\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"Read-1773420180464065165-1327\",\n\t\t\t\t\t\t\"content\": \"file content here\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-haiku-4-5-20251001\", inputJSON, false)\n\toutputStr := string(output)\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.1.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\tif got := funcResp.Get(\"name\").String(); got != \"Read\" {\n\t\tt.Errorf(\"Expected name 'Read', got '%s'\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_Heuristic(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"get_weather-call-123\",\n\t\t\t\t\t\t\"content\": \"22C sunny\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\tif got := funcResp.Get(\"name\").String(); got != \"get_weather\" {\n\t\tt.Errorf(\"Expected heuristic-derived name 'get_weather', got '%s'\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_RawID(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"toolu_tool-48fca351f12844eabf49dad8b63886d2\",\n\t\t\t\t\t\t\"content\": \"result data\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\tgot := funcResp.Get(\"name\").String()\n\tif got == \"\" {\n\t\tt.Error(\"functionResponse.name must not be empty\")\n\t}\n\tif got != \"toolu_tool-48fca351f12844eabf49dad8b63886d2\" {\n\t\tt.Errorf(\"Expected raw ID as last-resort name, got '%s'\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {\n\t// Note: This test requires the model to be registered in the registry\n\t// with Thinking metadata. If the registry is not populated in test environment,\n\t// thinkingConfig won't be added. We'll test the basic structure only.\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [],\n\t\t\"thinking\": {\n\t\t\t\"type\": \"enabled\",\n\t\t\t\"budget_tokens\": 8000\n\t\t}\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check thinking config conversion (only if model supports thinking in registry)\n\tthinkingConfig := gjson.Get(outputStr, \"request.generationConfig.thinkingConfig\")\n\tif thinkingConfig.Exists() {\n\t\tif thinkingConfig.Get(\"thinkingBudget\").Int() != 8000 {\n\t\t\tt.Errorf(\"Expected thinkingBudget 8000, got %d\", thinkingConfig.Get(\"thinkingBudget\").Int())\n\t\t}\n\t\tif !thinkingConfig.Get(\"includeThoughts\").Bool() {\n\t\t\tt.Error(\"includeThoughts should be true\")\n\t\t}\n\t} else {\n\t\tt.Log(\"thinkingConfig not present - model may not be registered in test registry\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\"type\": \"base64\",\n\t\t\t\t\t\t\t\"media_type\": \"image/png\",\n\t\t\t\t\t\t\t\"data\": \"iVBORw0KGgoAAAANSUhEUg==\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check inline data conversion\n\tinlineData := gjson.Get(outputStr, \"request.contents.0.parts.0.inlineData\")\n\tif !inlineData.Exists() {\n\t\tt.Error(\"inlineData should exist\")\n\t}\n\tif inlineData.Get(\"mimeType\").String() != \"image/png\" {\n\t\tt.Error(\"mimeType mismatch\")\n\t}\n\tif !strings.Contains(inlineData.Get(\"data\").String(), \"iVBORw0KGgo\") {\n\t\tt.Error(\"data mismatch\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [],\n\t\t\"temperature\": 0.7,\n\t\t\"top_p\": 0.9,\n\t\t\"top_k\": 40,\n\t\t\"max_tokens\": 2000\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tgenConfig := gjson.Get(outputStr, \"request.generationConfig\")\n\tif genConfig.Get(\"temperature\").Float() != 0.7 {\n\t\tt.Errorf(\"Expected temperature 0.7, got %f\", genConfig.Get(\"temperature\").Float())\n\t}\n\tif genConfig.Get(\"topP\").Float() != 0.9 {\n\t\tt.Errorf(\"Expected topP 0.9, got %f\", genConfig.Get(\"topP\").Float())\n\t}\n\tif genConfig.Get(\"topK\").Float() != 40 {\n\t\tt.Errorf(\"Expected topK 40, got %f\", genConfig.Get(\"topK\").Float())\n\t}\n\tif genConfig.Get(\"maxOutputTokens\").Float() != 2000 {\n\t\tt.Errorf(\"Expected maxOutputTokens 2000, got %f\", genConfig.Get(\"maxOutputTokens\").Float())\n\t}\n}\n\n// ============================================================================\n// Trailing Unsigned Thinking Block Removal\n// ============================================================================\n\nfunc TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) {\n\t// Last assistant message ends with unsigned thinking block - should be removed\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Here is my answer\"},\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"I should think more...\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// The last part of the last assistant message should NOT be a thinking block\n\tlastMessageParts := gjson.Get(outputStr, \"request.contents.1.parts\")\n\tif !lastMessageParts.IsArray() {\n\t\tt.Fatal(\"Last message should have parts array\")\n\t}\n\tparts := lastMessageParts.Array()\n\tif len(parts) == 0 {\n\t\tt.Fatal(\"Last message should have at least one part\")\n\t}\n\n\t// The unsigned thinking should be removed, leaving only the text\n\tlastPart := parts[len(parts)-1]\n\tif lastPart.Get(\"thought\").Bool() {\n\t\tt.Error(\"Trailing unsigned thinking block should be removed\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\t// Last assistant message ends with signed thinking block - should be kept\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tthinkingText := \"Valid thinking...\"\n\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Here is my answer\"},\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"` + thinkingText + `\", \"signature\": \"` + validSignature + `\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tcache.CacheSignature(\"claude-sonnet-4-5-thinking\", thinkingText, validSignature)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// The signed thinking block should be preserved\n\tlastMessageParts := gjson.Get(outputStr, \"request.contents.1.parts\")\n\tparts := lastMessageParts.Array()\n\tif len(parts) < 2 {\n\t\tt.Error(\"Signed thinking block should be preserved\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {\n\t// Middle message has unsigned thinking - should be removed entirely\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Middle thinking...\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Answer\"}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Follow up\"}]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Unsigned thinking should be removed entirely\n\tparts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(parts) != 1 {\n\t\tt.Fatalf(\"Expected 1 part (thinking removed), got %d\", len(parts))\n\t}\n\n\t// Only text part should remain\n\tif parts[0].Get(\"thought\").Bool() {\n\t\tt.Error(\"Thinking block should be removed, not preserved\")\n\t}\n\tif parts[0].Get(\"text\").String() != \"Answer\" {\n\t\tt.Errorf(\"Expected text 'Answer', got '%s'\", parts[0].Get(\"text\").String())\n\t}\n}\n\n// ============================================================================\n// Tool + Thinking System Hint Injection\n// ============================================================================\n\nfunc TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) {\n\t// When both tools and thinking are enabled, hint should be injected into system instruction\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]}],\n\t\t\"system\": [{\"type\": \"text\", \"text\": \"You are helpful.\"}],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\"input_schema\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n\t\t\t}\n\t\t],\n\t\t\"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 8000}\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// System instruction should contain the interleaved thinking hint\n\tsysInstruction := gjson.Get(outputStr, \"request.systemInstruction\")\n\tif !sysInstruction.Exists() {\n\t\tt.Fatal(\"systemInstruction should exist\")\n\t}\n\n\t// Check if hint is appended\n\tsysText := sysInstruction.Get(\"parts\").Array()\n\tfound := false\n\tfor _, part := range sysText {\n\t\tif strings.Contains(part.Get(\"text\").String(), \"Interleaved thinking is enabled\") {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Errorf(\"Interleaved thinking hint should be injected when tools and thinking are both active, got: %v\", sysInstruction.Raw)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {\n\t// When only tools are present (no thinking), hint should NOT be injected\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]}],\n\t\t\"system\": [{\"type\": \"text\", \"text\": \"You are helpful.\"}],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\"input_schema\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// System instruction should NOT contain the hint\n\tsysInstruction := gjson.Get(outputStr, \"request.systemInstruction\")\n\tif sysInstruction.Exists() {\n\t\tfor _, part := range sysInstruction.Get(\"parts\").Array() {\n\t\t\tif strings.Contains(part.Get(\"text\").String(), \"Interleaved thinking is enabled\") {\n\t\t\t\tt.Error(\"Hint should NOT be injected when only tools are present (no thinking)\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {\n\t// When only thinking is enabled (no tools), hint should NOT be injected\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]}],\n\t\t\"system\": [{\"type\": \"text\", \"text\": \"You are helpful.\"}],\n\t\t\"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 8000}\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// System instruction should NOT contain the hint (no tools)\n\tsysInstruction := gjson.Get(outputStr, \"request.systemInstruction\")\n\tif sysInstruction.Exists() {\n\t\tfor _, part := range sysInstruction.Get(\"parts\").Array() {\n\t\t\tif strings.Contains(part.Get(\"text\").String(), \"Interleaved thinking is enabled\") {\n\t\t\t\tt.Error(\"Hint should NOT be injected when only thinking is present (no tools)\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultNoContent(t *testing.T) {\n\t// Bug repro: tool_result with no content field produces invalid JSON\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-opus-4-6-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"MyTool-123-456\",\n\t\t\t\t\t\t\"name\": \"MyTool\",\n\t\t\t\t\t\t\"input\": {\"key\": \"value\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"MyTool-123-456\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-opus-4-6-thinking\", inputJSON, true)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Errorf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\t// Verify the functionResponse has a valid result value\n\tfr := gjson.Get(outputStr, \"request.contents.1.parts.0.functionResponse.response.result\")\n\tif !fr.Exists() {\n\t\tt.Error(\"functionResponse.response.result should exist\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) {\n\t// Bug repro: tool_result with null content produces invalid JSON\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-opus-4-6-thinking\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"MyTool-123-456\",\n\t\t\t\t\t\t\"name\": \"MyTool\",\n\t\t\t\t\t\t\"input\": {\"key\": \"value\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"MyTool-123-456\",\n\t\t\t\t\t\t\"content\": null\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-opus-4-6-thinking\", inputJSON, true)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Errorf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {\n\t// tool_result with array content containing text + image should place\n\t// image data inside functionResponse.parts as inlineData, not as a\n\t// sibling part in the outer content (to avoid base64 context bloat).\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"Read-123-456\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"text\": \"File content here\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"base64\",\n\t\t\t\t\t\t\t\t\t\"media_type\": \"image/png\",\n\t\t\t\t\t\t\t\t\t\"data\": \"iVBORw0KGgoAAAANSUhEUg==\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\t// Image should be inside functionResponse.parts, not as outer sibling part\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// Text content should be in response.result\n\tresultText := funcResp.Get(\"response.result.text\").String()\n\tif resultText != \"File content here\" {\n\t\tt.Errorf(\"Expected response.result.text = 'File content here', got '%s'\", resultText)\n\t}\n\n\t// Image should be in functionResponse.parts[0].inlineData\n\tinlineData := funcResp.Get(\"parts.0.inlineData\")\n\tif !inlineData.Exists() {\n\t\tt.Fatal(\"functionResponse.parts[0].inlineData should exist\")\n\t}\n\tif inlineData.Get(\"mimeType\").String() != \"image/png\" {\n\t\tt.Errorf(\"Expected mimeType 'image/png', got '%s'\", inlineData.Get(\"mimeType\").String())\n\t}\n\tif !strings.Contains(inlineData.Get(\"data\").String(), \"iVBORw0KGgo\") {\n\t\tt.Error(\"data mismatch\")\n\t}\n\n\t// Image should NOT be in outer parts (only functionResponse part should exist)\n\touterParts := gjson.Get(outputStr, \"request.contents.0.parts\")\n\tif outerParts.IsArray() && len(outerParts.Array()) > 1 {\n\t\tt.Errorf(\"Expected only 1 outer part (functionResponse), got %d\", len(outerParts.Array()))\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.T) {\n\t// tool_result with single image object as content should place\n\t// image data inside functionResponse.parts, not as outer sibling part.\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"Read-789-012\",\n\t\t\t\t\t\t\"content\": {\n\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\t\"type\": \"base64\",\n\t\t\t\t\t\t\t\t\"media_type\": \"image/jpeg\",\n\t\t\t\t\t\t\t\t\"data\": \"/9j/4AAQSkZJRgABAQ==\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// response.result should be empty (image only)\n\tif funcResp.Get(\"response.result\").String() != \"\" {\n\t\tt.Errorf(\"Expected empty response.result for image-only content, got '%s'\", funcResp.Get(\"response.result\").String())\n\t}\n\n\t// Image should be in functionResponse.parts[0].inlineData\n\tinlineData := funcResp.Get(\"parts.0.inlineData\")\n\tif !inlineData.Exists() {\n\t\tt.Fatal(\"functionResponse.parts[0].inlineData should exist\")\n\t}\n\tif inlineData.Get(\"mimeType\").String() != \"image/jpeg\" {\n\t\tt.Errorf(\"Expected mimeType 'image/jpeg', got '%s'\", inlineData.Get(\"mimeType\").String())\n\t}\n\n\t// Image should NOT be in outer parts\n\touterParts := gjson.Get(outputStr, \"request.contents.0.parts\")\n\tif outerParts.IsArray() && len(outerParts.Array()) > 1 {\n\t\tt.Errorf(\"Expected only 1 outer part, got %d\", len(outerParts.Array()))\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(t *testing.T) {\n\t// tool_result with array content: 2 text items + 2 images\n\t// All images go into functionResponse.parts, texts into response.result array\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"Multi-001\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"First text\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": \"AAAA\"}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Second text\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"media_type\": \"image/jpeg\", \"data\": \"BBBB\"}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// Multiple text items => response.result is an array\n\tresultArr := funcResp.Get(\"response.result\")\n\tif !resultArr.IsArray() {\n\t\tt.Fatalf(\"Expected response.result to be an array, got: %s\", resultArr.Raw)\n\t}\n\tresults := resultArr.Array()\n\tif len(results) != 2 {\n\t\tt.Fatalf(\"Expected 2 result items, got %d\", len(results))\n\t}\n\n\t// Both images should be in functionResponse.parts\n\timgParts := funcResp.Get(\"parts\").Array()\n\tif len(imgParts) != 2 {\n\t\tt.Fatalf(\"Expected 2 image parts in functionResponse.parts, got %d\", len(imgParts))\n\t}\n\tif imgParts[0].Get(\"inlineData.mimeType\").String() != \"image/png\" {\n\t\tt.Errorf(\"Expected first image mimeType 'image/png', got '%s'\", imgParts[0].Get(\"inlineData.mimeType\").String())\n\t}\n\tif imgParts[0].Get(\"inlineData.data\").String() != \"AAAA\" {\n\t\tt.Errorf(\"Expected first image data 'AAAA', got '%s'\", imgParts[0].Get(\"inlineData.data\").String())\n\t}\n\tif imgParts[1].Get(\"inlineData.mimeType\").String() != \"image/jpeg\" {\n\t\tt.Errorf(\"Expected second image mimeType 'image/jpeg', got '%s'\", imgParts[1].Get(\"inlineData.mimeType\").String())\n\t}\n\tif imgParts[1].Get(\"inlineData.data\").String() != \"BBBB\" {\n\t\tt.Errorf(\"Expected second image data 'BBBB', got '%s'\", imgParts[1].Get(\"inlineData.data\").String())\n\t}\n\n\t// Only 1 outer part (the functionResponse itself)\n\touterParts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(outerParts) != 1 {\n\t\tt.Errorf(\"Expected 1 outer part, got %d\", len(outerParts))\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *testing.T) {\n\t// tool_result with only images (no text) — response.result should be empty string\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"ImgOnly-001\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": \"PNG1\"}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"media_type\": \"image/gif\", \"data\": \"GIF1\"}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// No text => response.result should be empty string\n\tif funcResp.Get(\"response.result\").String() != \"\" {\n\t\tt.Errorf(\"Expected empty response.result, got '%s'\", funcResp.Get(\"response.result\").String())\n\t}\n\n\t// Both images in functionResponse.parts\n\timgParts := funcResp.Get(\"parts\").Array()\n\tif len(imgParts) != 2 {\n\t\tt.Fatalf(\"Expected 2 image parts, got %d\", len(imgParts))\n\t}\n\tif imgParts[0].Get(\"inlineData.mimeType\").String() != \"image/png\" {\n\t\tt.Error(\"first image mimeType mismatch\")\n\t}\n\tif imgParts[1].Get(\"inlineData.mimeType\").String() != \"image/gif\" {\n\t\tt.Error(\"second image mimeType mismatch\")\n\t}\n\n\t// Only 1 outer part\n\touterParts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(outerParts) != 1 {\n\t\tt.Errorf(\"Expected 1 outer part, got %d\", len(outerParts))\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T) {\n\t// image with source.type != \"base64\" should be treated as non-image (falls through)\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"NotB64-001\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"some output\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"url\", \"url\": \"https://example.com/img.png\"}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// Non-base64 image is treated as non-image, so it goes into the filtered results\n\t// along with the text item. Since there are 2 non-image items, result is array.\n\tresultArr := funcResp.Get(\"response.result\")\n\tif !resultArr.IsArray() {\n\t\tt.Fatalf(\"Expected response.result to be an array (2 non-image items), got: %s\", resultArr.Raw)\n\t}\n\tresults := resultArr.Array()\n\tif len(results) != 2 {\n\t\tt.Fatalf(\"Expected 2 result items, got %d\", len(results))\n\t}\n\n\t// No functionResponse.parts (no base64 images collected)\n\tif funcResp.Get(\"parts\").Exists() {\n\t\tt.Error(\"functionResponse.parts should NOT exist when no base64 images\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing.T) {\n\t// image with source.type=base64 but missing data field\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"NoData-001\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"output\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"media_type\": \"image/png\"}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// The image is still classified as base64 image (type check passes),\n\t// but data field is missing => inlineData has mimeType but no data\n\timgParts := funcResp.Get(\"parts\").Array()\n\tif len(imgParts) != 1 {\n\t\tt.Fatalf(\"Expected 1 image part, got %d\", len(imgParts))\n\t}\n\tif imgParts[0].Get(\"inlineData.mimeType\").String() != \"image/png\" {\n\t\tt.Error(\"mimeType should still be set\")\n\t}\n\tif imgParts[0].Get(\"inlineData.data\").Exists() {\n\t\tt.Error(\"data should not exist when source.data is missing\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *testing.T) {\n\t// image with source.type=base64 but missing media_type field\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-3-5-sonnet-20240620\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"NoMime-001\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"output\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\"type\": \"base64\", \"data\": \"AAAA\"}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif !gjson.Valid(outputStr) {\n\t\tt.Fatalf(\"Result is not valid JSON:\\n%s\", outputStr)\n\t}\n\n\tfuncResp := gjson.Get(outputStr, \"request.contents.0.parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist\")\n\t}\n\n\t// The image is still classified as base64 image,\n\t// but media_type is missing => inlineData has data but no mimeType\n\timgParts := funcResp.Get(\"parts\").Array()\n\tif len(imgParts) != 1 {\n\t\tt.Fatalf(\"Expected 1 image part, got %d\", len(imgParts))\n\t}\n\tif imgParts[0].Get(\"inlineData.mimeType\").Exists() {\n\t\tt.Error(\"mimeType should not exist when media_type is missing\")\n\t}\n\tif imgParts[0].Get(\"inlineData.data\").String() != \"AAAA\" {\n\t\tt.Error(\"data should still be set\")\n\t}\n}\n\nfunc TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {\n\t// When tools + thinking but no system instruction, should create one with hint\n\tinputJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]}],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\"input_schema\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n\t\t\t}\n\t\t],\n\t\t\"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 8000}\n\t}`)\n\n\toutput := ConvertClaudeRequestToAntigravity(\"claude-sonnet-4-5-thinking\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// System instruction should be created with hint\n\tsysInstruction := gjson.Get(outputStr, \"request.systemInstruction\")\n\tif !sysInstruction.Exists() {\n\t\tt.Fatal(\"systemInstruction should be created when tools + thinking are active\")\n\t}\n\n\tsysText := sysInstruction.Get(\"parts\").Array()\n\tfound := false\n\tfor _, part := range sysText {\n\t\tif strings.Contains(part.Get(\"text\").String(), \"Interleaved thinking is enabled\") {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Errorf(\"Interleaved thinking hint should be in created systemInstruction, got: %v\", sysInstruction.Raw)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/antigravity/claude/antigravity_claude_response.go",
    "content": "// Package claude provides response translation functionality for Claude Code API compatibility.\n// This package handles the conversion of backend client responses into Claude Code-compatible\n// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages\n// different response types including text content, thinking processes, and function calls.\n// The translation ensures proper sequencing of SSE events and maintains state across\n// multiple response chunks to provide a seamless streaming experience.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/cache\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Params holds parameters for response conversion and maintains state across streaming chunks.\n// This structure tracks the current state of the response translation process to ensure\n// proper sequencing of SSE events and transitions between different content types.\ntype Params struct {\n\tHasFirstResponse     bool   // Indicates if the initial message_start event has been sent\n\tResponseType         int    // Current response type: 0=none, 1=content, 2=thinking, 3=function\n\tResponseIndex        int    // Index counter for content blocks in the streaming response\n\tHasFinishReason      bool   // Tracks whether a finish reason has been observed\n\tFinishReason         string // The finish reason string returned by the provider\n\tHasUsageMetadata     bool   // Tracks whether usage metadata has been observed\n\tPromptTokenCount     int64  // Cached prompt token count from usage metadata\n\tCandidatesTokenCount int64  // Cached candidate token count from usage metadata\n\tThoughtsTokenCount   int64  // Cached thinking token count from usage metadata\n\tTotalTokenCount      int64  // Cached total token count from usage metadata\n\tCachedTokenCount     int64  // Cached content token count (indicates prompt caching)\n\tHasSentFinalEvents   bool   // Indicates if final content/message events have been sent\n\tHasToolUse           bool   // Indicates if tool use was observed in the stream\n\tHasContent           bool   // Tracks whether any content (text, thinking, or tool use) has been output\n\n\t// Signature caching support\n\tCurrentThinkingText strings.Builder // Accumulates thinking text for signature caching\n}\n\n// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.\nvar toolUseIDCounter uint64\n\n// ConvertAntigravityResponseToClaude performs sophisticated streaming response format conversion.\n// This function implements a complex state machine that translates backend client responses\n// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types\n// and handles state transitions between content blocks, thinking processes, and function calls.\n//\n// Response type states: 0=none, 1=content, 2=thinking, 3=function\n// The function maintains state across multiple calls to ensure proper SSE event sequencing.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Claude Code-compatible JSON response\nfunc ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &Params{\n\t\t\tHasFirstResponse: false,\n\t\t\tResponseType:     0,\n\t\t\tResponseIndex:    0,\n\t\t}\n\t}\n\tmodelName := gjson.GetBytes(requestRawJSON, \"model\").String()\n\n\tparams := (*param).(*Params)\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\toutput := \"\"\n\t\t// Only send final events if we have actually output content\n\t\tif params.HasContent {\n\t\t\tappendFinalEvents(params, &output, true)\n\t\t\treturn []string{\n\t\t\t\toutput + \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\\n\",\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\t}\n\n\toutput := \"\"\n\n\t// Initialize the streaming session with a message_start event\n\t// This is only sent for the very first response chunk to establish the streaming session\n\tif !params.HasFirstResponse {\n\t\toutput = \"event: message_start\\n\"\n\n\t\t// Create the initial message structure with default values according to Claude Code API specification\n\t\t// This follows the Claude Code API specification for streaming message initialization\n\t\tmessageStartTemplate := `{\"type\": \"message_start\", \"message\": {\"id\": \"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [], \"model\": \"claude-3-5-sonnet-20241022\", \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 0, \"output_tokens\": 0}}}`\n\n\t\t// Use cpaUsageMetadata within the message_start event for Claude.\n\t\tif promptTokenCount := gjson.GetBytes(rawJSON, \"response.cpaUsageMetadata.promptTokenCount\"); promptTokenCount.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.usage.input_tokens\", promptTokenCount.Int())\n\t\t}\n\t\tif candidatesTokenCount := gjson.GetBytes(rawJSON, \"response.cpaUsageMetadata.candidatesTokenCount\"); candidatesTokenCount.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.usage.output_tokens\", candidatesTokenCount.Int())\n\t\t}\n\n\t\t// Override default values with actual response metadata if available from the Gemini CLI response\n\t\tif modelVersionResult := gjson.GetBytes(rawJSON, \"response.modelVersion\"); modelVersionResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.model\", modelVersionResult.String())\n\t\t}\n\t\tif responseIDResult := gjson.GetBytes(rawJSON, \"response.responseId\"); responseIDResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.id\", responseIDResult.String())\n\t\t}\n\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", messageStartTemplate)\n\n\t\tparams.HasFirstResponse = true\n\t}\n\n\t// Process the response parts array from the backend client\n\t// Each part can contain text content, thinking content, or function calls\n\tpartsResult := gjson.GetBytes(rawJSON, \"response.candidates.0.content.parts\")\n\tif partsResult.IsArray() {\n\t\tpartResults := partsResult.Array()\n\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\tpartResult := partResults[i]\n\n\t\t\t// Extract the different types of content from each part\n\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\n\t\t\t// Handle text content (both regular content and thinking)\n\t\t\tif partTextResult.Exists() {\n\t\t\t\t// Process thinking content (internal reasoning)\n\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\tif thoughtSignature := partResult.Get(\"thoughtSignature\"); thoughtSignature.Exists() && thoughtSignature.String() != \"\" {\n\t\t\t\t\t\t// log.Debug(\"Branch: signature_delta\")\n\n\t\t\t\t\t\tif params.CurrentThinkingText.Len() > 0 {\n\t\t\t\t\t\t\tcache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())\n\t\t\t\t\t\t\t// log.Debugf(\"Cached signature for thinking block (textLen=%d)\", params.CurrentThinkingText.Len())\n\t\t\t\t\t\t\tparams.CurrentThinkingText.Reset()\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"\"}}`, params.ResponseIndex), \"delta.signature\", fmt.Sprintf(\"%s#%s\", cache.GetModelGroup(modelName), thoughtSignature.String()))\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\tparams.HasContent = true\n\t\t\t\t\t} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state\n\t\t\t\t\t\tparams.CurrentThinkingText.WriteString(partTextResult.String())\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, params.ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\tparams.HasContent = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition from another state to thinking\n\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\tif params.ResponseType != 0 {\n\t\t\t\t\t\t\tif params.ResponseType == 2 {\n\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, params.ResponseIndex)\n\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, params.ResponseIndex)\n\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\tparams.ResponseIndex++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Start a new thinking content block\n\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}`, params.ResponseIndex)\n\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, params.ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\tparams.ResponseType = 2 // Set state to thinking\n\t\t\t\t\t\tparams.HasContent = true\n\t\t\t\t\t\t// Start accumulating thinking text for signature caching\n\t\t\t\t\t\tparams.CurrentThinkingText.Reset()\n\t\t\t\t\t\tparams.CurrentThinkingText.WriteString(partTextResult.String())\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfinishReasonResult := gjson.GetBytes(rawJSON, \"response.candidates.0.finishReason\")\n\t\t\t\t\tif partTextResult.String() != \"\" || !finishReasonResult.Exists() {\n\t\t\t\t\t\t// Process regular text content (user-visible output)\n\t\t\t\t\t\t// Continue existing text block if already in content state\n\t\t\t\t\t\tif params.ResponseType == 1 {\n\t\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, params.ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t\tparams.HasContent = true\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Transition from another state to text content\n\t\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\t\tif params.ResponseType != 0 {\n\t\t\t\t\t\t\t\tif params.ResponseType == 2 {\n\t\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, params.ResponseIndex)\n\t\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, params.ResponseIndex)\n\t\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t\tparams.ResponseIndex++\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif partTextResult.String() != \"\" {\n\t\t\t\t\t\t\t\t// Start a new text content block\n\t\t\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}`, params.ResponseIndex)\n\t\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, params.ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t\t\tparams.ResponseType = 1 // Set state to content\n\t\t\t\t\t\t\t\tparams.HasContent = true\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t// Handle function/tool calls from the AI model\n\t\t\t\t// This processes tool usage requests and formats them for Claude Code API compatibility\n\t\t\t\tparams.HasToolUse = true\n\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\n\t\t\t\t// Handle state transitions when switching to function calls\n\t\t\t\t// Close any existing function call block first\n\t\t\t\tif params.ResponseType == 3 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, params.ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\tparams.ResponseIndex++\n\t\t\t\t\tparams.ResponseType = 0\n\t\t\t\t}\n\n\t\t\t\t// Special handling for thinking state transition\n\t\t\t\tif params.ResponseType == 2 {\n\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, params.ResponseIndex)\n\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t}\n\n\t\t\t\t// Close any other existing content block\n\t\t\t\tif params.ResponseType != 0 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, params.ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\tparams.ResponseIndex++\n\t\t\t\t}\n\n\t\t\t\t// Start a new tool use content block\n\t\t\t\t// This creates the structure for a function call in Claude Code format\n\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\n\t\t\t\t// Create the tool use block with unique ID and function details\n\t\t\t\tdata := fmt.Sprintf(`{\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}}`, params.ResponseIndex)\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.id\", util.SanitizeClaudeToolID(fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1))))\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.name\", fcName)\n\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\n\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\tdata, _ = sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`, params.ResponseIndex), \"delta.partial_json\", fcArgsResult.Raw)\n\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t}\n\t\t\t\tparams.ResponseType = 3\n\t\t\t\tparams.HasContent = true\n\t\t\t}\n\t\t}\n\t}\n\n\tif finishReasonResult := gjson.GetBytes(rawJSON, \"response.candidates.0.finishReason\"); finishReasonResult.Exists() {\n\t\tparams.HasFinishReason = true\n\t\tparams.FinishReason = finishReasonResult.String()\n\t}\n\n\tif usageResult := gjson.GetBytes(rawJSON, \"response.usageMetadata\"); usageResult.Exists() {\n\t\tparams.HasUsageMetadata = true\n\t\tparams.CachedTokenCount = usageResult.Get(\"cachedContentTokenCount\").Int()\n\t\tparams.PromptTokenCount = usageResult.Get(\"promptTokenCount\").Int() - params.CachedTokenCount\n\t\tparams.CandidatesTokenCount = usageResult.Get(\"candidatesTokenCount\").Int()\n\t\tparams.ThoughtsTokenCount = usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\tparams.TotalTokenCount = usageResult.Get(\"totalTokenCount\").Int()\n\t\tif params.CandidatesTokenCount == 0 && params.TotalTokenCount > 0 {\n\t\t\tparams.CandidatesTokenCount = params.TotalTokenCount - params.PromptTokenCount - params.ThoughtsTokenCount\n\t\t\tif params.CandidatesTokenCount < 0 {\n\t\t\t\tparams.CandidatesTokenCount = 0\n\t\t\t}\n\t\t}\n\t}\n\n\tif params.HasUsageMetadata && params.HasFinishReason {\n\t\tappendFinalEvents(params, &output, false)\n\t}\n\n\treturn []string{output}\n}\n\nfunc appendFinalEvents(params *Params, output *string, force bool) {\n\tif params.HasSentFinalEvents {\n\t\treturn\n\t}\n\n\tif !params.HasUsageMetadata && !force {\n\t\treturn\n\t}\n\n\t// Only send final events if we have actually output content\n\tif !params.HasContent {\n\t\treturn\n\t}\n\n\tif params.ResponseType != 0 {\n\t\t*output = *output + \"event: content_block_stop\\n\"\n\t\t*output = *output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, params.ResponseIndex)\n\t\t*output = *output + \"\\n\\n\\n\"\n\t\tparams.ResponseType = 0\n\t}\n\n\tstopReason := resolveStopReason(params)\n\tusageOutputTokens := params.CandidatesTokenCount + params.ThoughtsTokenCount\n\tif usageOutputTokens == 0 && params.TotalTokenCount > 0 {\n\t\tusageOutputTokens = params.TotalTokenCount - params.PromptTokenCount\n\t\tif usageOutputTokens < 0 {\n\t\t\tusageOutputTokens = 0\n\t\t}\n\t}\n\n\t*output = *output + \"event: message_delta\\n\"\n\t*output = *output + \"data: \"\n\tdelta := fmt.Sprintf(`{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"%s\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":%d,\"output_tokens\":%d}}`, stopReason, params.PromptTokenCount, usageOutputTokens)\n\t// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)\n\tif params.CachedTokenCount > 0 {\n\t\tvar err error\n\t\tdelta, err = sjson.Set(delta, \"usage.cache_read_input_tokens\", params.CachedTokenCount)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"antigravity claude response: failed to set cache_read_input_tokens: %v\", err)\n\t\t}\n\t}\n\t*output = *output + delta + \"\\n\\n\\n\"\n\n\tparams.HasSentFinalEvents = true\n}\n\nfunc resolveStopReason(params *Params) string {\n\tif params.HasToolUse {\n\t\treturn \"tool_use\"\n\t}\n\n\tswitch params.FinishReason {\n\tcase \"MAX_TOKENS\":\n\t\treturn \"max_tokens\"\n\tcase \"STOP\", \"FINISH_REASON_UNSPECIFIED\", \"UNKNOWN\":\n\t\treturn \"end_turn\"\n\t}\n\n\treturn \"end_turn\"\n}\n\n// ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini CLI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: A Claude-compatible JSON response.\nfunc ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t_ = originalRequestRawJSON\n\tmodelName := gjson.GetBytes(requestRawJSON, \"model\").String()\n\n\troot := gjson.ParseBytes(rawJSON)\n\tpromptTokens := root.Get(\"response.usageMetadata.promptTokenCount\").Int()\n\tcandidateTokens := root.Get(\"response.usageMetadata.candidatesTokenCount\").Int()\n\tthoughtTokens := root.Get(\"response.usageMetadata.thoughtsTokenCount\").Int()\n\ttotalTokens := root.Get(\"response.usageMetadata.totalTokenCount\").Int()\n\tcachedTokens := root.Get(\"response.usageMetadata.cachedContentTokenCount\").Int()\n\toutputTokens := candidateTokens + thoughtTokens\n\tif outputTokens == 0 && totalTokens > 0 {\n\t\toutputTokens = totalTokens - promptTokens\n\t\tif outputTokens < 0 {\n\t\t\toutputTokens = 0\n\t\t}\n\t}\n\n\tresponseJSON := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":null,\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tresponseJSON, _ = sjson.Set(responseJSON, \"id\", root.Get(\"response.responseId\").String())\n\tresponseJSON, _ = sjson.Set(responseJSON, \"model\", root.Get(\"response.modelVersion\").String())\n\tresponseJSON, _ = sjson.Set(responseJSON, \"usage.input_tokens\", promptTokens)\n\tresponseJSON, _ = sjson.Set(responseJSON, \"usage.output_tokens\", outputTokens)\n\t// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)\n\tif cachedTokens > 0 {\n\t\tvar err error\n\t\tresponseJSON, err = sjson.Set(responseJSON, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"antigravity claude response: failed to set cache_read_input_tokens: %v\", err)\n\t\t}\n\t}\n\n\tcontentArrayInitialized := false\n\tensureContentArray := func() {\n\t\tif contentArrayInitialized {\n\t\t\treturn\n\t\t}\n\t\tresponseJSON, _ = sjson.SetRaw(responseJSON, \"content\", \"[]\")\n\t\tcontentArrayInitialized = true\n\t}\n\n\tparts := root.Get(\"response.candidates.0.content.parts\")\n\ttextBuilder := strings.Builder{}\n\tthinkingBuilder := strings.Builder{}\n\tthinkingSignature := \"\"\n\ttoolIDCounter := 0\n\thasToolCall := false\n\n\tflushText := func() {\n\t\tif textBuilder.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\tensureContentArray()\n\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"text\", textBuilder.String())\n\t\tresponseJSON, _ = sjson.SetRaw(responseJSON, \"content.-1\", block)\n\t\ttextBuilder.Reset()\n\t}\n\n\tflushThinking := func() {\n\t\tif thinkingBuilder.Len() == 0 && thinkingSignature == \"\" {\n\t\t\treturn\n\t\t}\n\t\tensureContentArray()\n\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"thinking\", thinkingBuilder.String())\n\t\tif thinkingSignature != \"\" {\n\t\t\tblock, _ = sjson.Set(block, \"signature\", fmt.Sprintf(\"%s#%s\", cache.GetModelGroup(modelName), thinkingSignature))\n\t\t}\n\t\tresponseJSON, _ = sjson.SetRaw(responseJSON, \"content.-1\", block)\n\t\tthinkingBuilder.Reset()\n\t\tthinkingSignature = \"\"\n\t}\n\n\tif parts.IsArray() {\n\t\tfor _, part := range parts.Array() {\n\t\t\tisThought := part.Get(\"thought\").Bool()\n\t\t\tif isThought {\n\t\t\t\tsig := part.Get(\"thoughtSignature\")\n\t\t\t\tif !sig.Exists() {\n\t\t\t\t\tsig = part.Get(\"thought_signature\")\n\t\t\t\t}\n\t\t\t\tif sig.Exists() && sig.String() != \"\" {\n\t\t\t\t\tthinkingSignature = sig.String()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif text := part.Get(\"text\"); text.Exists() && text.String() != \"\" {\n\t\t\t\tif isThought {\n\t\t\t\t\tflushText()\n\t\t\t\t\tthinkingBuilder.WriteString(text.String())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tflushThinking()\n\t\t\t\ttextBuilder.WriteString(text.String())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif functionCall := part.Get(\"functionCall\"); functionCall.Exists() {\n\t\t\t\tflushThinking()\n\t\t\t\tflushText()\n\t\t\t\thasToolCall = true\n\n\t\t\t\tname := functionCall.Get(\"name\").String()\n\t\t\t\ttoolIDCounter++\n\t\t\t\ttoolBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"id\", fmt.Sprintf(\"tool_%d\", toolIDCounter))\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"name\", name)\n\n\t\t\t\tif args := functionCall.Get(\"args\"); args.Exists() && args.Raw != \"\" && gjson.Valid(args.Raw) && args.IsObject() {\n\t\t\t\t\ttoolBlock, _ = sjson.SetRaw(toolBlock, \"input\", args.Raw)\n\t\t\t\t}\n\n\t\t\t\tensureContentArray()\n\t\t\t\tresponseJSON, _ = sjson.SetRaw(responseJSON, \"content.-1\", toolBlock)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\tflushThinking()\n\tflushText()\n\n\tstopReason := \"end_turn\"\n\tif hasToolCall {\n\t\tstopReason = \"tool_use\"\n\t} else {\n\t\tif finish := root.Get(\"response.candidates.0.finishReason\"); finish.Exists() {\n\t\t\tswitch finish.String() {\n\t\t\tcase \"MAX_TOKENS\":\n\t\t\t\tstopReason = \"max_tokens\"\n\t\t\tcase \"STOP\", \"FINISH_REASON_UNSPECIFIED\", \"UNKNOWN\":\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\tdefault:\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\t}\n\t\t}\n\t}\n\tresponseJSON, _ = sjson.Set(responseJSON, \"stop_reason\", stopReason)\n\n\tif promptTokens == 0 && outputTokens == 0 {\n\t\tif usageMeta := root.Get(\"response.usageMetadata\"); !usageMeta.Exists() {\n\t\t\tresponseJSON, _ = sjson.Delete(responseJSON, \"usage\")\n\t\t}\n\t}\n\n\treturn responseJSON\n}\n\nfunc ClaudeTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"input_tokens\":%d}`, count)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/claude/antigravity_claude_response_test.go",
    "content": "package claude\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/cache\"\n)\n\n// ============================================================================\n// Signature Caching Tests\n// ============================================================================\n\nfunc TestConvertAntigravityResponseToClaude_ParamsInitialized(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\t// Request with user message - should initialize params\n\trequestJSON := []byte(`{\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Hello world\"}]}\n\t\t]\n\t}`)\n\n\t// First response chunk with thinking\n\tresponseJSON := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"Let me think...\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\tvar param any\n\tctx := context.Background()\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, responseJSON, &param)\n\n\tparams := param.(*Params)\n\tif !params.HasFirstResponse {\n\t\tt.Error(\"HasFirstResponse should be set after first chunk\")\n\t}\n\tif params.CurrentThinkingText.Len() == 0 {\n\t\tt.Error(\"Thinking text should be accumulated\")\n\t}\n}\n\nfunc TestConvertAntigravityResponseToClaude_ThinkingTextAccumulated(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\trequestJSON := []byte(`{\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Test\"}]}]\n\t}`)\n\n\t// First thinking chunk\n\tchunk1 := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"First part of thinking...\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\t// Second thinking chunk (continuation)\n\tchunk2 := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \" Second part of thinking...\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\tvar param any\n\tctx := context.Background()\n\n\t// Process first chunk - starts new thinking block\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, chunk1, &param)\n\tparams := param.(*Params)\n\n\tif params.CurrentThinkingText.Len() == 0 {\n\t\tt.Error(\"Thinking text should be accumulated after first chunk\")\n\t}\n\n\t// Process second chunk - continues thinking block\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, chunk2, &param)\n\n\ttext := params.CurrentThinkingText.String()\n\tif !strings.Contains(text, \"First part\") || !strings.Contains(text, \"Second part\") {\n\t\tt.Errorf(\"Thinking text should accumulate both parts, got: %s\", text)\n\t}\n}\n\nfunc TestConvertAntigravityResponseToClaude_SignatureCached(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\trequestJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Cache test\"}]}]\n\t}`)\n\n\t// Thinking chunk\n\tthinkingChunk := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"My thinking process here\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\t// Signature chunk\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tsignatureChunk := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"\", \"thought\": true, \"thoughtSignature\": \"` + validSignature + `\"}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\tvar param any\n\tctx := context.Background()\n\n\t// Process thinking chunk\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, thinkingChunk, &param)\n\tparams := param.(*Params)\n\tthinkingText := params.CurrentThinkingText.String()\n\n\tif thinkingText == \"\" {\n\t\tt.Fatal(\"Thinking text should be accumulated\")\n\t}\n\n\t// Process signature chunk - should cache the signature\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, signatureChunk, &param)\n\n\t// Verify signature was cached\n\tcachedSig := cache.GetCachedSignature(\"claude-sonnet-4-5-thinking\", thinkingText)\n\tif cachedSig != validSignature {\n\t\tt.Errorf(\"Expected cached signature '%s', got '%s'\", validSignature, cachedSig)\n\t}\n\n\t// Verify thinking text was reset after caching\n\tif params.CurrentThinkingText.Len() != 0 {\n\t\tt.Error(\"Thinking text should be reset after signature is cached\")\n\t}\n}\n\nfunc TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T) {\n\tcache.ClearSignatureCache(\"\")\n\n\trequestJSON := []byte(`{\n\t\t\"model\": \"claude-sonnet-4-5-thinking\",\n\t\t\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Multi block test\"}]}]\n\t}`)\n\n\tvalidSig1 := \"signature1_12345678901234567890123456789012345678901234567\"\n\tvalidSig2 := \"signature2_12345678901234567890123456789012345678901234567\"\n\n\t// First thinking block with signature\n\tblock1Thinking := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"First thinking block\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\tblock1Sig := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"\", \"thought\": true, \"thoughtSignature\": \"` + validSig1 + `\"}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\t// Text content (breaks thinking)\n\ttextBlock := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"Regular text output\"}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\t// Second thinking block with signature\n\tblock2Thinking := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"Second thinking block\", \"thought\": true}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\tblock2Sig := []byte(`{\n\t\t\"response\": {\n\t\t\t\"candidates\": [{\n\t\t\t\t\"content\": {\n\t\t\t\t\t\"parts\": [{\"text\": \"\", \"thought\": true, \"thoughtSignature\": \"` + validSig2 + `\"}]\n\t\t\t\t}\n\t\t\t}]\n\t\t}\n\t}`)\n\n\tvar param any\n\tctx := context.Background()\n\n\t// Process first thinking block\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, block1Thinking, &param)\n\tparams := param.(*Params)\n\tfirstThinkingText := params.CurrentThinkingText.String()\n\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, block1Sig, &param)\n\n\t// Verify first signature cached\n\tif cache.GetCachedSignature(\"claude-sonnet-4-5-thinking\", firstThinkingText) != validSig1 {\n\t\tt.Error(\"First thinking block signature should be cached\")\n\t}\n\n\t// Process text (transitions out of thinking)\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, textBlock, &param)\n\n\t// Process second thinking block\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, block2Thinking, &param)\n\tsecondThinkingText := params.CurrentThinkingText.String()\n\n\tConvertAntigravityResponseToClaude(ctx, \"claude-sonnet-4-5-thinking\", requestJSON, requestJSON, block2Sig, &param)\n\n\t// Verify second signature cached\n\tif cache.GetCachedSignature(\"claude-sonnet-4-5-thinking\", secondThinkingText) != validSig2 {\n\t\tt.Error(\"Second thinking block signature should be cached\")\n\t}\n}\n"
  },
  {
    "path": "internal/translator/antigravity/claude/init.go",
    "content": "package claude\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tClaude,\n\t\tAntigravity,\n\t\tConvertClaudeRequestToAntigravity,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertAntigravityResponseToClaude,\n\t\t\tNonStream:  ConvertAntigravityResponseToClaudeNonStream,\n\t\t\tTokenCount: ClaudeTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/gemini/antigravity_gemini_request.go",
    "content": "// Package gemini provides request translation functionality for Gemini CLI to Gemini API compatibility.\n// It handles parsing and transforming Gemini CLI API requests into Gemini API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini CLI API format and Gemini API's expected format.\npackage gemini\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiRequestToAntigravity parses and transforms a Gemini CLI API request into Gemini API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini API.\n// The function performs the following transformations:\n// 1. Extracts the model information from the request\n// 2. Restructures the JSON to match Gemini API format\n// 3. Converts system instructions to the expected format\n// 4. Fixes CLI tool response format and grouping\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini API format\nfunc ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\ttemplate := \"\"\n\ttemplate = `{\"project\":\"\",\"request\":{},\"model\":\"\"}`\n\ttemplate, _ = sjson.SetRaw(template, \"request\", string(rawJSON))\n\ttemplate, _ = sjson.Set(template, \"model\", modelName)\n\ttemplate, _ = sjson.Delete(template, \"request.model\")\n\n\ttemplate, errFixCLIToolResponse := fixCLIToolResponse(template)\n\tif errFixCLIToolResponse != nil {\n\t\treturn []byte{}\n\t}\n\n\tsystemInstructionResult := gjson.Get(template, \"request.system_instruction\")\n\tif systemInstructionResult.Exists() {\n\t\ttemplate, _ = sjson.SetRaw(template, \"request.systemInstruction\", systemInstructionResult.Raw)\n\t\ttemplate, _ = sjson.Delete(template, \"request.system_instruction\")\n\t}\n\trawJSON = []byte(template)\n\n\t// Normalize roles in request.contents: default to valid values if missing/invalid\n\tcontents := gjson.GetBytes(rawJSON, \"request.contents\")\n\tif contents.Exists() {\n\t\tprevRole := \"\"\n\t\tidx := 0\n\t\tcontents.ForEach(func(_ gjson.Result, value gjson.Result) bool {\n\t\t\trole := value.Get(\"role\").String()\n\t\t\tvalid := role == \"user\" || role == \"model\"\n\t\t\tif role == \"\" || !valid {\n\t\t\t\tvar newRole string\n\t\t\t\tif prevRole == \"\" {\n\t\t\t\t\tnewRole = \"user\"\n\t\t\t\t} else if prevRole == \"user\" {\n\t\t\t\t\tnewRole = \"model\"\n\t\t\t\t} else {\n\t\t\t\t\tnewRole = \"user\"\n\t\t\t\t}\n\t\t\t\tpath := fmt.Sprintf(\"request.contents.%d.role\", idx)\n\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, path, newRole)\n\t\t\t\trole = newRole\n\t\t\t}\n\t\t\tprevRole = role\n\t\t\tidx++\n\t\t\treturn true\n\t\t})\n\t}\n\n\ttoolsResult := gjson.GetBytes(rawJSON, \"request.tools\")\n\tif toolsResult.Exists() && toolsResult.IsArray() {\n\t\ttoolResults := toolsResult.Array()\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\tfunctionDeclarationsResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"request.tools.%d.function_declarations\", i))\n\t\t\tif functionDeclarationsResult.Exists() && functionDeclarationsResult.IsArray() {\n\t\t\t\tfunctionDeclarationsResults := functionDeclarationsResult.Array()\n\t\t\t\tfor j := 0; j < len(functionDeclarationsResults); j++ {\n\t\t\t\t\tparametersResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parameters\", i, j))\n\t\t\t\t\tif parametersResult.Exists() {\n\t\t\t\t\t\tstrJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parameters\", i, j), fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parametersJsonSchema\", i, j))\n\t\t\t\t\t\trawJSON = []byte(strJson)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Gemini-specific handling for non-Claude models:\n\t// - Add skip_thought_signature_validator to functionCall parts so upstream can bypass signature validation.\n\t// - Also mark thinking parts with the same sentinel when present (we keep the parts; we only annotate them).\n\tif !strings.Contains(modelName, \"claude\") {\n\t\tconst skipSentinel = \"skip_thought_signature_validator\"\n\n\t\tgjson.GetBytes(rawJSON, \"request.contents\").ForEach(func(contentIdx, content gjson.Result) bool {\n\t\t\tif content.Get(\"role\").String() == \"model\" {\n\t\t\t\t// First pass: collect indices of thinking parts to mark with skip sentinel\n\t\t\t\tvar thinkingIndicesToSkipSignature []int64\n\t\t\t\tcontent.Get(\"parts\").ForEach(func(partIdx, part gjson.Result) bool {\n\t\t\t\t\t// Collect indices of thinking blocks to mark with skip sentinel\n\t\t\t\t\tif part.Get(\"thought\").Bool() {\n\t\t\t\t\t\tthinkingIndicesToSkipSignature = append(thinkingIndicesToSkipSignature, partIdx.Int())\n\t\t\t\t\t}\n\t\t\t\t\t// Add skip sentinel to functionCall parts\n\t\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\t\texistingSig := part.Get(\"thoughtSignature\").String()\n\t\t\t\t\t\tif existingSig == \"\" || len(existingSig) < 50 {\n\t\t\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"request.contents.%d.parts.%d.thoughtSignature\", contentIdx.Int(), partIdx.Int()), skipSentinel)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\t\t// Add skip_thought_signature_validator sentinel to thinking blocks in reverse order to preserve indices\n\t\t\t\tfor i := len(thinkingIndicesToSkipSignature) - 1; i >= 0; i-- {\n\t\t\t\t\tidx := thinkingIndicesToSkipSignature[i]\n\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"request.contents.%d.parts.%d.thoughtSignature\", contentIdx.Int(), idx), skipSentinel)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\treturn common.AttachDefaultSafetySettings(rawJSON, \"request.safetySettings\")\n}\n\n// FunctionCallGroup represents a group of function calls and their responses\ntype FunctionCallGroup struct {\n\tResponsesNeeded int\n\tCallNames       []string // ordered function call names for backfilling empty response names\n}\n\n// parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string.\n// Falls back to a minimal \"functionResponse\" object when parsing fails.\n// fallbackName is used when the response's own name is empty.\nfunc parseFunctionResponseRaw(response gjson.Result, fallbackName string) string {\n\tif response.IsObject() && gjson.Valid(response.Raw) {\n\t\traw := response.Raw\n\t\tname := response.Get(\"functionResponse.name\").String()\n\t\tif strings.TrimSpace(name) == \"\" && fallbackName != \"\" {\n\t\t\traw, _ = sjson.Set(raw, \"functionResponse.name\", fallbackName)\n\t\t}\n\t\treturn raw\n\t}\n\n\tlog.Debugf(\"parse function response failed, using fallback\")\n\tfuncResp := response.Get(\"functionResponse\")\n\tif funcResp.Exists() {\n\t\tfr := `{\"functionResponse\":{\"name\":\"\",\"response\":{\"result\":\"\"}}}`\n\t\tname := funcResp.Get(\"name\").String()\n\t\tif strings.TrimSpace(name) == \"\" {\n\t\t\tname = fallbackName\n\t\t}\n\t\tfr, _ = sjson.Set(fr, \"functionResponse.name\", name)\n\t\tfr, _ = sjson.Set(fr, \"functionResponse.response.result\", funcResp.Get(\"response\").String())\n\t\tif id := funcResp.Get(\"id\").String(); id != \"\" {\n\t\t\tfr, _ = sjson.Set(fr, \"functionResponse.id\", id)\n\t\t}\n\t\treturn fr\n\t}\n\n\tuseName := fallbackName\n\tif useName == \"\" {\n\t\tuseName = \"unknown\"\n\t}\n\tfr := `{\"functionResponse\":{\"name\":\"\",\"response\":{\"result\":\"\"}}}`\n\tfr, _ = sjson.Set(fr, \"functionResponse.name\", useName)\n\tfr, _ = sjson.Set(fr, \"functionResponse.response.result\", response.String())\n\treturn fr\n}\n\n// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.\n// This function transforms the CLI tool response format by intelligently grouping function calls\n// with their corresponding responses, ensuring proper conversation flow and API compatibility.\n// It converts from a linear format (1.json) to a grouped format (2.json) where function calls\n// and their responses are properly associated and structured.\n//\n// Parameters:\n//   - input: The input JSON string to be processed\n//\n// Returns:\n//   - string: The processed JSON string with grouped function calls and responses\n//   - error: An error if the processing fails\nfunc fixCLIToolResponse(input string) (string, error) {\n\t// Parse the input JSON to extract the conversation structure\n\tparsed := gjson.Parse(input)\n\n\t// Extract the contents array which contains the conversation messages\n\tcontents := parsed.Get(\"request.contents\")\n\tif !contents.Exists() {\n\t\t// log.Debugf(input)\n\t\treturn input, fmt.Errorf(\"contents not found in input\")\n\t}\n\n\t// Initialize data structures for processing and grouping\n\tcontentsWrapper := `{\"contents\":[]}`\n\tvar pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses\n\tvar collectedResponses []gjson.Result  // Standalone responses to be matched\n\n\t// Process each content object in the conversation\n\t// This iterates through messages and groups function calls with their responses\n\tcontents.ForEach(func(key, value gjson.Result) bool {\n\t\trole := value.Get(\"role\").String()\n\t\tparts := value.Get(\"parts\")\n\n\t\t// Check if this content has function responses\n\t\tvar responsePartsInThisContent []gjson.Result\n\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\tif part.Get(\"functionResponse\").Exists() {\n\t\t\t\tresponsePartsInThisContent = append(responsePartsInThisContent, part)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\t// If this content has function responses, collect them\n\t\tif len(responsePartsInThisContent) > 0 {\n\t\t\tcollectedResponses = append(collectedResponses, responsePartsInThisContent...)\n\n\t\t\t// Check if pending groups can be satisfied (FIFO: oldest group first)\n\t\t\tfor len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {\n\t\t\t\tgroup := pendingGroups[0]\n\t\t\t\tpendingGroups = pendingGroups[1:]\n\n\t\t\t\t// Take the needed responses for this group\n\t\t\t\tgroupResponses := collectedResponses[:group.ResponsesNeeded]\n\t\t\t\tcollectedResponses = collectedResponses[group.ResponsesNeeded:]\n\n\t\t\t\t// Create merged function response content\n\t\t\t\tfunctionResponseContent := `{\"parts\":[],\"role\":\"function\"}`\n\t\t\t\tfor ri, response := range groupResponses {\n\t\t\t\t\tpartRaw := parseFunctionResponseRaw(response, group.CallNames[ri])\n\t\t\t\t\tif partRaw != \"\" {\n\t\t\t\t\t\tfunctionResponseContent, _ = sjson.SetRaw(functionResponseContent, \"parts.-1\", partRaw)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif gjson.Get(functionResponseContent, \"parts.#\").Int() > 0 {\n\t\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", functionResponseContent)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn true // Skip adding this content, responses are merged\n\t\t}\n\n\t\t// If this is a model with function calls, create a new group\n\t\tif role == \"model\" {\n\t\t\tvar callNames []string\n\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\tcallNames = append(callNames, part.Get(\"functionCall.name\").String())\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\tif len(callNames) > 0 {\n\t\t\t\t// Add the model content\n\t\t\t\tif !value.IsObject() {\n\t\t\t\t\tlog.Warnf(\"failed to parse model content\")\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\n\t\t\t\t// Create a new group for tracking responses\n\t\t\t\tgroup := &FunctionCallGroup{\n\t\t\t\t\tResponsesNeeded: len(callNames),\n\t\t\t\t\tCallNames:       callNames,\n\t\t\t\t}\n\t\t\t\tpendingGroups = append(pendingGroups, group)\n\t\t\t} else {\n\t\t\t\t// Regular model content without function calls\n\t\t\t\tif !value.IsObject() {\n\t\t\t\t\tlog.Warnf(\"failed to parse content\")\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-model content (user, etc.)\n\t\t\tif !value.IsObject() {\n\t\t\t\tlog.Warnf(\"failed to parse content\")\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\t\t}\n\n\t\treturn true\n\t})\n\n\t// Handle any remaining pending groups with remaining responses\n\tfor _, group := range pendingGroups {\n\t\tif len(collectedResponses) >= group.ResponsesNeeded {\n\t\t\tgroupResponses := collectedResponses[:group.ResponsesNeeded]\n\t\t\tcollectedResponses = collectedResponses[group.ResponsesNeeded:]\n\n\t\t\tfunctionResponseContent := `{\"parts\":[],\"role\":\"function\"}`\n\t\t\tfor ri, response := range groupResponses {\n\t\t\t\tpartRaw := parseFunctionResponseRaw(response, group.CallNames[ri])\n\t\t\t\tif partRaw != \"\" {\n\t\t\t\t\tfunctionResponseContent, _ = sjson.SetRaw(functionResponseContent, \"parts.-1\", partRaw)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif gjson.Get(functionResponseContent, \"parts.#\").Int() > 0 {\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", functionResponseContent)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update the original JSON with the new contents\n\tresult := input\n\tresult, _ = sjson.SetRaw(result, \"request.contents\", gjson.Get(contentsWrapper, \"contents\").Raw)\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/translator/antigravity/gemini/antigravity_gemini_request_test.go",
    "content": "package gemini\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertGeminiRequestToAntigravity_PreserveValidSignature(t *testing.T) {\n\t// Valid signature on functionCall should be preserved\n\tvalidSignature := \"abc123validSignature1234567890123456789012345678901234567890\"\n\tinputJSON := []byte(fmt.Sprintf(`{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"test_tool\", \"args\": {}}, \"thoughtSignature\": \"%s\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`, validSignature))\n\n\toutput := ConvertGeminiRequestToAntigravity(\"gemini-3-pro-preview\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that valid thoughtSignature is preserved\n\tparts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(parts) != 1 {\n\t\tt.Fatalf(\"Expected 1 part, got %d\", len(parts))\n\t}\n\n\tsig := parts[0].Get(\"thoughtSignature\").String()\n\tif sig != validSignature {\n\t\tt.Errorf(\"Expected thoughtSignature '%s', got '%s'\", validSignature, sig)\n\t}\n}\n\nfunc TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) {\n\t// functionCall without signature should get skip_thought_signature_validator\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"test_tool\", \"args\": {}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertGeminiRequestToAntigravity(\"gemini-3-pro-preview\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that skip_thought_signature_validator is added to functionCall\n\tsig := gjson.Get(outputStr, \"request.contents.0.parts.0.thoughtSignature\").String()\n\texpectedSig := \"skip_thought_signature_validator\"\n\tif sig != expectedSig {\n\t\tt.Errorf(\"Expected skip sentinel '%s', got '%s'\", expectedSig, sig)\n\t}\n}\n\nfunc TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) {\n\t// Multiple functionCalls should all get skip_thought_signature_validator\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"tool_one\", \"args\": {\"a\": \"1\"}}},\n\t\t\t\t\t{\"functionCall\": {\"name\": \"tool_two\", \"args\": {\"b\": \"2\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertGeminiRequestToAntigravity(\"gemini-3-pro-preview\", inputJSON, false)\n\toutputStr := string(output)\n\n\tparts := gjson.Get(outputStr, \"request.contents.0.parts\").Array()\n\tif len(parts) != 2 {\n\t\tt.Fatalf(\"Expected 2 parts, got %d\", len(parts))\n\t}\n\n\texpectedSig := \"skip_thought_signature_validator\"\n\tfor i, part := range parts {\n\t\tsig := part.Get(\"thoughtSignature\").String()\n\t\tif sig != expectedSig {\n\t\t\tt.Errorf(\"Part %d: Expected '%s', got '%s'\", i, expectedSig, sig)\n\t\t}\n\t}\n}\n\nfunc TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) {\n\t// When functionResponse contains a \"parts\" field with inlineData (from Claude\n\t// translator's image embedding), fixCLIToolResponse should preserve it as-is.\n\t// parseFunctionResponseRaw returns response.Raw for valid JSON objects,\n\t// so extra fields like \"parts\" survive the pipeline.\n\tinput := `{\n\t\t\"model\": \"claude-opus-4-6-thinking\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"functionCall\": {\"name\": \"screenshot\", \"args\": {}}\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"functionResponse\": {\n\t\t\t\t\t\t\t\t\"id\": \"tool-001\",\n\t\t\t\t\t\t\t\t\"name\": \"screenshot\",\n\t\t\t\t\t\t\t\t\"response\": {\"result\": \"Screenshot taken\"},\n\t\t\t\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t\t\t\t{\"inlineData\": {\"mimeType\": \"image/png\", \"data\": \"iVBOR\"}}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\t// Find the function response content (role=function)\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContent gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContent = c\n\t\t\tbreak\n\t\t}\n\t}\n\tif !funcContent.Exists() {\n\t\tt.Fatal(\"function role content should exist in output\")\n\t}\n\n\t// The functionResponse should be preserved with its parts field\n\tfuncResp := funcContent.Get(\"parts.0.functionResponse\")\n\tif !funcResp.Exists() {\n\t\tt.Fatal(\"functionResponse should exist in output\")\n\t}\n\n\t// Verify the parts field with inlineData is preserved\n\tinlineParts := funcResp.Get(\"parts\").Array()\n\tif len(inlineParts) != 1 {\n\t\tt.Fatalf(\"Expected 1 inlineData part in functionResponse.parts, got %d\", len(inlineParts))\n\t}\n\tif inlineParts[0].Get(\"inlineData.mimeType\").String() != \"image/png\" {\n\t\tt.Errorf(\"Expected mimeType 'image/png', got '%s'\", inlineParts[0].Get(\"inlineData.mimeType\").String())\n\t}\n\tif inlineParts[0].Get(\"inlineData.data\").String() != \"iVBOR\" {\n\t\tt.Errorf(\"Expected data 'iVBOR', got '%s'\", inlineParts[0].Get(\"inlineData.data\").String())\n\t}\n\n\t// Verify response.result is also preserved\n\tif funcResp.Get(\"response.result\").String() != \"Screenshot taken\" {\n\t\tt.Errorf(\"Expected response.result 'Screenshot taken', got '%s'\", funcResp.Get(\"response.result\").String())\n\t}\n}\n\nfunc TestFixCLIToolResponse_BackfillsEmptyFunctionResponseName(t *testing.T) {\n\t// When the Amp client sends functionResponse with an empty name,\n\t// fixCLIToolResponse should backfill it from the corresponding functionCall.\n\tinput := `{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {\"cmd\": \"ls\"}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"output\": \"file1.txt\"}}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContent gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContent = c\n\t\t\tbreak\n\t\t}\n\t}\n\tif !funcContent.Exists() {\n\t\tt.Fatal(\"function role content should exist in output\")\n\t}\n\n\tname := funcContent.Get(\"parts.0.functionResponse.name\").String()\n\tif name != \"Bash\" {\n\t\tt.Errorf(\"Expected backfilled name 'Bash', got '%s'\", name)\n\t}\n}\n\nfunc TestFixCLIToolResponse_BackfillsMultipleEmptyNames(t *testing.T) {\n\t// Parallel function calls: both responses have empty names.\n\tinput := `{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Read\", \"args\": {\"path\": \"/a\"}}},\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Grep\", \"args\": {\"pattern\": \"x\"}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"content a\"}}},\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"match x\"}}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContent gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContent = c\n\t\t\tbreak\n\t\t}\n\t}\n\tif !funcContent.Exists() {\n\t\tt.Fatal(\"function role content should exist in output\")\n\t}\n\n\tparts := funcContent.Get(\"parts\").Array()\n\tif len(parts) != 2 {\n\t\tt.Fatalf(\"Expected 2 function response parts, got %d\", len(parts))\n\t}\n\n\tname0 := parts[0].Get(\"functionResponse.name\").String()\n\tname1 := parts[1].Get(\"functionResponse.name\").String()\n\tif name0 != \"Read\" {\n\t\tt.Errorf(\"Expected first response name 'Read', got '%s'\", name0)\n\t}\n\tif name1 != \"Grep\" {\n\t\tt.Errorf(\"Expected second response name 'Grep', got '%s'\", name1)\n\t}\n}\n\nfunc TestFixCLIToolResponse_PreservesExistingName(t *testing.T) {\n\t// When functionResponse already has a valid name, it should be preserved.\n\tinput := `{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"Bash\", \"response\": {\"result\": \"ok\"}}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContent gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContent = c\n\t\t\tbreak\n\t\t}\n\t}\n\tif !funcContent.Exists() {\n\t\tt.Fatal(\"function role content should exist in output\")\n\t}\n\n\tname := funcContent.Get(\"parts.0.functionResponse.name\").String()\n\tif name != \"Bash\" {\n\t\tt.Errorf(\"Expected preserved name 'Bash', got '%s'\", name)\n\t}\n}\n\nfunc TestFixCLIToolResponse_MoreResponsesThanCalls(t *testing.T) {\n\t// If there are more function responses than calls, unmatched extras are discarded by grouping.\n\tinput := `{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"ok\"}}},\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"extra\"}}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContent gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContent = c\n\t\t\tbreak\n\t\t}\n\t}\n\tif !funcContent.Exists() {\n\t\tt.Fatal(\"function role content should exist in output\")\n\t}\n\n\t// First response should be backfilled from the call\n\tname0 := funcContent.Get(\"parts.0.functionResponse.name\").String()\n\tif name0 != \"Bash\" {\n\t\tt.Errorf(\"Expected first response name 'Bash', got '%s'\", name0)\n\t}\n}\n\nfunc TestFixCLIToolResponse_MultipleGroupsFIFO(t *testing.T) {\n\t// Two sequential function call groups should be matched FIFO.\n\tinput := `{\n\t\t\"model\": \"gemini-3-pro-preview\",\n\t\t\"request\": {\n\t\t\t\"contents\": [\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Read\", \"args\": {}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"file content\"}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"model\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionCall\": {\"name\": \"Grep\", \"args\": {}}}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\": \"function\",\n\t\t\t\t\t\"parts\": [\n\t\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"match\"}}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}`\n\n\tresult, err := fixCLIToolResponse(input)\n\tif err != nil {\n\t\tt.Fatalf(\"fixCLIToolResponse failed: %v\", err)\n\t}\n\n\tcontents := gjson.Get(result, \"request.contents\").Array()\n\tvar funcContents []gjson.Result\n\tfor _, c := range contents {\n\t\tif c.Get(\"role\").String() == \"function\" {\n\t\t\tfuncContents = append(funcContents, c)\n\t\t}\n\t}\n\tif len(funcContents) != 2 {\n\t\tt.Fatalf(\"Expected 2 function contents, got %d\", len(funcContents))\n\t}\n\n\tname0 := funcContents[0].Get(\"parts.0.functionResponse.name\").String()\n\tname1 := funcContents[1].Get(\"parts.0.functionResponse.name\").String()\n\tif name0 != \"Read\" {\n\t\tt.Errorf(\"Expected first group name 'Read', got '%s'\", name0)\n\t}\n\tif name1 != \"Grep\" {\n\t\tt.Errorf(\"Expected second group name 'Grep', got '%s'\", name1)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/antigravity/gemini/antigravity_gemini_response.go",
    "content": "// Package gemini provides request translation functionality for Gemini to Gemini CLI API compatibility.\n// It handles parsing and transforming Gemini API requests into Gemini CLI API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini API format and Gemini CLI API's expected format.\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertAntigravityResponseToGemini parses and transforms a Gemini CLI API request into Gemini API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini API.\n// The function performs the following transformations:\n// 1. Extracts the response data from the request\n// 2. Handles alternative response formats\n// 3. Processes array responses by extracting individual response objects\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model to use for the request (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - []string: The transformed request data in Gemini API format\nfunc ConvertAntigravityResponseToGemini(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string {\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\tif alt, ok := ctx.Value(\"alt\").(string); ok {\n\t\tvar chunk []byte\n\t\tif alt == \"\" {\n\t\t\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\t\t\tif responseResult.Exists() {\n\t\t\t\tchunk = []byte(responseResult.Raw)\n\t\t\t\tchunk = restoreUsageMetadata(chunk)\n\t\t\t}\n\t\t} else {\n\t\t\tchunkTemplate := \"[]\"\n\t\t\tresponseResult := gjson.ParseBytes(chunk)\n\t\t\tif responseResult.IsArray() {\n\t\t\t\tresponseResultItems := responseResult.Array()\n\t\t\t\tfor i := 0; i < len(responseResultItems); i++ {\n\t\t\t\t\tresponseResultItem := responseResultItems[i]\n\t\t\t\t\tif responseResultItem.Get(\"response\").Exists() {\n\t\t\t\t\t\tchunkTemplate, _ = sjson.SetRaw(chunkTemplate, \"-1\", responseResultItem.Get(\"response\").Raw)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tchunk = []byte(chunkTemplate)\n\t\t}\n\t\treturn []string{string(chunk)}\n\t}\n\treturn []string{}\n}\n\n// ConvertAntigravityResponseToGeminiNonStream converts a non-streaming Gemini CLI request to a non-streaming Gemini response.\n// This function processes the complete Gemini CLI request and transforms it into a single Gemini-compatible\n// JSON response. It extracts the response data from the request and returns it in the expected format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response containing the response data\nfunc ConvertAntigravityResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\tchunk := restoreUsageMetadata([]byte(responseResult.Raw))\n\t\treturn string(chunk)\n\t}\n\treturn string(rawJSON)\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n\n// restoreUsageMetadata renames cpaUsageMetadata back to usageMetadata.\n// The executor renames usageMetadata to cpaUsageMetadata in non-terminal chunks\n// to preserve usage data while hiding it from clients that don't expect it.\n// When returning standard Gemini API format, we must restore the original name.\nfunc restoreUsageMetadata(chunk []byte) []byte {\n\tif cpaUsage := gjson.GetBytes(chunk, \"cpaUsageMetadata\"); cpaUsage.Exists() {\n\t\tchunk, _ = sjson.SetRawBytes(chunk, \"usageMetadata\", []byte(cpaUsage.Raw))\n\t\tchunk, _ = sjson.DeleteBytes(chunk, \"cpaUsageMetadata\")\n\t}\n\treturn chunk\n}\n"
  },
  {
    "path": "internal/translator/antigravity/gemini/antigravity_gemini_response_test.go",
    "content": "package gemini\n\nimport (\n\t\"context\"\n\t\"testing\"\n)\n\nfunc TestRestoreUsageMetadata(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"cpaUsageMetadata renamed to usageMetadata\",\n\t\t\tinput:    []byte(`{\"modelVersion\":\"gemini-3-pro\",\"cpaUsageMetadata\":{\"promptTokenCount\":100,\"candidatesTokenCount\":200}}`),\n\t\t\texpected: `{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100,\"candidatesTokenCount\":200}}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"no cpaUsageMetadata unchanged\",\n\t\t\tinput:    []byte(`{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}`),\n\t\t\texpected: `{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tinput:    []byte(`{}`),\n\t\t\texpected: `{}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := restoreUsageMetadata(tt.input)\n\t\t\tif string(result) != tt.expected {\n\t\t\t\tt.Errorf(\"restoreUsageMetadata() = %s, want %s\", string(result), tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertAntigravityResponseToGeminiNonStream(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"cpaUsageMetadata restored in response\",\n\t\t\tinput:    []byte(`{\"response\":{\"modelVersion\":\"gemini-3-pro\",\"cpaUsageMetadata\":{\"promptTokenCount\":100}}}`),\n\t\t\texpected: `{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"usageMetadata preserved\",\n\t\t\tinput:    []byte(`{\"response\":{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}}`),\n\t\t\texpected: `{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := ConvertAntigravityResponseToGeminiNonStream(context.Background(), \"\", nil, nil, tt.input, nil)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"ConvertAntigravityResponseToGeminiNonStream() = %s, want %s\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertAntigravityResponseToGeminiStream(t *testing.T) {\n\tctx := context.WithValue(context.Background(), \"alt\", \"\")\n\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"cpaUsageMetadata restored in streaming response\",\n\t\t\tinput:    []byte(`data: {\"response\":{\"modelVersion\":\"gemini-3-pro\",\"cpaUsageMetadata\":{\"promptTokenCount\":100}}}`),\n\t\t\texpected: `{\"modelVersion\":\"gemini-3-pro\",\"usageMetadata\":{\"promptTokenCount\":100}}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresults := ConvertAntigravityResponseToGemini(ctx, \"\", nil, nil, tt.input, nil)\n\t\t\tif len(results) != 1 {\n\t\t\t\tt.Fatalf(\"expected 1 result, got %d\", len(results))\n\t\t\t}\n\t\t\tif results[0] != tt.expected {\n\t\t\t\tt.Errorf(\"ConvertAntigravityResponseToGemini() = %s, want %s\", results[0], tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/translator/antigravity/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tAntigravity,\n\t\tConvertGeminiRequestToAntigravity,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertAntigravityResponseToGemini,\n\t\t\tNonStream:  ConvertAntigravityResponseToGeminiNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go",
    "content": "// Package openai provides request translation functionality for OpenAI to Gemini CLI API compatibility.\n// It converts OpenAI Chat Completions requests into Gemini CLI compatible JSON using gjson/sjson only.\npackage chat_completions\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiCLIFunctionThoughtSignature = \"skip_thought_signature_validator\"\n\n// ConvertOpenAIRequestToAntigravity converts an OpenAI Chat Completions request (raw JSON)\n// into a complete Gemini CLI request JSON. All JSON construction uses sjson and lookups use gjson.\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini CLI API format\nfunc ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base envelope (no default thinkingConfig)\n\tout := []byte(`{\"project\":\"\",\"request\":{\"contents\":[]},\"model\":\"gemini-2.5-pro\"}`)\n\n\t// Model\n\tout, _ = sjson.SetBytes(out, \"model\", modelName)\n\n\t// Let user-provided generationConfig pass through\n\tif genConfig := gjson.GetBytes(rawJSON, \"generationConfig\"); genConfig.Exists() {\n\t\tout, _ = sjson.SetRawBytes(out, \"request.generationConfig\", []byte(genConfig.Raw))\n\t}\n\n\t// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini CLI thinkingConfig.\n\t// Inline translation-only mapping; capability checks happen later in ApplyThinking.\n\tre := gjson.GetBytes(rawJSON, \"reasoning_effort\")\n\tif re.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(re.String()))\n\t\tif effort != \"\" {\n\t\t\tthinkingPath := \"request.generationConfig.thinkingConfig\"\n\t\t\tif effort == \"auto\" {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingBudget\", -1)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", true)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingLevel\", effort)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", effort != \"none\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// Temperature/top_p/top_k/max_tokens\n\tif tr := gjson.GetBytes(rawJSON, \"temperature\"); tr.Exists() && tr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.temperature\", tr.Num)\n\t}\n\tif tpr := gjson.GetBytes(rawJSON, \"top_p\"); tpr.Exists() && tpr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.topP\", tpr.Num)\n\t}\n\tif tkr := gjson.GetBytes(rawJSON, \"top_k\"); tkr.Exists() && tkr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.topK\", tkr.Num)\n\t}\n\tif maxTok := gjson.GetBytes(rawJSON, \"max_tokens\"); maxTok.Exists() && maxTok.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.maxOutputTokens\", maxTok.Num)\n\t}\n\n\t// Candidate count (OpenAI 'n' parameter)\n\tif n := gjson.GetBytes(rawJSON, \"n\"); n.Exists() && n.Type == gjson.Number {\n\t\tif val := n.Int(); val > 1 {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.candidateCount\", val)\n\t\t}\n\t}\n\n\t// Map OpenAI modalities -> Gemini CLI request.generationConfig.responseModalities\n\t// e.g. \"modalities\": [\"image\", \"text\"] -> [\"IMAGE\", \"TEXT\"]\n\tif mods := gjson.GetBytes(rawJSON, \"modalities\"); mods.Exists() && mods.IsArray() {\n\t\tvar responseMods []string\n\t\tfor _, m := range mods.Array() {\n\t\t\tswitch strings.ToLower(m.String()) {\n\t\t\tcase \"text\":\n\t\t\t\tresponseMods = append(responseMods, \"TEXT\")\n\t\t\tcase \"image\":\n\t\t\t\tresponseMods = append(responseMods, \"IMAGE\")\n\t\t\t}\n\t\t}\n\t\tif len(responseMods) > 0 {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.responseModalities\", responseMods)\n\t\t}\n\t}\n\n\t// OpenRouter-style image_config support\n\t// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.\n\tif imgCfg := gjson.GetBytes(rawJSON, \"image_config\"); imgCfg.Exists() && imgCfg.IsObject() {\n\t\tif ar := imgCfg.Get(\"aspect_ratio\"); ar.Exists() && ar.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.imageConfig.aspectRatio\", ar.Str)\n\t\t}\n\t\tif size := imgCfg.Get(\"image_size\"); size.Exists() && size.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.imageConfig.imageSize\", size.Str)\n\t\t}\n\t}\n\n\t// messages -> systemInstruction + contents\n\tmessages := gjson.GetBytes(rawJSON, \"messages\")\n\tif messages.IsArray() {\n\t\tarr := messages.Array()\n\t\t// First pass: assistant tool_calls id->name map\n\t\ttcID2Name := map[string]string{}\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\tif m.Get(\"role\").String() == \"assistant\" {\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\t\tid := tc.Get(\"id\").String()\n\t\t\t\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\t\tif id != \"\" && name != \"\" {\n\t\t\t\t\t\t\t\ttcID2Name[id] = name\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Second pass build systemInstruction/tool responses cache\n\t\ttoolResponses := map[string]string{} // tool_call_id -> response text\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tif role == \"tool\" {\n\t\t\t\ttoolCallID := m.Get(\"tool_call_id\").String()\n\t\t\t\tif toolCallID != \"\" {\n\t\t\t\t\tc := m.Get(\"content\")\n\t\t\t\t\ttoolResponses[toolCallID] = c.Raw\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsystemPartIndex := 0\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tcontent := m.Get(\"content\")\n\n\t\t\tif (role == \"system\" || role == \"developer\") && len(arr) > 1 {\n\t\t\t\t// system -> request.systemInstruction as a user message style\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), content.String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsObject() && content.Get(\"type\").String() == \"text\" {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), content.Get(\"text\").String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\tcontents := content.Array()\n\t\t\t\t\tif len(contents) > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\t\tfor j := 0; j < len(contents); j++ {\n\t\t\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), contents[j].Get(\"text\").String())\n\t\t\t\t\t\t\tsystemPartIndex++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if role == \"user\" || ((role == \"system\" || role == \"developer\") && len(arr) == 1) {\n\t\t\t\t// Build single user content node to avoid splitting into multiple contents\n\t\t\t\tnode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.0.text\", content.String())\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\titems := content.Array()\n\t\t\t\t\tp := 0\n\t\t\t\t\tfor _, item := range items {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\ttext := item.Get(\"text\").String()\n\t\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", text)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 {\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mimeType\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"file\":\n\t\t\t\t\t\t\tfilename := item.Get(\"file.filename\").String()\n\t\t\t\t\t\t\tfileData := item.Get(\"file.file_data\").String()\n\t\t\t\t\t\t\text := \"\"\n\t\t\t\t\t\t\tif sp := strings.Split(filename, \".\"); len(sp) > 1 {\n\t\t\t\t\t\t\t\text = sp[len(sp)-1]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif mimeType, ok := misc.MimeTypes[ext]; ok {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mimeType\", mimeType)\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", fileData)\n\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Unknown file name extension '%s' in user message, skip\", ext)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"input_audio\":\n\t\t\t\t\t\t\taudioData := item.Get(\"input_audio.data\").String()\n\t\t\t\t\t\t\taudioFormat := item.Get(\"input_audio.format\").String()\n\t\t\t\t\t\t\tif audioData != \"\" {\n\t\t\t\t\t\t\t\taudioMimeMap := map[string]string{\n\t\t\t\t\t\t\t\t\t\"mp3\":       \"audio/mpeg\",\n\t\t\t\t\t\t\t\t\t\"wav\":       \"audio/wav\",\n\t\t\t\t\t\t\t\t\t\"ogg\":       \"audio/ogg\",\n\t\t\t\t\t\t\t\t\t\"flac\":      \"audio/flac\",\n\t\t\t\t\t\t\t\t\t\"aac\":       \"audio/aac\",\n\t\t\t\t\t\t\t\t\t\"webm\":      \"audio/webm\",\n\t\t\t\t\t\t\t\t\t\"pcm16\":     \"audio/pcm\",\n\t\t\t\t\t\t\t\t\t\"g711_ulaw\": \"audio/basic\",\n\t\t\t\t\t\t\t\t\t\"g711_alaw\": \"audio/basic\",\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tmimeType := \"audio/wav\"\n\t\t\t\t\t\t\t\tif audioFormat != \"\" {\n\t\t\t\t\t\t\t\t\tif mapped, ok := audioMimeMap[audioFormat]; ok {\n\t\t\t\t\t\t\t\t\t\tmimeType = mapped\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tmimeType = \"audio/\" + audioFormat\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mimeType)\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", audioData)\n\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\t\t\t} else if role == \"assistant\" {\n\t\t\t\tnode := []byte(`{\"role\":\"model\",\"parts\":[]}`)\n\t\t\t\tp := 0\n\t\t\t\tif content.Type == gjson.String && content.String() != \"\" {\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.-1.text\", content.String())\n\t\t\t\t\tp++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\t// Assistant multimodal content (e.g. text + image) -> single model content with parts\n\t\t\t\t\tfor _, item := range content.Array() {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\ttext := item.Get(\"text\").String()\n\t\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", text)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\t// If the assistant returned an inline data URL, preserve it for history fidelity.\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 { // expect data:...\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mimeType\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Tool calls -> single model content with functionCall parts\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfIDs := make([]string, 0)\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() != \"function\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfid := tc.Get(\"id\").String()\n\t\t\t\t\t\tfname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\tfargs := tc.Get(\"function.arguments\").String()\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".functionCall.id\", fid)\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".functionCall.name\", fname)\n\t\t\t\t\t\tif gjson.Valid(fargs) {\n\t\t\t\t\t\t\tnode, _ = sjson.SetRawBytes(node, \"parts.\"+itoa(p)+\".functionCall.args\", []byte(fargs))\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".functionCall.args.params\", []byte(fargs))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\tp++\n\t\t\t\t\t\tif fid != \"\" {\n\t\t\t\t\t\t\tfIDs = append(fIDs, fid)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\n\t\t\t\t\t// Append a single tool content combining name + response per function\n\t\t\t\t\ttoolNode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\t\tpp := 0\n\t\t\t\t\tfor _, fid := range fIDs {\n\t\t\t\t\t\tif name, ok := tcID2Name[fid]; ok {\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.id\", fid)\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.name\", name)\n\t\t\t\t\t\t\tresp := toolResponses[fid]\n\t\t\t\t\t\t\tif resp == \"\" {\n\t\t\t\t\t\t\t\tresp = \"{}\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// Handle non-JSON output gracefully (matches dev branch approach)\n\t\t\t\t\t\t\tif resp != \"null\" {\n\t\t\t\t\t\t\t\tparsed := gjson.Parse(resp)\n\t\t\t\t\t\t\t\tif parsed.Type == gjson.JSON {\n\t\t\t\t\t\t\t\t\ttoolNode, _ = sjson.SetRawBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.response.result\", []byte(parsed.Raw))\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.response.result\", resp)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tpp++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif pp > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", toolNode)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// tools -> request.tools[].functionDeclarations + request.tools[].googleSearch/codeExecution/urlContext passthrough\n\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\tfunctionToolNode := []byte(`{}`)\n\t\thasFunction := false\n\t\tgoogleSearchNodes := make([][]byte, 0)\n\t\tcodeExecutionNodes := make([][]byte, 0)\n\t\turlContextNodes := make([][]byte, 0)\n\t\tfor _, t := range tools.Array() {\n\t\t\tif t.Get(\"type\").String() == \"function\" {\n\t\t\t\tfn := t.Get(\"function\")\n\t\t\t\tif fn.Exists() && fn.IsObject() {\n\t\t\t\t\tfnRaw := fn.Raw\n\t\t\t\t\tif fn.Get(\"parameters\").Exists() {\n\t\t\t\t\t\trenamed, errRename := util.RenameKey(fnRaw, \"parameters\", \"parametersJsonSchema\")\n\t\t\t\t\t\tif errRename != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to rename parameters for tool '%s': %v\", fn.Get(\"name\").String(), errRename)\n\t\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tfnRaw = renamed\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfnRaw, _ = sjson.Delete(fnRaw, \"strict\")\n\t\t\t\t\tif !hasFunction {\n\t\t\t\t\t\tfunctionToolNode, _ = sjson.SetRawBytes(functionToolNode, \"functionDeclarations\", []byte(\"[]\"))\n\t\t\t\t\t}\n\t\t\t\t\ttmp, errSet := sjson.SetRawBytes(functionToolNode, \"functionDeclarations.-1\", []byte(fnRaw))\n\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\tlog.Warnf(\"Failed to append tool declaration for '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tfunctionToolNode = tmp\n\t\t\t\t\thasFunction = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif gs := t.Get(\"google_search\"); gs.Exists() {\n\t\t\t\tgoogleToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tgoogleToolNode, errSet = sjson.SetRawBytes(googleToolNode, \"googleSearch\", []byte(gs.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set googleSearch tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tgoogleSearchNodes = append(googleSearchNodes, googleToolNode)\n\t\t\t}\n\t\t\tif ce := t.Get(\"code_execution\"); ce.Exists() {\n\t\t\t\tcodeToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tcodeToolNode, errSet = sjson.SetRawBytes(codeToolNode, \"codeExecution\", []byte(ce.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set codeExecution tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcodeExecutionNodes = append(codeExecutionNodes, codeToolNode)\n\t\t\t}\n\t\t\tif uc := t.Get(\"url_context\"); uc.Exists() {\n\t\t\t\turlToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\turlToolNode, errSet = sjson.SetRawBytes(urlToolNode, \"urlContext\", []byte(uc.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set urlContext tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\turlContextNodes = append(urlContextNodes, urlToolNode)\n\t\t\t}\n\t\t}\n\t\tif hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {\n\t\t\ttoolsNode := []byte(\"[]\")\n\t\t\tif hasFunction {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", functionToolNode)\n\t\t\t}\n\t\t\tfor _, googleNode := range googleSearchNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", googleNode)\n\t\t\t}\n\t\t\tfor _, codeNode := range codeExecutionNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", codeNode)\n\t\t\t}\n\t\t\tfor _, urlNode := range urlContextNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", urlNode)\n\t\t\t}\n\t\t\tout, _ = sjson.SetRawBytes(out, \"request.tools\", toolsNode)\n\t\t}\n\t}\n\n\treturn common.AttachDefaultSafetySettings(out, \"request.safetySettings\")\n}\n\n// itoa converts int to string without strconv import for few usages.\nfunc itoa(i int) string { return fmt.Sprintf(\"%d\", i) }\n"
  },
  {
    "path": "internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go",
    "content": "// Package openai provides response translation functionality for Gemini CLI to OpenAI API compatibility.\n// This package handles the conversion of Gemini CLI API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// convertCliResponseToOpenAIChatParams holds parameters for response conversion.\ntype convertCliResponseToOpenAIChatParams struct {\n\tUnixTimestamp        int64\n\tFunctionIndex        int\n\tSawToolCall          bool   // Tracks if any tool call was seen in the entire stream\n\tUpstreamFinishReason string // Caches the upstream finish reason for final chunk\n}\n\n// functionCallIDCounter provides a process-wide unique counter for function call identifiers.\nvar functionCallIDCounter uint64\n\n// ConvertAntigravityResponseToOpenAI translates a single chunk of a streaming response from the\n// Gemini CLI API format to the OpenAI Chat Completions streaming format.\n// It processes various Gemini CLI event types and transforms them into OpenAI-compatible JSON responses.\n// The function handles text content, tool calls, reasoning content, and usage metadata, outputting\n// responses that match the OpenAI API format. It supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &convertCliResponseToOpenAIChatParams{\n\t\t\tUnixTimestamp: 0,\n\t\t\tFunctionIndex: 0,\n\t\t}\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\t// Initialize the OpenAI SSE template.\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion.chunk\",\"created\":12345,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}]}`\n\n\t// Extract and set the model version.\n\tif modelVersionResult := gjson.GetBytes(rawJSON, \"response.modelVersion\"); modelVersionResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelVersionResult.String())\n\t}\n\n\t// Extract and set the creation timestamp.\n\tif createTimeResult := gjson.GetBytes(rawJSON, \"response.createTime\"); createTimeResult.Exists() {\n\t\tt, err := time.Parse(time.RFC3339Nano, createTimeResult.String())\n\t\tif err == nil {\n\t\t\t(*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp = t.Unix()\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)\n\t}\n\n\t// Extract and set the response ID.\n\tif responseIDResult := gjson.GetBytes(rawJSON, \"response.responseId\"); responseIDResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"id\", responseIDResult.String())\n\t}\n\n\t// Cache the finish reason - do NOT set it in output yet (will be set on final chunk)\n\tif finishReasonResult := gjson.GetBytes(rawJSON, \"response.candidates.0.finishReason\"); finishReasonResult.Exists() {\n\t\t(*param).(*convertCliResponseToOpenAIChatParams).UpstreamFinishReason = strings.ToUpper(finishReasonResult.String())\n\t}\n\n\t// Extract and set usage metadata (token counts).\n\tif usageResult := gjson.GetBytes(rawJSON, \"response.usageMetadata\"); usageResult.Exists() {\n\t\tcachedTokenCount := usageResult.Get(\"cachedContentTokenCount\").Int()\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", candidatesTokenCountResult.Int())\n\t\t}\n\t\tif totalTokenCountResult := usageResult.Get(\"totalTokenCount\"); totalTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", totalTokenCountResult.Int())\n\t\t}\n\t\tpromptTokenCount := usageResult.Get(\"promptTokenCount\").Int()\n\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", promptTokenCount)\n\t\tif thoughtsTokenCount > 0 {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens_details.reasoning_tokens\", thoughtsTokenCount)\n\t\t}\n\t\t// Include cached token count if present (indicates prompt caching is working)\n\t\tif cachedTokenCount > 0 {\n\t\t\tvar err error\n\t\t\ttemplate, err = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cachedTokenCount)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"antigravity openai response: failed to set cached_tokens: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process the main content part of the response.\n\tpartsResult := gjson.GetBytes(rawJSON, \"response.candidates.0.content.parts\")\n\tif partsResult.IsArray() {\n\t\tpartResults := partsResult.Array()\n\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\tpartResult := partResults[i]\n\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\t\t\tthoughtSignatureResult := partResult.Get(\"thoughtSignature\")\n\t\t\tif !thoughtSignatureResult.Exists() {\n\t\t\t\tthoughtSignatureResult = partResult.Get(\"thought_signature\")\n\t\t\t}\n\t\t\tinlineDataResult := partResult.Get(\"inlineData\")\n\t\t\tif !inlineDataResult.Exists() {\n\t\t\t\tinlineDataResult = partResult.Get(\"inline_data\")\n\t\t\t}\n\n\t\t\thasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != \"\"\n\t\t\thasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()\n\n\t\t\t// Ignore encrypted thoughtSignature but keep any actual content in the same part.\n\t\t\tif hasThoughtSignature && !hasContentPayload {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif partTextResult.Exists() {\n\t\t\t\ttextContent := partTextResult.String()\n\n\t\t\t\t// Handle text content, distinguishing between regular content and reasoning/thoughts.\n\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", textContent)\n\t\t\t\t} else {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.content\", textContent)\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t// Handle function call content.\n\t\t\t\t(*param).(*convertCliResponseToOpenAIChatParams).SawToolCall = true // Persist across chunks\n\t\t\t\ttoolCallsResult := gjson.Get(template, \"choices.0.delta.tool_calls\")\n\t\t\t\tfunctionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex\n\t\t\t\t(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++\n\t\t\t\tif toolCallsResult.Exists() && toolCallsResult.IsArray() {\n\t\t\t\t\tfunctionCallIndex = len(toolCallsResult.Array())\n\t\t\t\t} else {\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\t\t\t}\n\n\t\t\t\tfunctionCallTemplate := `{\"id\": \"\",\"index\": 0,\"type\": \"function\",\"function\": {\"name\": \"\",\"arguments\": \"\"}}`\n\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"id\", fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"index\", functionCallIndex)\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.name\", fcName)\n\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.arguments\", fcArgsResult.Raw)\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallTemplate)\n\t\t\t} else if inlineDataResult.Exists() {\n\t\t\t\tdata := inlineDataResult.Get(\"data\").String()\n\t\t\t\tif data == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmimeType := inlineDataResult.Get(\"mimeType\").String()\n\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\tmimeType = inlineDataResult.Get(\"mime_type\").String()\n\t\t\t\t}\n\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\t}\n\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\t\t\t\timagesResult := gjson.Get(template, \"choices.0.delta.images\")\n\t\t\t\tif !imagesResult.Exists() || !imagesResult.IsArray() {\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images\", `[]`)\n\t\t\t\t}\n\t\t\t\timageIndex := len(gjson.Get(template, \"choices.0.delta.images\").Array())\n\t\t\t\timagePayload := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"index\", imageIndex)\n\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"image_url.url\", imageURL)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images.-1\", imagePayload)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Determine finish_reason only on the final chunk (has both finishReason and usage metadata)\n\tparams := (*param).(*convertCliResponseToOpenAIChatParams)\n\tupstreamFinishReason := params.UpstreamFinishReason\n\tsawToolCall := params.SawToolCall\n\n\tusageExists := gjson.GetBytes(rawJSON, \"response.usageMetadata\").Exists()\n\tisFinalChunk := upstreamFinishReason != \"\" && usageExists\n\n\tif isFinalChunk {\n\t\tvar finishReason string\n\t\tif sawToolCall {\n\t\t\tfinishReason = \"tool_calls\"\n\t\t} else if upstreamFinishReason == \"MAX_TOKENS\" {\n\t\t\tfinishReason = \"max_tokens\"\n\t\t} else {\n\t\t\tfinishReason = \"stop\"\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", finishReason)\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", strings.ToLower(upstreamFinishReason))\n\t}\n\n\treturn []string{template}\n}\n\n// ConvertAntigravityResponseToOpenAINonStream converts a non-streaming Gemini CLI response to a non-streaming OpenAI response.\n// This function processes the complete Gemini CLI response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertAntigravityResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\treturn ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, []byte(responseResult.Raw), param)\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go",
    "content": "package chat_completions\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestFinishReasonToolCallsNotOverwritten(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\t// Chunk 1: Contains functionCall - should set SawToolCall = true\n\tchunk1 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"list_files\",\"args\":{\"path\":\".\"}}}]}}]}}`)\n\tresult1 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk1, &param)\n\n\t// Verify chunk1 has no finish_reason (null)\n\tif len(result1) != 1 {\n\t\tt.Fatalf(\"Expected 1 result from chunk1, got %d\", len(result1))\n\t}\n\tfr1 := gjson.Get(result1[0], \"choices.0.finish_reason\")\n\tif fr1.Exists() && fr1.String() != \"\" && fr1.Type.String() != \"Null\" {\n\t\tt.Errorf(\"Expected finish_reason to be null in chunk1, got: %v\", fr1.String())\n\t}\n\n\t// Chunk 2: Contains finishReason STOP + usage (final chunk, no functionCall)\n\t// This simulates what the upstream sends AFTER the tool call chunk\n\tchunk2 := []byte(`{\"response\":{\"candidates\":[{\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":20,\"totalTokenCount\":30}}}`)\n\tresult2 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk2, &param)\n\n\t// Verify chunk2 has finish_reason: \"tool_calls\" (not \"stop\")\n\tif len(result2) != 1 {\n\t\tt.Fatalf(\"Expected 1 result from chunk2, got %d\", len(result2))\n\t}\n\tfr2 := gjson.Get(result2[0], \"choices.0.finish_reason\").String()\n\tif fr2 != \"tool_calls\" {\n\t\tt.Errorf(\"Expected finish_reason 'tool_calls', got: %s\", fr2)\n\t}\n\n\t// Verify native_finish_reason is lowercase upstream value\n\tnfr2 := gjson.Get(result2[0], \"choices.0.native_finish_reason\").String()\n\tif nfr2 != \"stop\" {\n\t\tt.Errorf(\"Expected native_finish_reason 'stop', got: %s\", nfr2)\n\t}\n}\n\nfunc TestFinishReasonStopForNormalText(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\t// Chunk 1: Text content only\n\tchunk1 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello world\"}]}}]}}`)\n\tConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk1, &param)\n\n\t// Chunk 2: Final chunk with STOP\n\tchunk2 := []byte(`{\"response\":{\"candidates\":[{\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":5,\"totalTokenCount\":15}}}`)\n\tresult2 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk2, &param)\n\n\t// Verify finish_reason is \"stop\" (no tool calls were made)\n\tfr := gjson.Get(result2[0], \"choices.0.finish_reason\").String()\n\tif fr != \"stop\" {\n\t\tt.Errorf(\"Expected finish_reason 'stop', got: %s\", fr)\n\t}\n}\n\nfunc TestFinishReasonMaxTokens(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\t// Chunk 1: Text content\n\tchunk1 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}]}}]}}`)\n\tConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk1, &param)\n\n\t// Chunk 2: Final chunk with MAX_TOKENS\n\tchunk2 := []byte(`{\"response\":{\"candidates\":[{\"finishReason\":\"MAX_TOKENS\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":100,\"totalTokenCount\":110}}}`)\n\tresult2 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk2, &param)\n\n\t// Verify finish_reason is \"max_tokens\"\n\tfr := gjson.Get(result2[0], \"choices.0.finish_reason\").String()\n\tif fr != \"max_tokens\" {\n\t\tt.Errorf(\"Expected finish_reason 'max_tokens', got: %s\", fr)\n\t}\n}\n\nfunc TestToolCallTakesPriorityOverMaxTokens(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\t// Chunk 1: Contains functionCall\n\tchunk1 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"test\",\"args\":{}}}]}}]}}`)\n\tConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk1, &param)\n\n\t// Chunk 2: Final chunk with MAX_TOKENS (but we had a tool call, so tool_calls should win)\n\tchunk2 := []byte(`{\"response\":{\"candidates\":[{\"finishReason\":\"MAX_TOKENS\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":100,\"totalTokenCount\":110}}}`)\n\tresult2 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk2, &param)\n\n\t// Verify finish_reason is \"tool_calls\" (takes priority over max_tokens)\n\tfr := gjson.Get(result2[0], \"choices.0.finish_reason\").String()\n\tif fr != \"tool_calls\" {\n\t\tt.Errorf(\"Expected finish_reason 'tool_calls', got: %s\", fr)\n\t}\n}\n\nfunc TestNoFinishReasonOnIntermediateChunks(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\t// Chunk 1: Text content (no finish reason, no usage)\n\tchunk1 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}]}}]}}`)\n\tresult1 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk1, &param)\n\n\t// Verify no finish_reason on intermediate chunk\n\tfr1 := gjson.Get(result1[0], \"choices.0.finish_reason\")\n\tif fr1.Exists() && fr1.String() != \"\" && fr1.Type.String() != \"Null\" {\n\t\tt.Errorf(\"Expected no finish_reason on intermediate chunk, got: %v\", fr1)\n\t}\n\n\t// Chunk 2: More text (no finish reason, no usage)\n\tchunk2 := []byte(`{\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\" world\"}]}}]}}`)\n\tresult2 := ConvertAntigravityResponseToOpenAI(ctx, \"model\", nil, nil, chunk2, &param)\n\n\t// Verify no finish_reason on intermediate chunk\n\tfr2 := gjson.Get(result2[0], \"choices.0.finish_reason\")\n\tif fr2.Exists() && fr2.String() != \"\" && fr2.Type.String() != \"Null\" {\n\t\tt.Errorf(\"Expected no finish_reason on intermediate chunk, got: %v\", fr2)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tAntigravity,\n\t\tConvertOpenAIRequestToAntigravity,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertAntigravityResponseToOpenAI,\n\t\t\tNonStream: ConvertAntigravityResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses\"\n)\n\nfunc ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\trawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)\n\treturn ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"context\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\trawJSON = []byte(responseResult.Raw)\n\t}\n\treturn ConvertGeminiResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\nfunc ConvertAntigravityResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\trawJSON = []byte(responseResult.Raw)\n\t}\n\n\trequestResult := gjson.GetBytes(originalRequestRawJSON, \"request\")\n\tif responseResult.Exists() {\n\t\toriginalRequestRawJSON = []byte(requestResult.Raw)\n\t}\n\n\trequestResult = gjson.GetBytes(requestRawJSON, \"request\")\n\tif responseResult.Exists() {\n\t\trequestRawJSON = []byte(requestResult.Raw)\n\t}\n\n\treturn ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n"
  },
  {
    "path": "internal/translator/antigravity/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tAntigravity,\n\t\tConvertOpenAIResponsesRequestToAntigravity,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertAntigravityResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertAntigravityResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini/claude_gemini_request.go",
    "content": "// Package gemini provides request translation functionality for Gemini to Claude Code API compatibility.\n// It handles parsing and transforming Gemini API requests into Claude Code API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini API format and Claude Code API's expected format.\npackage gemini\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tuser    = \"\"\n\taccount = \"\"\n\tsession = \"\"\n)\n\n// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Claude Code API.\n// The function performs comprehensive transformation including:\n// 1. Model name mapping and generation configuration extraction\n// 2. System instruction conversion to Claude Code format\n// 3. Message content conversion with proper role mapping\n// 4. Tool call and tool result handling with FIFO queue for ID matching\n// 5. Image and file data conversion to Claude Code base64 format\n// 6. Tool declaration and tool choice configuration mapping\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Gemini API\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in Claude Code API format\nfunc ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\tif account == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\taccount = u.String()\n\t}\n\tif session == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\tsession = u.String()\n\t}\n\tif user == \"\" {\n\t\tsum := sha256.Sum256([]byte(account + session))\n\t\tuser = hex.EncodeToString(sum[:])\n\t}\n\tuserID := fmt.Sprintf(\"user_%s_account_%s_session_%s\", user, account, session)\n\n\t// Base Claude message payload\n\tout := fmt.Sprintf(`{\"model\":\"\",\"max_tokens\":32000,\"messages\":[],\"metadata\":{\"user_id\":\"%s\"}}`, userID)\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Helper for generating tool call IDs in the form: toolu_<alphanum>\n\t// This ensures unique identifiers for tool calls in the Claude Code format\n\tgenToolCallID := func() string {\n\t\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\tvar b strings.Builder\n\t\t// 24 chars random suffix for uniqueness\n\t\tfor i := 0; i < 24; i++ {\n\t\t\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\t\tb.WriteByte(letters[n.Int64()])\n\t\t}\n\t\treturn \"toolu_\" + b.String()\n\t}\n\n\t// FIFO queue to store tool call IDs for matching with tool results\n\t// Gemini uses sequential pairing across possibly multiple in-flight\n\t// functionCalls, so we keep a FIFO queue of generated tool IDs and\n\t// consume them in order when functionResponses arrive.\n\tvar pendingToolIDs []string\n\n\t// Model mapping to specify which Claude Code model to use\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Generation config extraction from Gemini format\n\tif genConfig := root.Get(\"generationConfig\"); genConfig.Exists() {\n\t\t// Max output tokens configuration\n\t\tif maxTokens := genConfig.Get(\"maxOutputTokens\"); maxTokens.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t\t}\n\t\t// Temperature setting for controlling response randomness\n\t\tif temp := genConfig.Get(\"temperature\"); temp.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"temperature\", temp.Float())\n\t\t} else if topP := genConfig.Get(\"topP\"); topP.Exists() {\n\t\t\t// Top P setting for nucleus sampling (filtered out if temperature is set)\n\t\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t\t}\n\t\t// Stop sequences configuration for custom termination conditions\n\t\tif stopSeqs := genConfig.Get(\"stopSequences\"); stopSeqs.Exists() && stopSeqs.IsArray() {\n\t\t\tvar stopSequences []string\n\t\t\tstopSeqs.ForEach(func(_, value gjson.Result) bool {\n\t\t\t\tstopSequences = append(stopSequences, value.String())\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif len(stopSequences) > 0 {\n\t\t\t\tout, _ = sjson.Set(out, \"stop_sequences\", stopSequences)\n\t\t\t}\n\t\t}\n\t\t// Include thoughts configuration for reasoning process visibility\n\t\t// Translator only does format conversion, ApplyThinking handles model capability validation.\n\t\tif thinkingConfig := genConfig.Get(\"thinkingConfig\"); thinkingConfig.Exists() && thinkingConfig.IsObject() {\n\t\t\tmi := registry.LookupModelInfo(modelName, \"claude\")\n\t\t\tsupportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0\n\t\t\tsupportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))\n\n\t\t\t// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid\n\t\t\t// validation errors since validate treats same-provider unsupported levels as errors.\n\t\t\tthinkingLevel := thinkingConfig.Get(\"thinkingLevel\")\n\t\t\tif !thinkingLevel.Exists() {\n\t\t\t\tthinkingLevel = thinkingConfig.Get(\"thinking_level\")\n\t\t\t}\n\t\t\tif thinkingLevel.Exists() {\n\t\t\t\tlevel := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))\n\t\t\t\tif supportsAdaptive {\n\t\t\t\t\tswitch level {\n\t\t\t\t\tcase \"\":\n\t\t\t\t\tcase \"none\":\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {\n\t\t\t\t\t\t\tlevel = mapped\n\t\t\t\t\t\t}\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"output_config.effort\", level)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tswitch level {\n\t\t\t\t\tcase \"\":\n\t\t\t\t\tcase \"none\":\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tcase \"auto\":\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif budget, ok := thinking.ConvertLevelToBudget(level); ok {\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.budget_tokens\", budget)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthinkingBudget := thinkingConfig.Get(\"thinkingBudget\")\n\t\t\t\tif !thinkingBudget.Exists() {\n\t\t\t\t\tthinkingBudget = thinkingConfig.Get(\"thinking_budget\")\n\t\t\t\t}\n\t\t\t\tif thinkingBudget.Exists() {\n\t\t\t\t\tbudget := int(thinkingBudget.Int())\n\t\t\t\t\tif supportsAdaptive {\n\t\t\t\t\t\tswitch budget {\n\t\t\t\t\t\tcase 0:\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\tlevel, ok := thinking.ConvertBudgetToLevel(budget)\n\t\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\t\tif mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {\n\t\t\t\t\t\t\t\t\tlevel = mapped\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"output_config.effort\", level)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tswitch budget {\n\t\t\t\t\t\tcase 0:\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\tcase -1:\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.budget_tokens\", budget)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if includeThoughts := thinkingConfig.Get(\"includeThoughts\"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t} else if includeThoughts := thinkingConfig.Get(\"include_thoughts\"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// System instruction conversion to Claude Code format\n\tif sysInstr := root.Get(\"system_instruction\"); sysInstr.Exists() {\n\t\tif parts := sysInstr.Get(\"parts\"); parts.Exists() && parts.IsArray() {\n\t\t\tvar systemText strings.Builder\n\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\tif text := part.Get(\"text\"); text.Exists() {\n\t\t\t\t\tif systemText.Len() > 0 {\n\t\t\t\t\t\tsystemText.WriteString(\"\\n\")\n\t\t\t\t\t}\n\t\t\t\t\tsystemText.WriteString(text.String())\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif systemText.Len() > 0 {\n\t\t\t\t// Create system message in Claude Code format\n\t\t\t\tsystemMessage := `{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"\"}]}`\n\t\t\t\tsystemMessage, _ = sjson.Set(systemMessage, \"content.0.text\", systemText.String())\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", systemMessage)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Contents conversion to messages with proper role mapping\n\tif contents := root.Get(\"contents\"); contents.Exists() && contents.IsArray() {\n\t\tcontents.ForEach(func(_, content gjson.Result) bool {\n\t\t\trole := content.Get(\"role\").String()\n\t\t\t// Map Gemini roles to Claude Code roles\n\t\t\tif role == \"model\" {\n\t\t\t\trole = \"assistant\"\n\t\t\t}\n\n\t\t\tif role == \"function\" {\n\t\t\t\trole = \"user\"\n\t\t\t}\n\n\t\t\tif role == \"tool\" {\n\t\t\t\trole = \"user\"\n\t\t\t}\n\n\t\t\t// Create message structure in Claude Code format\n\t\t\tmsg := `{\"role\":\"\",\"content\":[]}`\n\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\n\t\t\tif parts := content.Get(\"parts\"); parts.Exists() && parts.IsArray() {\n\t\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t// Text content conversion\n\t\t\t\t\tif text := part.Get(\"text\"); text.Exists() {\n\t\t\t\t\t\ttextContent := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\ttextContent, _ = sjson.Set(textContent, \"text\", text.String())\n\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", textContent)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// Function call (from model/assistant) conversion to tool use\n\t\t\t\t\tif fc := part.Get(\"functionCall\"); fc.Exists() && role == \"assistant\" {\n\t\t\t\t\t\ttoolUse := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\n\t\t\t\t\t\t// Generate a unique tool ID and enqueue it for later matching\n\t\t\t\t\t\t// with the corresponding functionResponse\n\t\t\t\t\t\ttoolID := genToolCallID()\n\t\t\t\t\t\tpendingToolIDs = append(pendingToolIDs, toolID)\n\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"id\", toolID)\n\n\t\t\t\t\t\tif name := fc.Get(\"name\"); name.Exists() {\n\t\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"name\", name.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif args := fc.Get(\"args\"); args.Exists() && args.IsObject() {\n\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", args.Raw)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", toolUse)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// Function response (from user) conversion to tool result\n\t\t\t\t\tif fr := part.Get(\"functionResponse\"); fr.Exists() {\n\t\t\t\t\t\ttoolResult := `{\"type\":\"tool_result\",\"tool_use_id\":\"\",\"content\":\"\"}`\n\n\t\t\t\t\t\t// Attach the oldest queued tool_id to pair the response\n\t\t\t\t\t\t// with its call. If the queue is empty, generate a new id.\n\t\t\t\t\t\tvar toolID string\n\t\t\t\t\t\tif len(pendingToolIDs) > 0 {\n\t\t\t\t\t\t\ttoolID = pendingToolIDs[0]\n\t\t\t\t\t\t\t// Pop the first element from the queue\n\t\t\t\t\t\t\tpendingToolIDs = pendingToolIDs[1:]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Fallback: generate new ID if no pending tool_use found\n\t\t\t\t\t\t\ttoolID = genToolCallID()\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttoolResult, _ = sjson.Set(toolResult, \"tool_use_id\", toolID)\n\n\t\t\t\t\t\t// Extract result content from the function response\n\t\t\t\t\t\tif result := fr.Get(\"response.result\"); result.Exists() {\n\t\t\t\t\t\t\ttoolResult, _ = sjson.Set(toolResult, \"content\", result.String())\n\t\t\t\t\t\t} else if response := fr.Get(\"response\"); response.Exists() {\n\t\t\t\t\t\t\ttoolResult, _ = sjson.Set(toolResult, \"content\", response.Raw)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", toolResult)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// Image content (inline_data) conversion to Claude Code format\n\t\t\t\t\tif inlineData := part.Get(\"inline_data\"); inlineData.Exists() {\n\t\t\t\t\t\timageContent := `{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\tif mimeType := inlineData.Get(\"mime_type\"); mimeType.Exists() {\n\t\t\t\t\t\t\timageContent, _ = sjson.Set(imageContent, \"source.media_type\", mimeType.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif data := inlineData.Get(\"data\"); data.Exists() {\n\t\t\t\t\t\t\timageContent, _ = sjson.Set(imageContent, \"source.data\", data.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", imageContent)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// File data conversion to text content with file info\n\t\t\t\t\tif fileData := part.Get(\"file_data\"); fileData.Exists() {\n\t\t\t\t\t\t// For file data, we'll convert to text content with file info\n\t\t\t\t\t\ttextContent := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\tfileInfo := \"File: \" + fileData.Get(\"file_uri\").String()\n\t\t\t\t\t\tif mimeType := fileData.Get(\"mime_type\"); mimeType.Exists() {\n\t\t\t\t\t\t\tfileInfo += \" (Type: \" + mimeType.String() + \")\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttextContent, _ = sjson.Set(textContent, \"text\", fileInfo)\n\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", textContent)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Only add message if it has content\n\t\t\tif contentArray := gjson.Get(msg, \"content\"); contentArray.Exists() && len(contentArray.Array()) > 0 {\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Tools mapping: Gemini functionDeclarations -> Claude Code tools\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\tvar anthropicTools []interface{}\n\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tif funcDecls := tool.Get(\"functionDeclarations\"); funcDecls.Exists() && funcDecls.IsArray() {\n\t\t\t\tfuncDecls.ForEach(func(_, funcDecl gjson.Result) bool {\n\t\t\t\t\tanthropicTool := `{\"name\":\"\",\"description\":\"\",\"input_schema\":{}}`\n\n\t\t\t\t\tif name := funcDecl.Get(\"name\"); name.Exists() {\n\t\t\t\t\t\tanthropicTool, _ = sjson.Set(anthropicTool, \"name\", name.String())\n\t\t\t\t\t}\n\t\t\t\t\tif desc := funcDecl.Get(\"description\"); desc.Exists() {\n\t\t\t\t\t\tanthropicTool, _ = sjson.Set(anthropicTool, \"description\", desc.String())\n\t\t\t\t\t}\n\t\t\t\t\tif params := funcDecl.Get(\"parameters\"); params.Exists() {\n\t\t\t\t\t\t// Clean up the parameters schema for Claude Code compatibility\n\t\t\t\t\t\tcleaned := params.Raw\n\t\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"additionalProperties\", false)\n\t\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"$schema\", \"http://json-schema.org/draft-07/schema#\")\n\t\t\t\t\t\tanthropicTool, _ = sjson.SetRaw(anthropicTool, \"input_schema\", cleaned)\n\t\t\t\t\t} else if params = funcDecl.Get(\"parametersJsonSchema\"); params.Exists() {\n\t\t\t\t\t\t// Clean up the parameters schema for Claude Code compatibility\n\t\t\t\t\t\tcleaned := params.Raw\n\t\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"additionalProperties\", false)\n\t\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"$schema\", \"http://json-schema.org/draft-07/schema#\")\n\t\t\t\t\t\tanthropicTool, _ = sjson.SetRaw(anthropicTool, \"input_schema\", cleaned)\n\t\t\t\t\t}\n\n\t\t\t\t\tanthropicTools = append(anthropicTools, gjson.Parse(anthropicTool).Value())\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\tif len(anthropicTools) > 0 {\n\t\t\tout, _ = sjson.Set(out, \"tools\", anthropicTools)\n\t\t}\n\t}\n\n\t// Tool config mapping from Gemini format to Claude Code format\n\tif toolConfig := root.Get(\"tool_config\"); toolConfig.Exists() {\n\t\tif funcCalling := toolConfig.Get(\"function_calling_config\"); funcCalling.Exists() {\n\t\t\tif mode := funcCalling.Get(\"mode\"); mode.Exists() {\n\t\t\t\tswitch mode.String() {\n\t\t\t\tcase \"AUTO\":\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"auto\"}`)\n\t\t\t\tcase \"NONE\":\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"none\"}`)\n\t\t\t\tcase \"ANY\":\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"any\"}`)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Stream setting configuration\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Convert tool parameter types to lowercase for Claude Code compatibility\n\tvar pathsToLower []string\n\ttoolsResult := gjson.Get(out, \"tools\")\n\tutil.Walk(toolsResult, \"\", \"type\", &pathsToLower)\n\tfor _, p := range pathsToLower {\n\t\tfullPath := fmt.Sprintf(\"tools.%s\", p)\n\t\tout, _ = sjson.Set(out, fullPath, strings.ToLower(gjson.Get(out, fullPath).String()))\n\t}\n\n\treturn []byte(out)\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini/claude_gemini_response.go",
    "content": "// Package gemini provides response translation functionality for Claude Code to Gemini API compatibility.\n// This package handles the conversion of Claude Code API responses into Gemini-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, and usage metadata appropriately.\npackage gemini\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertAnthropicResponseToGeminiParams holds parameters for response conversion\n// It also carries minimal streaming state across calls to assemble tool_use input_json_delta.\n// This structure maintains state information needed for proper conversion of streaming responses\n// from Claude Code format to Gemini format, particularly for handling tool calls that span\n// multiple streaming events.\ntype ConvertAnthropicResponseToGeminiParams struct {\n\tModel             string\n\tCreatedAt         int64\n\tResponseID        string\n\tLastStorageOutput string\n\tIsStreaming       bool\n\n\t// Streaming state for tool_use assembly\n\t// Keyed by content_block index from Claude SSE events\n\tToolUseNames map[int]string           // function/tool name per block index\n\tToolUseArgs  map[int]*strings.Builder // accumulates partial_json across deltas\n}\n\n// ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format.\n// This function processes various Claude Code event types and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, reasoning content, and usage metadata, outputting responses that match\n// the Gemini API format. The function supports incremental updates for streaming responses and maintains\n// state information to properly assemble multi-part tool calls.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response\nfunc ConvertClaudeResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertAnthropicResponseToGeminiParams{\n\t\t\tModel:      modelName,\n\t\t\tCreatedAt:  0,\n\t\t\tResponseID: \"\",\n\t\t}\n\t}\n\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\troot := gjson.ParseBytes(rawJSON)\n\teventType := root.Get(\"type\").String()\n\n\t// Base Gemini response template with default values\n\ttemplate := `{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[]}}],\"usageMetadata\":{\"trafficType\":\"PROVISIONED_THROUGHPUT\"},\"modelVersion\":\"\",\"createTime\":\"\",\"responseId\":\"\"}`\n\n\t// Set model version\n\tif (*param).(*ConvertAnthropicResponseToGeminiParams).Model != \"\" {\n\t\t// Map Claude model names back to Gemini model names\n\t\ttemplate, _ = sjson.Set(template, \"modelVersion\", (*param).(*ConvertAnthropicResponseToGeminiParams).Model)\n\t}\n\n\t// Set response ID and creation time\n\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"responseId\", (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID)\n\t}\n\n\t// Set creation time to current time if not provided\n\tif (*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt == 0 {\n\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt = time.Now().Unix()\n\t}\n\ttemplate, _ = sjson.Set(template, \"createTime\", time.Unix((*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))\n\n\tswitch eventType {\n\tcase \"message_start\":\n\t\t// Initialize response with message metadata when a new message begins\n\t\tif message := root.Get(\"message\"); message.Exists() {\n\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = message.Get(\"id\").String()\n\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).Model = message.Get(\"model\").String()\n\t\t}\n\t\treturn []string{}\n\n\tcase \"content_block_start\":\n\t\t// Start of a content block - record tool_use name by index for functionCall assembly\n\t\tif cb := root.Get(\"content_block\"); cb.Exists() {\n\t\t\tif cb.Get(\"type\").String() == \"tool_use\" {\n\t\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames == nil {\n\t\t\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames = map[int]string{}\n\t\t\t\t}\n\t\t\t\tif name := cb.Get(\"name\"); name.Exists() {\n\t\t\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\n\tcase \"content_block_delta\":\n\t\t// Handle content delta (text, thinking, or tool use arguments)\n\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\tdeltaType := delta.Get(\"type\").String()\n\n\t\t\tswitch deltaType {\n\t\t\tcase \"text_delta\":\n\t\t\t\t// Regular text content delta for normal response text\n\t\t\t\tif text := delta.Get(\"text\"); text.Exists() && text.String() != \"\" {\n\t\t\t\t\ttextPart := `{\"text\":\"\"}`\n\t\t\t\t\ttextPart, _ = sjson.Set(textPart, \"text\", text.String())\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", textPart)\n\t\t\t\t}\n\t\t\tcase \"thinking_delta\":\n\t\t\t\t// Thinking/reasoning content delta for models with reasoning capabilities\n\t\t\t\tif text := delta.Get(\"thinking\"); text.Exists() && text.String() != \"\" {\n\t\t\t\t\tthinkingPart := `{\"thought\":true,\"text\":\"\"}`\n\t\t\t\t\tthinkingPart, _ = sjson.Set(thinkingPart, \"text\", text.String())\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", thinkingPart)\n\t\t\t\t}\n\t\t\tcase \"input_json_delta\":\n\t\t\t\t// Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop\n\t\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs == nil {\n\t\t\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs = map[int]*strings.Builder{}\n\t\t\t\t}\n\t\t\t\tb, ok := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]\n\t\t\t\tif !ok || b == nil {\n\t\t\t\t\tbb := &strings.Builder{}\n\t\t\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx] = bb\n\t\t\t\t\tb = bb\n\t\t\t\t}\n\t\t\t\tif pj := delta.Get(\"partial_json\"); pj.Exists() {\n\t\t\t\t\tb.WriteString(pj.String())\n\t\t\t\t}\n\t\t\t\treturn []string{}\n\t\t\t}\n\t\t}\n\t\treturn []string{template}\n\n\tcase \"content_block_stop\":\n\t\t// End of content block - finalize tool calls if any\n\t\tidx := int(root.Get(\"index\").Int())\n\t\t// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)\n\t\t// So we finalize using accumulated state captured during content_block_start and input_json_delta.\n\t\tname := \"\"\n\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {\n\t\t\tname = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx]\n\t\t}\n\t\tvar argsTrim string\n\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {\n\t\t\tif b := (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs[idx]; b != nil {\n\t\t\t\targsTrim = strings.TrimSpace(b.String())\n\t\t\t}\n\t\t}\n\t\tif name != \"\" || argsTrim != \"\" {\n\t\t\tfunctionCall := `{\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\tif name != \"\" {\n\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"functionCall.name\", name)\n\t\t\t}\n\t\t\tif argsTrim != \"\" {\n\t\t\t\tfunctionCall, _ = sjson.SetRaw(functionCall, \"functionCall.args\", argsTrim)\n\t\t\t}\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", functionCall)\n\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t\t(*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = template\n\t\t\t// cleanup used state for this index\n\t\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {\n\t\t\t\tdelete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs, idx)\n\t\t\t}\n\t\t\tif (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {\n\t\t\t\tdelete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames, idx)\n\t\t\t}\n\t\t\treturn []string{template}\n\t\t}\n\t\treturn []string{}\n\n\tcase \"message_delta\":\n\t\t// Handle message-level changes (like stop reason and usage information)\n\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\tif stopReason := delta.Get(\"stop_reason\"); stopReason.Exists() {\n\t\t\t\tswitch stopReason.String() {\n\t\t\t\tcase \"end_turn\":\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t\t\tcase \"tool_use\":\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t\t\tcase \"max_tokens\":\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"MAX_TOKENS\")\n\t\t\t\tcase \"stop_sequence\":\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t\t\tdefault:\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t// Basic token counts for prompt and completion\n\t\t\tinputTokens := usage.Get(\"input_tokens\").Int()\n\t\t\toutputTokens := usage.Get(\"output_tokens\").Int()\n\n\t\t\t// Set basic usage metadata according to Gemini API specification\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.promptTokenCount\", inputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.candidatesTokenCount\", outputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.totalTokenCount\", inputTokens+outputTokens)\n\n\t\t\t// Add cache-related token counts if present (Claude Code API cache fields)\n\t\t\tif cacheCreationTokens := usage.Get(\"cache_creation_input_tokens\"); cacheCreationTokens.Exists() {\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.cachedContentTokenCount\", cacheCreationTokens.Int())\n\t\t\t}\n\t\t\tif cacheReadTokens := usage.Get(\"cache_read_input_tokens\"); cacheReadTokens.Exists() {\n\t\t\t\t// Add cache read tokens to cached content count\n\t\t\t\texistingCacheTokens := usage.Get(\"cache_creation_input_tokens\").Int()\n\t\t\t\ttotalCacheTokens := existingCacheTokens + cacheReadTokens.Int()\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.cachedContentTokenCount\", totalCacheTokens)\n\t\t\t}\n\n\t\t\t// Add thinking tokens if present (for models with reasoning capabilities)\n\t\t\tif thinkingTokens := usage.Get(\"thinking_tokens\"); thinkingTokens.Exists() {\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.thoughtsTokenCount\", thinkingTokens.Int())\n\t\t\t}\n\n\t\t\t// Set traffic type (required by Gemini API)\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.trafficType\", \"PROVISIONED_THROUGHPUT\")\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\n\t\treturn []string{template}\n\tcase \"message_stop\":\n\t\t// Final message with usage information - no additional output needed\n\t\treturn []string{}\n\tcase \"error\":\n\t\t// Handle error responses and convert to Gemini error format\n\t\terrorMsg := root.Get(\"error.message\").String()\n\t\tif errorMsg == \"\" {\n\t\t\terrorMsg = \"Unknown error occurred\"\n\t\t}\n\n\t\t// Create error response in Gemini format\n\t\terrorResponse := `{\"error\":{\"code\":400,\"message\":\"\",\"status\":\"INVALID_ARGUMENT\"}}`\n\t\terrorResponse, _ = sjson.Set(errorResponse, \"error.message\", errorMsg)\n\t\treturn []string{errorResponse}\n\n\tdefault:\n\t\t// Unknown event type, return empty response\n\t\treturn []string{}\n\t}\n}\n\n// ConvertClaudeResponseToGeminiNonStream converts a non-streaming Claude Code response to a non-streaming Gemini response.\n// This function processes the complete Claude Code response and transforms it into a single Gemini-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the Gemini API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response containing all message content and metadata\nfunc ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t// Base Gemini response template for non-streaming with default values\n\ttemplate := `{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"trafficType\":\"PROVISIONED_THROUGHPUT\"},\"modelVersion\":\"\",\"createTime\":\"\",\"responseId\":\"\"}`\n\n\t// Set model version\n\ttemplate, _ = sjson.Set(template, \"modelVersion\", modelName)\n\n\tstreamingEvents := make([][]byte, 0)\n\n\tscanner := bufio.NewScanner(bytes.NewReader(rawJSON))\n\tbuffer := make([]byte, 52_428_800) // 50MB\n\tscanner.Buffer(buffer, 52_428_800)\n\tfor scanner.Scan() {\n\t\tline := scanner.Bytes()\n\t\t// log.Debug(string(line))\n\t\tif bytes.HasPrefix(line, dataTag) {\n\t\t\tjsonData := bytes.TrimSpace(line[5:])\n\t\t\tstreamingEvents = append(streamingEvents, jsonData)\n\t\t}\n\t}\n\t// log.Debug(\"streamingEvents: \", streamingEvents)\n\t// log.Debug(\"rawJSON: \", string(rawJSON))\n\n\t// Initialize parameters for streaming conversion with proper state management\n\tnewParam := &ConvertAnthropicResponseToGeminiParams{\n\t\tModel:             modelName,\n\t\tCreatedAt:         0,\n\t\tResponseID:        \"\",\n\t\tLastStorageOutput: \"\",\n\t\tIsStreaming:       false,\n\t\tToolUseNames:      nil,\n\t\tToolUseArgs:       nil,\n\t}\n\n\t// Process each streaming event and collect parts\n\tvar allParts []string\n\tvar finalUsageJSON string\n\tvar responseID string\n\tvar createdAt int64\n\n\tfor _, eventData := range streamingEvents {\n\t\tif len(eventData) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\troot := gjson.ParseBytes(eventData)\n\t\teventType := root.Get(\"type\").String()\n\n\t\tswitch eventType {\n\t\tcase \"message_start\":\n\t\t\t// Extract response metadata including ID, model, and creation time\n\t\t\tif message := root.Get(\"message\"); message.Exists() {\n\t\t\t\tresponseID = message.Get(\"id\").String()\n\t\t\t\tnewParam.ResponseID = responseID\n\t\t\t\tnewParam.Model = message.Get(\"model\").String()\n\n\t\t\t\t// Set creation time to current time if not provided\n\t\t\t\tcreatedAt = time.Now().Unix()\n\t\t\t\tnewParam.CreatedAt = createdAt\n\t\t\t}\n\n\t\tcase \"content_block_start\":\n\t\t\t// Prepare for content block; record tool_use name by index for later functionCall assembly\n\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\tif cb := root.Get(\"content_block\"); cb.Exists() {\n\t\t\t\tif cb.Get(\"type\").String() == \"tool_use\" {\n\t\t\t\t\tif newParam.ToolUseNames == nil {\n\t\t\t\t\t\tnewParam.ToolUseNames = map[int]string{}\n\t\t\t\t\t}\n\t\t\t\t\tif name := cb.Get(\"name\"); name.Exists() {\n\t\t\t\t\t\tnewParam.ToolUseNames[idx] = name.String()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\n\t\tcase \"content_block_delta\":\n\t\t\t// Handle content delta (text, thinking, or tool input)\n\t\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\t\tdeltaType := delta.Get(\"type\").String()\n\t\t\t\tswitch deltaType {\n\t\t\t\tcase \"text_delta\":\n\t\t\t\t\t// Process regular text content\n\t\t\t\t\tif text := delta.Get(\"text\"); text.Exists() && text.String() != \"\" {\n\t\t\t\t\t\tpartJSON := `{\"text\":\"\"}`\n\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", text.String())\n\t\t\t\t\t\tallParts = append(allParts, partJSON)\n\t\t\t\t\t}\n\t\t\t\tcase \"thinking_delta\":\n\t\t\t\t\t// Process reasoning/thinking content\n\t\t\t\t\tif text := delta.Get(\"thinking\"); text.Exists() && text.String() != \"\" {\n\t\t\t\t\t\tpartJSON := `{\"thought\":true,\"text\":\"\"}`\n\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", text.String())\n\t\t\t\t\t\tallParts = append(allParts, partJSON)\n\t\t\t\t\t}\n\t\t\t\tcase \"input_json_delta\":\n\t\t\t\t\t// accumulate args partial_json for this index\n\t\t\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\t\t\tif newParam.ToolUseArgs == nil {\n\t\t\t\t\t\tnewParam.ToolUseArgs = map[int]*strings.Builder{}\n\t\t\t\t\t}\n\t\t\t\t\tif _, ok := newParam.ToolUseArgs[idx]; !ok || newParam.ToolUseArgs[idx] == nil {\n\t\t\t\t\t\tnewParam.ToolUseArgs[idx] = &strings.Builder{}\n\t\t\t\t\t}\n\t\t\t\t\tif pj := delta.Get(\"partial_json\"); pj.Exists() {\n\t\t\t\t\t\tnewParam.ToolUseArgs[idx].WriteString(pj.String())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"content_block_stop\":\n\t\t\t// Handle tool use completion by assembling accumulated arguments\n\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\t// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)\n\t\t\t// So we finalize using accumulated state captured during content_block_start and input_json_delta.\n\t\t\tname := \"\"\n\t\t\tif newParam.ToolUseNames != nil {\n\t\t\t\tname = newParam.ToolUseNames[idx]\n\t\t\t}\n\t\t\tvar argsTrim string\n\t\t\tif newParam.ToolUseArgs != nil {\n\t\t\t\tif b := newParam.ToolUseArgs[idx]; b != nil {\n\t\t\t\t\targsTrim = strings.TrimSpace(b.String())\n\t\t\t\t}\n\t\t\t}\n\t\t\tif name != \"\" || argsTrim != \"\" {\n\t\t\t\tfunctionCallJSON := `{\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\t\tif name != \"\" {\n\t\t\t\t\tfunctionCallJSON, _ = sjson.Set(functionCallJSON, \"functionCall.name\", name)\n\t\t\t\t}\n\t\t\t\tif argsTrim != \"\" {\n\t\t\t\t\tfunctionCallJSON, _ = sjson.SetRaw(functionCallJSON, \"functionCall.args\", argsTrim)\n\t\t\t\t}\n\t\t\t\tallParts = append(allParts, functionCallJSON)\n\t\t\t\t// cleanup used state for this index\n\t\t\t\tif newParam.ToolUseArgs != nil {\n\t\t\t\t\tdelete(newParam.ToolUseArgs, idx)\n\t\t\t\t}\n\t\t\t\tif newParam.ToolUseNames != nil {\n\t\t\t\t\tdelete(newParam.ToolUseNames, idx)\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"message_delta\":\n\t\t\t// Extract final usage information using sjson for token counts and metadata\n\t\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t\tusageJSON := `{}`\n\n\t\t\t\t// Basic token counts for prompt and completion\n\t\t\t\tinputTokens := usage.Get(\"input_tokens\").Int()\n\t\t\t\toutputTokens := usage.Get(\"output_tokens\").Int()\n\n\t\t\t\t// Set basic usage metadata according to Gemini API specification\n\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"promptTokenCount\", inputTokens)\n\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"candidatesTokenCount\", outputTokens)\n\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"totalTokenCount\", inputTokens+outputTokens)\n\n\t\t\t\t// Add cache-related token counts if present (Claude Code API cache fields)\n\t\t\t\tif cacheCreationTokens := usage.Get(\"cache_creation_input_tokens\"); cacheCreationTokens.Exists() {\n\t\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"cachedContentTokenCount\", cacheCreationTokens.Int())\n\t\t\t\t}\n\t\t\t\tif cacheReadTokens := usage.Get(\"cache_read_input_tokens\"); cacheReadTokens.Exists() {\n\t\t\t\t\t// Add cache read tokens to cached content count\n\t\t\t\t\texistingCacheTokens := usage.Get(\"cache_creation_input_tokens\").Int()\n\t\t\t\t\ttotalCacheTokens := existingCacheTokens + cacheReadTokens.Int()\n\t\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"cachedContentTokenCount\", totalCacheTokens)\n\t\t\t\t}\n\n\t\t\t\t// Add thinking tokens if present (for models with reasoning capabilities)\n\t\t\t\tif thinkingTokens := usage.Get(\"thinking_tokens\"); thinkingTokens.Exists() {\n\t\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"thoughtsTokenCount\", thinkingTokens.Int())\n\t\t\t\t}\n\n\t\t\t\t// Set traffic type (required by Gemini API)\n\t\t\t\tusageJSON, _ = sjson.Set(usageJSON, \"trafficType\", \"PROVISIONED_THROUGHPUT\")\n\n\t\t\t\tfinalUsageJSON = usageJSON\n\t\t\t}\n\t\t}\n\t}\n\n\t// Set response metadata\n\tif responseID != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"responseId\", responseID)\n\t}\n\tif createdAt > 0 {\n\t\ttemplate, _ = sjson.Set(template, \"createTime\", time.Unix(createdAt, 0).Format(time.RFC3339Nano))\n\t}\n\n\t// Consolidate consecutive text parts and thinking parts for cleaner output\n\tconsolidatedParts := consolidateParts(allParts)\n\n\t// Set the consolidated parts array\n\tif len(consolidatedParts) > 0 {\n\t\tpartsJSON := \"[]\"\n\t\tfor _, partJSON := range consolidatedParts {\n\t\t\tpartsJSON, _ = sjson.SetRaw(partsJSON, \"-1\", partJSON)\n\t\t}\n\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts\", partsJSON)\n\t}\n\n\t// Set usage metadata\n\tif finalUsageJSON != \"\" {\n\t\ttemplate, _ = sjson.SetRaw(template, \"usageMetadata\", finalUsageJSON)\n\t}\n\n\treturn template\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n\n// consolidateParts merges consecutive text parts and thinking parts to create a cleaner response.\n// This function processes the parts array to combine adjacent text elements and thinking elements\n// into single consolidated parts, which results in a more readable and efficient response structure.\n// Tool calls and other non-text parts are preserved as separate elements.\nfunc consolidateParts(parts []string) []string {\n\tif len(parts) == 0 {\n\t\treturn parts\n\t}\n\n\tvar consolidated []string\n\tvar currentTextPart strings.Builder\n\tvar currentThoughtPart strings.Builder\n\tvar hasText, hasThought bool\n\n\tflushText := func() {\n\t\t// Flush accumulated text content to the consolidated parts array\n\t\tif hasText && currentTextPart.Len() > 0 {\n\t\t\ttextPartJSON := `{\"text\":\"\"}`\n\t\t\ttextPartJSON, _ = sjson.Set(textPartJSON, \"text\", currentTextPart.String())\n\t\t\tconsolidated = append(consolidated, textPartJSON)\n\t\t\tcurrentTextPart.Reset()\n\t\t\thasText = false\n\t\t}\n\t}\n\n\tflushThought := func() {\n\t\t// Flush accumulated thinking content to the consolidated parts array\n\t\tif hasThought && currentThoughtPart.Len() > 0 {\n\t\t\tthoughtPartJSON := `{\"thought\":true,\"text\":\"\"}`\n\t\t\tthoughtPartJSON, _ = sjson.Set(thoughtPartJSON, \"text\", currentThoughtPart.String())\n\t\t\tconsolidated = append(consolidated, thoughtPartJSON)\n\t\t\tcurrentThoughtPart.Reset()\n\t\t\thasThought = false\n\t\t}\n\t}\n\n\tfor _, partJSON := range parts {\n\t\tpart := gjson.Parse(partJSON)\n\t\tif !part.Exists() || !part.IsObject() {\n\t\t\t// Flush any pending parts and add this non-text part\n\t\t\tflushText()\n\t\t\tflushThought()\n\t\t\tconsolidated = append(consolidated, partJSON)\n\t\t\tcontinue\n\t\t}\n\n\t\tthought := part.Get(\"thought\")\n\t\tif thought.Exists() && thought.Type == gjson.True {\n\t\t\t// This is a thinking part - flush any pending text first\n\t\t\tflushText() // Flush any pending text first\n\n\t\t\tif text := part.Get(\"text\"); text.Exists() && text.Type == gjson.String {\n\t\t\t\tcurrentThoughtPart.WriteString(text.String())\n\t\t\t\thasThought = true\n\t\t\t}\n\t\t} else if text := part.Get(\"text\"); text.Exists() && text.Type == gjson.String {\n\t\t\t// This is a regular text part - flush any pending thought first\n\t\t\tflushThought() // Flush any pending thought first\n\n\t\t\tcurrentTextPart.WriteString(text.String())\n\t\t\thasText = true\n\t\t} else {\n\t\t\t// This is some other type of part (like function call) - flush both text and thought\n\t\t\tflushText()\n\t\t\tflushThought()\n\t\t\tconsolidated = append(consolidated, partJSON)\n\t\t}\n\t}\n\n\t// Flush any remaining parts\n\tflushThought() // Flush thought first to maintain order\n\tflushText()\n\n\treturn consolidated\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tClaude,\n\t\tConvertGeminiRequestToClaude,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertClaudeResponseToGemini,\n\t\t\tNonStream:  ConvertClaudeResponseToGeminiNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini-cli/claude_gemini-cli_request.go",
    "content": "// Package geminiCLI provides request translation functionality for Gemini CLI to Claude Code API compatibility.\n// It handles parsing and transforming Gemini CLI API requests into Claude Code API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini CLI API format and Claude Code API's expected format.\npackage geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiCLIRequestToClaude parses and transforms a Gemini CLI API request into Claude Code API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Claude Code API.\n// The function performs the following transformations:\n// 1. Extracts the model information from the request\n// 2. Restructures the JSON to match Claude Code API format\n// 3. Converts system instructions to the expected format\n// 4. Delegates to the Gemini-to-Claude conversion function for further processing\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in Claude Code API format\nfunc ConvertGeminiCLIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\tmodelResult := gjson.GetBytes(rawJSON, \"model\")\n\t// Extract the inner request object and promote it to the top level\n\trawJSON = []byte(gjson.GetBytes(rawJSON, \"request\").Raw)\n\t// Restore the model information at the top level\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"model\", modelResult.String())\n\t// Convert systemInstruction field to system_instruction for Claude Code compatibility\n\tif gjson.GetBytes(rawJSON, \"systemInstruction\").Exists() {\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"system_instruction\", []byte(gjson.GetBytes(rawJSON, \"systemInstruction\").Raw))\n\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"systemInstruction\")\n\t}\n\t// Delegate to the Gemini-to-Claude conversion function for further processing\n\treturn ConvertGeminiRequestToClaude(modelName, rawJSON, stream)\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini-cli/claude_gemini-cli_response.go",
    "content": "// Package geminiCLI provides response translation functionality for Claude Code to Gemini CLI API compatibility.\n// This package handles the conversion of Claude Code API responses into Gemini CLI-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini CLI API clients.\npackage geminiCLI\n\nimport (\n\t\"context\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertClaudeResponseToGeminiCLI converts Claude Code streaming response format to Gemini CLI format.\n// This function processes various Claude Code event types and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini CLI API format.\n// The function wraps each converted response in a \"response\" object to match the Gemini CLI API structure.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response wrapped in a response object\nfunc ConvertClaudeResponseToGeminiCLI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\toutputs := ConvertClaudeResponseToGemini(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\t// Wrap each converted response in a \"response\" object to match Gemini CLI API structure\n\tnewOutputs := make([]string, 0)\n\tfor i := 0; i < len(outputs); i++ {\n\t\tjson := `{\"response\": {}}`\n\t\toutput, _ := sjson.SetRaw(json, \"response\", outputs[i])\n\t\tnewOutputs = append(newOutputs, output)\n\t}\n\treturn newOutputs\n}\n\n// ConvertClaudeResponseToGeminiCLINonStream converts a non-streaming Claude Code response to a non-streaming Gemini CLI response.\n// This function processes the complete Claude Code response and transforms it into a single Gemini-compatible\n// JSON response. It wraps the converted response in a \"response\" object to match the Gemini CLI API structure.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for the conversion\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response wrapped in a response object\nfunc ConvertClaudeResponseToGeminiCLINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tstrJSON := ConvertClaudeResponseToGeminiNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\t// Wrap the converted response in a \"response\" object to match Gemini CLI API structure\n\tjson := `{\"response\": {}}`\n\tstrJSON, _ = sjson.SetRaw(json, \"response\", strJSON)\n\treturn strJSON\n}\n\nfunc GeminiCLITokenCount(ctx context.Context, count int64) string {\n\treturn GeminiTokenCount(ctx, count)\n}\n"
  },
  {
    "path": "internal/translator/claude/gemini-cli/init.go",
    "content": "package geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGeminiCLI,\n\t\tClaude,\n\t\tConvertGeminiCLIRequestToClaude,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertClaudeResponseToGeminiCLI,\n\t\t\tNonStream:  ConvertClaudeResponseToGeminiCLINonStream,\n\t\t\tTokenCount: GeminiCLITokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/chat-completions/claude_openai_request.go",
    "content": "// Package openai provides request translation functionality for OpenAI to Claude Code API compatibility.\n// It handles parsing and transforming OpenAI Chat Completions API requests into Claude Code API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between OpenAI API format and Claude Code API's expected format.\npackage chat_completions\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tuser    = \"\"\n\taccount = \"\"\n\tsession = \"\"\n)\n\n// ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions API request into Claude Code API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Claude Code API.\n// The function performs comprehensive transformation including:\n// 1. Model name mapping and parameter extraction (max_tokens, temperature, top_p, etc.)\n// 2. Message content conversion from OpenAI to Claude Code format\n// 3. Tool call and tool result handling with proper ID mapping\n// 4. Image data conversion from OpenAI data URLs to Claude Code base64 format\n// 5. Stop sequence and streaming configuration handling\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI API\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in Claude Code API format\nfunc ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\tif account == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\taccount = u.String()\n\t}\n\tif session == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\tsession = u.String()\n\t}\n\tif user == \"\" {\n\t\tsum := sha256.Sum256([]byte(account + session))\n\t\tuser = hex.EncodeToString(sum[:])\n\t}\n\tuserID := fmt.Sprintf(\"user_%s_account_%s_session_%s\", user, account, session)\n\n\t// Base Claude Code API template with default max_tokens value\n\tout := fmt.Sprintf(`{\"model\":\"\",\"max_tokens\":32000,\"messages\":[],\"metadata\":{\"user_id\":\"%s\"}}`, userID)\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Convert OpenAI reasoning_effort to Claude thinking config.\n\tif v := root.Get(\"reasoning_effort\"); v.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(v.String()))\n\t\tif effort != \"\" {\n\t\t\tmi := registry.LookupModelInfo(modelName, \"claude\")\n\t\t\tsupportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0\n\t\t\tsupportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))\n\n\t\t\t// Claude 4.6 supports adaptive thinking with output_config.effort.\n\t\t\t// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid\n\t\t\t// validation errors since validate treats same-provider unsupported levels as errors.\n\t\t\tif supportsAdaptive {\n\t\t\t\tswitch effort {\n\t\t\t\tcase \"none\":\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\tcase \"auto\":\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\tdefault:\n\t\t\t\t\tif mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {\n\t\t\t\t\t\teffort = mapped\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Set(out, \"output_config.effort\", effort)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Legacy/manual thinking (budget_tokens).\n\t\t\t\tbudget, ok := thinking.ConvertLevelToBudget(effort)\n\t\t\t\tif ok {\n\t\t\t\t\tswitch budget {\n\t\t\t\t\tcase 0:\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\tcase -1:\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif budget > 0 {\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.budget_tokens\", budget)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Helper for generating tool call IDs in the form: toolu_<alphanum>\n\t// This ensures unique identifiers for tool calls in the Claude Code format\n\tgenToolCallID := func() string {\n\t\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\tvar b strings.Builder\n\t\t// 24 chars random suffix for uniqueness\n\t\tfor i := 0; i < 24; i++ {\n\t\t\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\t\tb.WriteByte(letters[n.Int64()])\n\t\t}\n\t\treturn \"toolu_\" + b.String()\n\t}\n\n\t// Model mapping to specify which Claude Code model to use\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Max tokens configuration with fallback to default value\n\tif maxTokens := root.Get(\"max_tokens\"); maxTokens.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t}\n\n\t// Temperature setting for controlling response randomness\n\tif temp := root.Get(\"temperature\"); temp.Exists() {\n\t\tout, _ = sjson.Set(out, \"temperature\", temp.Float())\n\t} else if topP := root.Get(\"top_p\"); topP.Exists() {\n\t\t// Top P setting for nucleus sampling (filtered out if temperature is set)\n\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t}\n\n\t// Stop sequences configuration for custom termination conditions\n\tif stop := root.Get(\"stop\"); stop.Exists() {\n\t\tif stop.IsArray() {\n\t\t\tvar stopSequences []string\n\t\t\tstop.ForEach(func(_, value gjson.Result) bool {\n\t\t\t\tstopSequences = append(stopSequences, value.String())\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif len(stopSequences) > 0 {\n\t\t\t\tout, _ = sjson.Set(out, \"stop_sequences\", stopSequences)\n\t\t\t}\n\t\t} else {\n\t\t\tout, _ = sjson.Set(out, \"stop_sequences\", []string{stop.String()})\n\t\t}\n\t}\n\n\t// Stream configuration to enable or disable streaming responses\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Process messages and transform them to Claude Code format\n\tif messages := root.Get(\"messages\"); messages.Exists() && messages.IsArray() {\n\t\tmessageIndex := 0\n\t\tsystemMessageIndex := -1\n\t\tmessages.ForEach(func(_, message gjson.Result) bool {\n\t\t\trole := message.Get(\"role\").String()\n\t\t\tcontentResult := message.Get(\"content\")\n\n\t\t\tswitch role {\n\t\t\tcase \"system\":\n\t\t\t\tif systemMessageIndex == -1 {\n\t\t\t\t\tsystemMsg := `{\"role\":\"user\",\"content\":[]}`\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", systemMsg)\n\t\t\t\t\tsystemMessageIndex = messageIndex\n\t\t\t\t\tmessageIndex++\n\t\t\t\t}\n\t\t\t\tif contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != \"\" {\n\t\t\t\t\ttextPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\ttextPart, _ = sjson.Set(textPart, \"text\", contentResult.String())\n\t\t\t\t\tout, _ = sjson.SetRaw(out, fmt.Sprintf(\"messages.%d.content.-1\", systemMessageIndex), textPart)\n\t\t\t\t} else if contentResult.Exists() && contentResult.IsArray() {\n\t\t\t\t\tcontentResult.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\tif part.Get(\"type\").String() == \"text\" {\n\t\t\t\t\t\t\ttextPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\t\ttextPart, _ = sjson.Set(textPart, \"text\", part.Get(\"text\").String())\n\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, fmt.Sprintf(\"messages.%d.content.-1\", systemMessageIndex), textPart)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\tcase \"user\", \"assistant\":\n\t\t\t\tmsg := `{\"role\":\"\",\"content\":[]}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\n\t\t\t\t// Handle content based on its type (string or array)\n\t\t\t\tif contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != \"\" {\n\t\t\t\t\tpart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentResult.String())\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t} else if contentResult.Exists() && contentResult.IsArray() {\n\t\t\t\t\tcontentResult.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\tclaudePart := convertOpenAIContentPartToClaudePart(part)\n\t\t\t\t\t\tif claudePart != \"\" {\n\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", claudePart)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// Handle tool calls (for assistant messages)\n\t\t\t\tif toolCalls := message.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() && role == \"assistant\" {\n\t\t\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\t\t\tif toolCall.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\t\ttoolCallID := toolCall.Get(\"id\").String()\n\t\t\t\t\t\t\tif toolCallID == \"\" {\n\t\t\t\t\t\t\t\ttoolCallID = genToolCallID()\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tfunction := toolCall.Get(\"function\")\n\t\t\t\t\t\t\ttoolUse := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"id\", toolCallID)\n\t\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"name\", function.Get(\"name\").String())\n\n\t\t\t\t\t\t\t// Parse arguments for the tool call\n\t\t\t\t\t\t\tif args := function.Get(\"arguments\"); args.Exists() {\n\t\t\t\t\t\t\t\targsStr := args.String()\n\t\t\t\t\t\t\t\tif argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", argsJSON.Raw)\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", \"{}\")\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", \"{}\")\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", \"{}\")\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", toolUse)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\t\tmessageIndex++\n\n\t\t\tcase \"tool\":\n\t\t\t\t// Handle tool result messages conversion\n\t\t\t\ttoolCallID := message.Get(\"tool_call_id\").String()\n\t\t\t\ttoolContentResult := message.Get(\"content\")\n\n\t\t\t\tmsg := `{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"\",\"content\":\"\"}]}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"content.0.tool_use_id\", toolCallID)\n\t\t\t\ttoolResultContent, toolResultContentRaw := convertOpenAIToolResultContent(toolContentResult)\n\t\t\t\tif toolResultContentRaw {\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.0.content\", toolResultContent)\n\t\t\t\t} else {\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"content.0.content\", toolResultContent)\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\t\tmessageIndex++\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Tools mapping: OpenAI tools -> Claude Code tools\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {\n\t\thasAnthropicTools := false\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tif tool.Get(\"type\").String() == \"function\" {\n\t\t\t\tfunction := tool.Get(\"function\")\n\t\t\t\tanthropicTool := `{\"name\":\"\",\"description\":\"\"}`\n\t\t\t\tanthropicTool, _ = sjson.Set(anthropicTool, \"name\", function.Get(\"name\").String())\n\t\t\t\tanthropicTool, _ = sjson.Set(anthropicTool, \"description\", function.Get(\"description\").String())\n\n\t\t\t\t// Convert parameters schema for the tool\n\t\t\t\tif parameters := function.Get(\"parameters\"); parameters.Exists() {\n\t\t\t\t\tanthropicTool, _ = sjson.SetRaw(anthropicTool, \"input_schema\", parameters.Raw)\n\t\t\t\t} else if parameters := function.Get(\"parametersJsonSchema\"); parameters.Exists() {\n\t\t\t\t\tanthropicTool, _ = sjson.SetRaw(anthropicTool, \"input_schema\", parameters.Raw)\n\t\t\t\t}\n\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.-1\", anthropicTool)\n\t\t\t\thasAnthropicTools = true\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\tif !hasAnthropicTools {\n\t\t\tout, _ = sjson.Delete(out, \"tools\")\n\t\t}\n\t}\n\n\t// Tool choice mapping from OpenAI format to Claude Code format\n\tif toolChoice := root.Get(\"tool_choice\"); toolChoice.Exists() {\n\t\tswitch toolChoice.Type {\n\t\tcase gjson.String:\n\t\t\tchoice := toolChoice.String()\n\t\t\tswitch choice {\n\t\t\tcase \"none\":\n\t\t\t\t// Don't set tool_choice, Claude Code will not use tools\n\t\t\tcase \"auto\":\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"auto\"}`)\n\t\t\tcase \"required\":\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"any\"}`)\n\t\t\t}\n\t\tcase gjson.JSON:\n\t\t\t// Specific tool choice mapping\n\t\t\tif toolChoice.Get(\"type\").String() == \"function\" {\n\t\t\t\tfunctionName := toolChoice.Get(\"function.name\").String()\n\t\t\t\ttoolChoiceJSON := `{\"type\":\"tool\",\"name\":\"\"}`\n\t\t\t\ttoolChoiceJSON, _ = sjson.Set(toolChoiceJSON, \"name\", functionName)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", toolChoiceJSON)\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\treturn []byte(out)\n}\n\nfunc convertOpenAIContentPartToClaudePart(part gjson.Result) string {\n\tswitch part.Get(\"type\").String() {\n\tcase \"text\":\n\t\ttextPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\ttextPart, _ = sjson.Set(textPart, \"text\", part.Get(\"text\").String())\n\t\treturn textPart\n\n\tcase \"image_url\":\n\t\treturn convertOpenAIImageURLToClaudePart(part.Get(\"image_url.url\").String())\n\n\tcase \"file\":\n\t\tfileData := part.Get(\"file.file_data\").String()\n\t\tif strings.HasPrefix(fileData, \"data:\") {\n\t\t\tsemicolonIdx := strings.Index(fileData, \";\")\n\t\t\tcommaIdx := strings.Index(fileData, \",\")\n\t\t\tif semicolonIdx != -1 && commaIdx != -1 && commaIdx > semicolonIdx {\n\t\t\t\tmediaType := strings.TrimPrefix(fileData[:semicolonIdx], \"data:\")\n\t\t\t\tdata := fileData[commaIdx+1:]\n\t\t\t\tdocPart := `{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"\",\"data\":\"\"}}`\n\t\t\t\tdocPart, _ = sjson.Set(docPart, \"source.media_type\", mediaType)\n\t\t\t\tdocPart, _ = sjson.Set(docPart, \"source.data\", data)\n\t\t\t\treturn docPart\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc convertOpenAIImageURLToClaudePart(imageURL string) string {\n\tif imageURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif strings.HasPrefix(imageURL, \"data:\") {\n\t\tparts := strings.SplitN(imageURL, \",\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn \"\"\n\t\t}\n\n\t\tmediaTypePart := strings.SplitN(parts[0], \";\", 2)[0]\n\t\tmediaType := strings.TrimPrefix(mediaTypePart, \"data:\")\n\t\tif mediaType == \"\" {\n\t\t\tmediaType = \"application/octet-stream\"\n\t\t}\n\n\t\timagePart := `{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"\",\"data\":\"\"}}`\n\t\timagePart, _ = sjson.Set(imagePart, \"source.media_type\", mediaType)\n\t\timagePart, _ = sjson.Set(imagePart, \"source.data\", parts[1])\n\t\treturn imagePart\n\t}\n\n\timagePart := `{\"type\":\"image\",\"source\":{\"type\":\"url\",\"url\":\"\"}}`\n\timagePart, _ = sjson.Set(imagePart, \"source.url\", imageURL)\n\treturn imagePart\n}\n\nfunc convertOpenAIToolResultContent(content gjson.Result) (string, bool) {\n\tif !content.Exists() {\n\t\treturn \"\", false\n\t}\n\n\tif content.Type == gjson.String {\n\t\treturn content.String(), false\n\t}\n\n\tif content.IsArray() {\n\t\tclaudeContent := \"[]\"\n\t\tpartCount := 0\n\n\t\tcontent.ForEach(func(_, part gjson.Result) bool {\n\t\t\tif part.Type == gjson.String {\n\t\t\t\ttextPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\ttextPart, _ = sjson.Set(textPart, \"text\", part.String())\n\t\t\t\tclaudeContent, _ = sjson.SetRaw(claudeContent, \"-1\", textPart)\n\t\t\t\tpartCount++\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\tclaudePart := convertOpenAIContentPartToClaudePart(part)\n\t\t\tif claudePart != \"\" {\n\t\t\t\tclaudeContent, _ = sjson.SetRaw(claudeContent, \"-1\", claudePart)\n\t\t\t\tpartCount++\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\tif partCount > 0 || len(content.Array()) == 0 {\n\t\t\treturn claudeContent, true\n\t\t}\n\n\t\treturn content.Raw, false\n\t}\n\n\tif content.IsObject() {\n\t\tclaudePart := convertOpenAIContentPartToClaudePart(content)\n\t\tif claudePart != \"\" {\n\t\t\tclaudeContent := \"[]\"\n\t\t\tclaudeContent, _ = sjson.SetRaw(claudeContent, \"-1\", claudePart)\n\t\t\treturn claudeContent, true\n\t\t}\n\t\treturn content.Raw, false\n\t}\n\n\treturn content.Raw, false\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/chat-completions/claude_openai_request_test.go",
    "content": "package chat_completions\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertOpenAIRequestToClaude_ToolResultTextAndBase64Image(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"gpt-4.1\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": \"\",\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_1\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"do_work\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"a\\\":1}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"tool\",\n\t\t\t\t\"tool_call_id\": \"call_1\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"tool ok\"},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"image_url\",\n\t\t\t\t\t\t\"image_url\": {\n\t\t\t\t\t\t\t\"url\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertOpenAIRequestToClaude(\"claude-sonnet-4-5\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"Expected 2 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\ttoolResult := messages[1].Get(\"content.0\")\n\tif got := toolResult.Get(\"type\").String(); got != \"tool_result\" {\n\t\tt.Fatalf(\"Expected content[0].type %q, got %q\", \"tool_result\", got)\n\t}\n\tif got := toolResult.Get(\"tool_use_id\").String(); got != \"call_1\" {\n\t\tt.Fatalf(\"Expected tool_use_id %q, got %q\", \"call_1\", got)\n\t}\n\n\ttoolContent := toolResult.Get(\"content\")\n\tif !toolContent.IsArray() {\n\t\tt.Fatalf(\"Expected tool_result content array, got %s\", toolContent.Raw)\n\t}\n\tif got := toolContent.Get(\"0.type\").String(); got != \"text\" {\n\t\tt.Fatalf(\"Expected first tool_result part type %q, got %q\", \"text\", got)\n\t}\n\tif got := toolContent.Get(\"0.text\").String(); got != \"tool ok\" {\n\t\tt.Fatalf(\"Expected first tool_result part text %q, got %q\", \"tool ok\", got)\n\t}\n\tif got := toolContent.Get(\"1.type\").String(); got != \"image\" {\n\t\tt.Fatalf(\"Expected second tool_result part type %q, got %q\", \"image\", got)\n\t}\n\tif got := toolContent.Get(\"1.source.type\").String(); got != \"base64\" {\n\t\tt.Fatalf(\"Expected image source type %q, got %q\", \"base64\", got)\n\t}\n\tif got := toolContent.Get(\"1.source.media_type\").String(); got != \"image/png\" {\n\t\tt.Fatalf(\"Expected image media type %q, got %q\", \"image/png\", got)\n\t}\n\tif got := toolContent.Get(\"1.source.data\").String(); got != \"iVBORw0KGgoAAAANSUhEUg==\" {\n\t\tt.Fatalf(\"Unexpected base64 image data: %q\", got)\n\t}\n}\n\nfunc TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"gpt-4.1\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": \"\",\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_1\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"do_work\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"a\\\":1}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"tool\",\n\t\t\t\t\"tool_call_id\": \"call_1\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"image_url\",\n\t\t\t\t\t\t\"image_url\": {\n\t\t\t\t\t\t\t\"url\": \"https://example.com/tool.png\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertOpenAIRequestToClaude(\"claude-sonnet-4-5\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"Expected 2 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\ttoolContent := messages[1].Get(\"content.0.content\")\n\tif !toolContent.IsArray() {\n\t\tt.Fatalf(\"Expected tool_result content array, got %s\", toolContent.Raw)\n\t}\n\tif got := toolContent.Get(\"0.type\").String(); got != \"image\" {\n\t\tt.Fatalf(\"Expected tool_result part type %q, got %q\", \"image\", got)\n\t}\n\tif got := toolContent.Get(\"0.source.type\").String(); got != \"url\" {\n\t\tt.Fatalf(\"Expected image source type %q, got %q\", \"url\", got)\n\t}\n\tif got := toolContent.Get(\"0.source.url\").String(); got != \"https://example.com/tool.png\" {\n\t\tt.Fatalf(\"Unexpected image URL: %q\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/chat-completions/claude_openai_response.go",
    "content": "// Package openai provides response translation functionality for Claude Code to OpenAI API compatibility.\n// This package handles the conversion of Claude Code API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertAnthropicResponseToOpenAIParams holds parameters for response conversion\ntype ConvertAnthropicResponseToOpenAIParams struct {\n\tCreatedAt    int64\n\tResponseID   string\n\tFinishReason string\n\t// Tool calls accumulator for streaming\n\tToolCallsAccumulator map[int]*ToolCallAccumulator\n}\n\n// ToolCallAccumulator holds the state for accumulating tool call data\ntype ToolCallAccumulator struct {\n\tID        string\n\tName      string\n\tArguments strings.Builder\n}\n\n// ConvertClaudeResponseToOpenAI converts Claude Code streaming response format to OpenAI Chat Completions format.\n// This function processes various Claude Code event types and transforms them into OpenAI-compatible JSON responses.\n// It handles text content, tool calls, reasoning content, and usage metadata, outputting responses that match\n// the OpenAI API format. The function supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertAnthropicResponseToOpenAIParams{\n\t\t\tCreatedAt:    0,\n\t\t\tResponseID:   \"\",\n\t\t\tFinishReason: \"\",\n\t\t}\n\t}\n\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\troot := gjson.ParseBytes(rawJSON)\n\teventType := root.Get(\"type\").String()\n\n\t// Base OpenAI streaming response template\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":null}]}`\n\n\t// Set model\n\tif modelName != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelName)\n\t}\n\n\t// Set response ID and creation time\n\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"id\", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)\n\t}\n\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt > 0 {\n\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)\n\t}\n\n\tswitch eventType {\n\tcase \"message_start\":\n\t\t// Initialize response with message metadata when a new message begins\n\t\tif message := root.Get(\"message\"); message.Exists() {\n\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID = message.Get(\"id\").String()\n\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt = time.Now().Unix()\n\n\t\t\ttemplate, _ = sjson.Set(template, \"id\", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)\n\t\t\ttemplate, _ = sjson.Set(template, \"model\", modelName)\n\t\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)\n\n\t\t\t// Set initial role to assistant for the response\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\n\t\t\t// Initialize tool calls accumulator for tracking tool call progress\n\t\t\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {\n\t\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)\n\t\t\t}\n\t\t}\n\t\treturn []string{template}\n\n\tcase \"content_block_start\":\n\t\t// Start of a content block (text, tool use, or reasoning)\n\t\tif contentBlock := root.Get(\"content_block\"); contentBlock.Exists() {\n\t\t\tblockType := contentBlock.Get(\"type\").String()\n\n\t\t\tif blockType == \"tool_use\" {\n\t\t\t\t// Start of tool call - initialize accumulator to track arguments\n\t\t\t\ttoolCallID := contentBlock.Get(\"id\").String()\n\t\t\t\ttoolName := contentBlock.Get(\"name\").String()\n\t\t\t\tindex := int(root.Get(\"index\").Int())\n\n\t\t\t\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {\n\t\t\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)\n\t\t\t\t}\n\n\t\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index] = &ToolCallAccumulator{\n\t\t\t\t\tID:   toolCallID,\n\t\t\t\t\tName: toolName,\n\t\t\t\t}\n\n\t\t\t\t// Don't output anything yet - wait for complete tool call\n\t\t\t\treturn []string{}\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\n\tcase \"content_block_delta\":\n\t\t// Handle content delta (text, tool use arguments, or reasoning content)\n\t\thasContent := false\n\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\tdeltaType := delta.Get(\"type\").String()\n\n\t\t\tswitch deltaType {\n\t\t\tcase \"text_delta\":\n\t\t\t\t// Text content delta - send incremental text updates\n\t\t\t\tif text := delta.Get(\"text\"); text.Exists() {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.content\", text.String())\n\t\t\t\t\thasContent = true\n\t\t\t\t}\n\t\t\tcase \"thinking_delta\":\n\t\t\t\t// Accumulate reasoning/thinking content\n\t\t\t\tif thinking := delta.Get(\"thinking\"); thinking.Exists() {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", thinking.String())\n\t\t\t\t\thasContent = true\n\t\t\t\t}\n\t\t\tcase \"input_json_delta\":\n\t\t\t\t// Tool use input delta - accumulate arguments for tool calls\n\t\t\t\tif partialJSON := delta.Get(\"partial_json\"); partialJSON.Exists() {\n\t\t\t\t\tindex := int(root.Get(\"index\").Int())\n\t\t\t\t\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {\n\t\t\t\t\t\tif accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {\n\t\t\t\t\t\t\taccumulator.Arguments.WriteString(partialJSON.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Don't output anything yet - wait for complete tool call\n\t\t\t\treturn []string{}\n\t\t\t}\n\t\t}\n\t\tif hasContent {\n\t\t\treturn []string{template}\n\t\t} else {\n\t\t\treturn []string{}\n\t\t}\n\n\tcase \"content_block_stop\":\n\t\t// End of content block - output complete tool call if it's a tool_use block\n\t\tindex := int(root.Get(\"index\").Int())\n\t\tif (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {\n\t\t\tif accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {\n\t\t\t\t// Build complete tool call with accumulated arguments\n\t\t\t\targuments := accumulator.Arguments.String()\n\t\t\t\tif arguments == \"\" {\n\t\t\t\t\targuments = \"{}\"\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.tool_calls.0.index\", index)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.tool_calls.0.id\", accumulator.ID)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.tool_calls.0.type\", \"function\")\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.tool_calls.0.function.name\", accumulator.Name)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.tool_calls.0.function.arguments\", arguments)\n\n\t\t\t\t// Clean up the accumulator for this index\n\t\t\t\tdelete((*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator, index)\n\n\t\t\t\treturn []string{template}\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\n\tcase \"message_delta\":\n\t\t// Handle message-level changes including stop reason and usage\n\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\tif stopReason := delta.Get(\"stop_reason\"); stopReason.Exists() {\n\t\t\t\t(*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason = mapAnthropicStopReasonToOpenAI(stopReason.String())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason)\n\t\t\t}\n\t\t}\n\n\t\t// Handle usage information for token counts\n\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\tinputTokens := usage.Get(\"input_tokens\").Int()\n\t\t\toutputTokens := usage.Get(\"output_tokens\").Int()\n\t\t\tcacheReadInputTokens := usage.Get(\"cache_read_input_tokens\").Int()\n\t\t\tcacheCreationInputTokens := usage.Get(\"cache_creation_input_tokens\").Int()\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", inputTokens+cacheCreationInputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", outputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", inputTokens+outputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cacheReadInputTokens)\n\t\t}\n\t\treturn []string{template}\n\n\tcase \"message_stop\":\n\t\t// Final message event - no additional output needed\n\t\treturn []string{}\n\n\tcase \"ping\":\n\t\t// Ping events for keeping connection alive - no output needed\n\t\treturn []string{}\n\n\tcase \"error\":\n\t\t// Error event - format and return error response\n\t\tif errorData := root.Get(\"error\"); errorData.Exists() {\n\t\t\terrorJSON := `{\"error\":{\"message\":\"\",\"type\":\"\"}}`\n\t\t\terrorJSON, _ = sjson.Set(errorJSON, \"error.message\", errorData.Get(\"message\").String())\n\t\t\terrorJSON, _ = sjson.Set(errorJSON, \"error.type\", errorData.Get(\"type\").String())\n\t\t\treturn []string{errorJSON}\n\t\t}\n\t\treturn []string{}\n\n\tdefault:\n\t\t// Unknown event type - ignore\n\t\treturn []string{}\n\t}\n}\n\n// mapAnthropicStopReasonToOpenAI maps Anthropic stop reasons to OpenAI stop reasons\nfunc mapAnthropicStopReasonToOpenAI(anthropicReason string) string {\n\tswitch anthropicReason {\n\tcase \"end_turn\":\n\t\treturn \"stop\"\n\tcase \"tool_use\":\n\t\treturn \"tool_calls\"\n\tcase \"max_tokens\":\n\t\treturn \"length\"\n\tcase \"stop_sequence\":\n\t\treturn \"stop\"\n\tdefault:\n\t\treturn \"stop\"\n\t}\n}\n\n// ConvertClaudeResponseToOpenAINonStream converts a non-streaming Claude Code response to a non-streaming OpenAI response.\n// This function processes the complete Claude Code response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Claude Code API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\tchunks := make([][]byte, 0)\n\n\tlines := bytes.Split(rawJSON, []byte(\"\\n\"))\n\tfor _, line := range lines {\n\t\tif !bytes.HasPrefix(line, dataTag) {\n\t\t\tcontinue\n\t\t}\n\t\tchunks = append(chunks, bytes.TrimSpace(line[5:]))\n\t}\n\n\t// Base OpenAI non-streaming response template\n\tout := `{\"id\":\"\",\"object\":\"chat.completion\",\"created\":0,\"model\":\"\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0}}`\n\n\tvar messageID string\n\tvar model string\n\tvar createdAt int64\n\tvar stopReason string\n\tvar contentParts []string\n\tvar reasoningParts []string\n\ttoolCallsAccumulator := make(map[int]*ToolCallAccumulator)\n\n\tfor _, chunk := range chunks {\n\t\troot := gjson.ParseBytes(chunk)\n\t\teventType := root.Get(\"type\").String()\n\n\t\tswitch eventType {\n\t\tcase \"message_start\":\n\t\t\t// Extract initial message metadata including ID, model, and input token count\n\t\t\tif message := root.Get(\"message\"); message.Exists() {\n\t\t\t\tmessageID = message.Get(\"id\").String()\n\t\t\t\tmodel = message.Get(\"model\").String()\n\t\t\t\tcreatedAt = time.Now().Unix()\n\t\t\t}\n\n\t\tcase \"content_block_start\":\n\t\t\t// Handle different content block types at the beginning\n\t\t\tif contentBlock := root.Get(\"content_block\"); contentBlock.Exists() {\n\t\t\t\tblockType := contentBlock.Get(\"type\").String()\n\t\t\t\tif blockType == \"thinking\" {\n\t\t\t\t\t// Start of thinking/reasoning content - skip for now as it's handled in delta\n\t\t\t\t\tcontinue\n\t\t\t\t} else if blockType == \"tool_use\" {\n\t\t\t\t\t// Initialize tool call accumulator for this index\n\t\t\t\t\tindex := int(root.Get(\"index\").Int())\n\t\t\t\t\ttoolCallsAccumulator[index] = &ToolCallAccumulator{\n\t\t\t\t\t\tID:   contentBlock.Get(\"id\").String(),\n\t\t\t\t\t\tName: contentBlock.Get(\"name\").String(),\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"content_block_delta\":\n\t\t\t// Process incremental content updates\n\t\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\t\tdeltaType := delta.Get(\"type\").String()\n\t\t\t\tswitch deltaType {\n\t\t\t\tcase \"text_delta\":\n\t\t\t\t\t// Accumulate text content\n\t\t\t\t\tif text := delta.Get(\"text\"); text.Exists() {\n\t\t\t\t\t\tcontentParts = append(contentParts, text.String())\n\t\t\t\t\t}\n\t\t\t\tcase \"thinking_delta\":\n\t\t\t\t\t// Accumulate reasoning/thinking content\n\t\t\t\t\tif thinking := delta.Get(\"thinking\"); thinking.Exists() {\n\t\t\t\t\t\treasoningParts = append(reasoningParts, thinking.String())\n\t\t\t\t\t}\n\t\t\t\tcase \"input_json_delta\":\n\t\t\t\t\t// Accumulate tool call arguments\n\t\t\t\t\tif partialJSON := delta.Get(\"partial_json\"); partialJSON.Exists() {\n\t\t\t\t\t\tindex := int(root.Get(\"index\").Int())\n\t\t\t\t\t\tif accumulator, exists := toolCallsAccumulator[index]; exists {\n\t\t\t\t\t\t\taccumulator.Arguments.WriteString(partialJSON.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"content_block_stop\":\n\t\t\t// Finalize tool call arguments for this index when content block ends\n\t\t\tindex := int(root.Get(\"index\").Int())\n\t\t\tif accumulator, exists := toolCallsAccumulator[index]; exists {\n\t\t\t\tif accumulator.Arguments.Len() == 0 {\n\t\t\t\t\taccumulator.Arguments.WriteString(\"{}\")\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"message_delta\":\n\t\t\t// Extract stop reason and output token count when message ends\n\t\t\tif delta := root.Get(\"delta\"); delta.Exists() {\n\t\t\t\tif sr := delta.Get(\"stop_reason\"); sr.Exists() {\n\t\t\t\t\tstopReason = sr.String()\n\t\t\t\t}\n\t\t\t}\n\t\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t\tinputTokens := usage.Get(\"input_tokens\").Int()\n\t\t\t\toutputTokens := usage.Get(\"output_tokens\").Int()\n\t\t\t\tcacheReadInputTokens := usage.Get(\"cache_read_input_tokens\").Int()\n\t\t\t\tcacheCreationInputTokens := usage.Get(\"cache_creation_input_tokens\").Int()\n\t\t\t\tout, _ = sjson.Set(out, \"usage.prompt_tokens\", inputTokens+cacheCreationInputTokens)\n\t\t\t\tout, _ = sjson.Set(out, \"usage.completion_tokens\", outputTokens)\n\t\t\t\tout, _ = sjson.Set(out, \"usage.total_tokens\", inputTokens+outputTokens)\n\t\t\t\tout, _ = sjson.Set(out, \"usage.prompt_tokens_details.cached_tokens\", cacheReadInputTokens)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Set basic response fields including message ID, creation time, and model\n\tout, _ = sjson.Set(out, \"id\", messageID)\n\tout, _ = sjson.Set(out, \"created\", createdAt)\n\tout, _ = sjson.Set(out, \"model\", model)\n\n\t// Set message content by combining all text parts\n\tmessageContent := strings.Join(contentParts, \"\")\n\tout, _ = sjson.Set(out, \"choices.0.message.content\", messageContent)\n\n\t// Add reasoning content if available (following OpenAI reasoning format)\n\tif len(reasoningParts) > 0 {\n\t\treasoningContent := strings.Join(reasoningParts, \"\")\n\t\t// Add reasoning as a separate field in the message\n\t\tout, _ = sjson.Set(out, \"choices.0.message.reasoning\", reasoningContent)\n\t}\n\n\t// Set tool calls if any were accumulated during processing\n\tif len(toolCallsAccumulator) > 0 {\n\t\ttoolCallsCount := 0\n\t\tmaxIndex := -1\n\t\tfor index := range toolCallsAccumulator {\n\t\t\tif index > maxIndex {\n\t\t\t\tmaxIndex = index\n\t\t\t}\n\t\t}\n\n\t\tfor i := 0; i <= maxIndex; i++ {\n\t\t\taccumulator, exists := toolCallsAccumulator[i]\n\t\t\tif !exists {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\targuments := accumulator.Arguments.String()\n\n\t\t\tidPath := fmt.Sprintf(\"choices.0.message.tool_calls.%d.id\", toolCallsCount)\n\t\t\ttypePath := fmt.Sprintf(\"choices.0.message.tool_calls.%d.type\", toolCallsCount)\n\t\t\tnamePath := fmt.Sprintf(\"choices.0.message.tool_calls.%d.function.name\", toolCallsCount)\n\t\t\targumentsPath := fmt.Sprintf(\"choices.0.message.tool_calls.%d.function.arguments\", toolCallsCount)\n\n\t\t\tout, _ = sjson.Set(out, idPath, accumulator.ID)\n\t\t\tout, _ = sjson.Set(out, typePath, \"function\")\n\t\t\tout, _ = sjson.Set(out, namePath, accumulator.Name)\n\t\t\tout, _ = sjson.Set(out, argumentsPath, arguments)\n\t\t\ttoolCallsCount++\n\t\t}\n\t\tif toolCallsCount > 0 {\n\t\t\tout, _ = sjson.Set(out, \"choices.0.finish_reason\", \"tool_calls\")\n\t\t} else {\n\t\t\tout, _ = sjson.Set(out, \"choices.0.finish_reason\", mapAnthropicStopReasonToOpenAI(stopReason))\n\t\t}\n\t} else {\n\t\tout, _ = sjson.Set(out, \"choices.0.finish_reason\", mapAnthropicStopReasonToOpenAI(stopReason))\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tClaude,\n\t\tConvertOpenAIRequestToClaude,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertClaudeResponseToOpenAI,\n\t\t\tNonStream: ConvertClaudeResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/responses/claude_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tuser    = \"\"\n\taccount = \"\"\n\tsession = \"\"\n)\n\n// ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request\n// into a Claude Messages API request using only gjson/sjson for JSON handling.\n// It supports:\n// - instructions -> system message\n// - input[].type==message with input_text/output_text -> user/assistant messages\n// - function_call -> assistant tool_use\n// - function_call_output -> user tool_result\n// - tools[].parameters -> tools[].input_schema\n// - max_output_tokens -> max_tokens\n// - stream passthrough via parameter\nfunc ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\tif account == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\taccount = u.String()\n\t}\n\tif session == \"\" {\n\t\tu, _ := uuid.NewRandom()\n\t\tsession = u.String()\n\t}\n\tif user == \"\" {\n\t\tsum := sha256.Sum256([]byte(account + session))\n\t\tuser = hex.EncodeToString(sum[:])\n\t}\n\tuserID := fmt.Sprintf(\"user_%s_account_%s_session_%s\", user, account, session)\n\n\t// Base Claude message payload\n\tout := fmt.Sprintf(`{\"model\":\"\",\"max_tokens\":32000,\"messages\":[],\"metadata\":{\"user_id\":\"%s\"}}`, userID)\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Convert OpenAI Responses reasoning.effort to Claude thinking config.\n\tif v := root.Get(\"reasoning.effort\"); v.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(v.String()))\n\t\tif effort != \"\" {\n\t\t\tmi := registry.LookupModelInfo(modelName, \"claude\")\n\t\t\tsupportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0\n\t\t\tsupportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))\n\n\t\t\t// Claude 4.6 supports adaptive thinking with output_config.effort.\n\t\t\t// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid\n\t\t\t// validation errors since validate treats same-provider unsupported levels as errors.\n\t\t\tif supportsAdaptive {\n\t\t\t\tswitch effort {\n\t\t\t\tcase \"none\":\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\tcase \"auto\":\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"output_config.effort\")\n\t\t\t\tdefault:\n\t\t\t\t\tif mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {\n\t\t\t\t\t\teffort = mapped\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"adaptive\")\n\t\t\t\t\tout, _ = sjson.Delete(out, \"thinking.budget_tokens\")\n\t\t\t\t\tout, _ = sjson.Set(out, \"output_config.effort\", effort)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Legacy/manual thinking (budget_tokens).\n\t\t\t\tbudget, ok := thinking.ConvertLevelToBudget(effort)\n\t\t\t\tif ok {\n\t\t\t\t\tswitch budget {\n\t\t\t\t\tcase 0:\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"disabled\")\n\t\t\t\t\tcase -1:\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif budget > 0 {\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.type\", \"enabled\")\n\t\t\t\t\t\t\tout, _ = sjson.Set(out, \"thinking.budget_tokens\", budget)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Helper for generating tool call IDs when missing\n\tgenToolCallID := func() string {\n\t\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\tvar b strings.Builder\n\t\tfor i := 0; i < 24; i++ {\n\t\t\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\t\tb.WriteByte(letters[n.Int64()])\n\t\t}\n\t\treturn \"toolu_\" + b.String()\n\t}\n\n\t// Model\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Max tokens\n\tif mot := root.Get(\"max_output_tokens\"); mot.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", mot.Int())\n\t}\n\n\t// Stream\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// instructions -> as a leading message (use role user for Claude API compatibility)\n\tinstructionsText := \"\"\n\textractedFromSystem := false\n\tif instr := root.Get(\"instructions\"); instr.Exists() && instr.Type == gjson.String {\n\t\tinstructionsText = instr.String()\n\t\tif instructionsText != \"\" {\n\t\t\tsysMsg := `{\"role\":\"user\",\"content\":\"\"}`\n\t\t\tsysMsg, _ = sjson.Set(sysMsg, \"content\", instructionsText)\n\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", sysMsg)\n\t\t}\n\t}\n\n\tif instructionsText == \"\" {\n\t\tif input := root.Get(\"input\"); input.Exists() && input.IsArray() {\n\t\t\tinput.ForEach(func(_, item gjson.Result) bool {\n\t\t\t\tif strings.EqualFold(item.Get(\"role\").String(), \"system\") {\n\t\t\t\t\tvar builder strings.Builder\n\t\t\t\t\tif parts := item.Get(\"content\"); parts.Exists() && parts.IsArray() {\n\t\t\t\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\t\ttextResult := part.Get(\"text\")\n\t\t\t\t\t\t\ttext := textResult.String()\n\t\t\t\t\t\t\tif builder.Len() > 0 && text != \"\" {\n\t\t\t\t\t\t\t\tbuilder.WriteByte('\\n')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tbuilder.WriteString(text)\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t})\n\t\t\t\t\t} else if parts.Type == gjson.String {\n\t\t\t\t\t\tbuilder.WriteString(parts.String())\n\t\t\t\t\t}\n\t\t\t\t\tinstructionsText = builder.String()\n\t\t\t\t\tif instructionsText != \"\" {\n\t\t\t\t\t\tsysMsg := `{\"role\":\"user\",\"content\":\"\"}`\n\t\t\t\t\t\tsysMsg, _ = sjson.Set(sysMsg, \"content\", instructionsText)\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", sysMsg)\n\t\t\t\t\t\textractedFromSystem = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn instructionsText == \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\t// input array processing\n\tif input := root.Get(\"input\"); input.Exists() && input.IsArray() {\n\t\tinput.ForEach(func(_, item gjson.Result) bool {\n\t\t\tif extractedFromSystem && strings.EqualFold(item.Get(\"role\").String(), \"system\") {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\ttyp := item.Get(\"type\").String()\n\t\t\tif typ == \"\" && item.Get(\"role\").String() != \"\" {\n\t\t\t\ttyp = \"message\"\n\t\t\t}\n\t\t\tswitch typ {\n\t\t\tcase \"message\":\n\t\t\t\t// Determine role and construct Claude-compatible content parts.\n\t\t\t\tvar role string\n\t\t\t\tvar textAggregate strings.Builder\n\t\t\t\tvar partsJSON []string\n\t\t\t\thasImage := false\n\t\t\t\thasFile := false\n\t\t\t\tif parts := item.Get(\"content\"); parts.Exists() && parts.IsArray() {\n\t\t\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\tptype := part.Get(\"type\").String()\n\t\t\t\t\t\tswitch ptype {\n\t\t\t\t\t\tcase \"input_text\", \"output_text\":\n\t\t\t\t\t\t\tif t := part.Get(\"text\"); t.Exists() {\n\t\t\t\t\t\t\t\ttxt := t.String()\n\t\t\t\t\t\t\t\ttextAggregate.WriteString(txt)\n\t\t\t\t\t\t\t\tcontentPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"text\", txt)\n\t\t\t\t\t\t\t\tpartsJSON = append(partsJSON, contentPart)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif ptype == \"input_text\" {\n\t\t\t\t\t\t\t\trole = \"user\"\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\trole = \"assistant\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"input_image\":\n\t\t\t\t\t\t\turl := part.Get(\"image_url\").String()\n\t\t\t\t\t\t\tif url == \"\" {\n\t\t\t\t\t\t\t\turl = part.Get(\"url\").String()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif url != \"\" {\n\t\t\t\t\t\t\t\tvar contentPart string\n\t\t\t\t\t\t\t\tif strings.HasPrefix(url, \"data:\") {\n\t\t\t\t\t\t\t\t\ttrimmed := strings.TrimPrefix(url, \"data:\")\n\t\t\t\t\t\t\t\t\tmediaAndData := strings.SplitN(trimmed, \";base64,\", 2)\n\t\t\t\t\t\t\t\t\tmediaType := \"application/octet-stream\"\n\t\t\t\t\t\t\t\t\tdata := \"\"\n\t\t\t\t\t\t\t\t\tif len(mediaAndData) == 2 {\n\t\t\t\t\t\t\t\t\t\tif mediaAndData[0] != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tmediaType = mediaAndData[0]\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdata = mediaAndData[1]\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tif data != \"\" {\n\t\t\t\t\t\t\t\t\t\tcontentPart = `{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"source.media_type\", mediaType)\n\t\t\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"source.data\", data)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tcontentPart = `{\"type\":\"image\",\"source\":{\"type\":\"url\",\"url\":\"\"}}`\n\t\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"source.url\", url)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif contentPart != \"\" {\n\t\t\t\t\t\t\t\t\tpartsJSON = append(partsJSON, contentPart)\n\t\t\t\t\t\t\t\t\tif role == \"\" {\n\t\t\t\t\t\t\t\t\t\trole = \"user\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\thasImage = true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"input_file\":\n\t\t\t\t\t\t\tfileData := part.Get(\"file_data\").String()\n\t\t\t\t\t\t\tif fileData != \"\" {\n\t\t\t\t\t\t\t\tmediaType := \"application/octet-stream\"\n\t\t\t\t\t\t\t\tdata := fileData\n\t\t\t\t\t\t\t\tif strings.HasPrefix(fileData, \"data:\") {\n\t\t\t\t\t\t\t\t\ttrimmed := strings.TrimPrefix(fileData, \"data:\")\n\t\t\t\t\t\t\t\t\tmediaAndData := strings.SplitN(trimmed, \";base64,\", 2)\n\t\t\t\t\t\t\t\t\tif len(mediaAndData) == 2 {\n\t\t\t\t\t\t\t\t\t\tif mediaAndData[0] != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tmediaType = mediaAndData[0]\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdata = mediaAndData[1]\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcontentPart := `{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"source.media_type\", mediaType)\n\t\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"source.data\", data)\n\t\t\t\t\t\t\t\tpartsJSON = append(partsJSON, contentPart)\n\t\t\t\t\t\t\t\tif role == \"\" {\n\t\t\t\t\t\t\t\t\trole = \"user\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\thasFile = true\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t} else if parts.Type == gjson.String {\n\t\t\t\t\ttextAggregate.WriteString(parts.String())\n\t\t\t\t}\n\n\t\t\t\t// Fallback to given role if content types not decisive\n\t\t\t\tif role == \"\" {\n\t\t\t\t\tr := item.Get(\"role\").String()\n\t\t\t\t\tswitch r {\n\t\t\t\t\tcase \"user\", \"assistant\", \"system\":\n\t\t\t\t\t\trole = r\n\t\t\t\t\tdefault:\n\t\t\t\t\t\trole = \"user\"\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(partsJSON) > 0 {\n\t\t\t\t\tmsg := `{\"role\":\"\",\"content\":[]}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\t\t\t\t\tif len(partsJSON) == 1 && !hasImage && !hasFile {\n\t\t\t\t\t\t// Preserve legacy behavior for single text content\n\t\t\t\t\t\tmsg, _ = sjson.Delete(msg, \"content\")\n\t\t\t\t\t\ttextPart := gjson.Parse(partsJSON[0])\n\t\t\t\t\t\tmsg, _ = sjson.Set(msg, \"content\", textPart.Get(\"text\").String())\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfor _, partJSON := range partsJSON {\n\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", partJSON)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\t\t} else if textAggregate.Len() > 0 || role == \"system\" {\n\t\t\t\t\tmsg := `{\"role\":\"\",\"content\":\"\"}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"content\", textAggregate.String())\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\t\t}\n\n\t\t\tcase \"function_call\":\n\t\t\t\t// Map to assistant tool_use\n\t\t\t\tcallID := item.Get(\"call_id\").String()\n\t\t\t\tif callID == \"\" {\n\t\t\t\t\tcallID = genToolCallID()\n\t\t\t\t}\n\t\t\t\tname := item.Get(\"name\").String()\n\t\t\t\targsStr := item.Get(\"arguments\").String()\n\n\t\t\t\ttoolUse := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"id\", callID)\n\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"name\", name)\n\t\t\t\tif argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", argsJSON.Raw)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tasst := `{\"role\":\"assistant\",\"content\":[]}`\n\t\t\t\tasst, _ = sjson.SetRaw(asst, \"content.-1\", toolUse)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", asst)\n\n\t\t\tcase \"function_call_output\":\n\t\t\t\t// Map to user tool_result\n\t\t\t\tcallID := item.Get(\"call_id\").String()\n\t\t\t\toutputStr := item.Get(\"output\").String()\n\t\t\t\ttoolResult := `{\"type\":\"tool_result\",\"tool_use_id\":\"\",\"content\":\"\"}`\n\t\t\t\ttoolResult, _ = sjson.Set(toolResult, \"tool_use_id\", callID)\n\t\t\t\ttoolResult, _ = sjson.Set(toolResult, \"content\", outputStr)\n\n\t\t\t\tusr := `{\"role\":\"user\",\"content\":[]}`\n\t\t\t\tusr, _ = sjson.SetRaw(usr, \"content.-1\", toolResult)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", usr)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// tools mapping: parameters -> input_schema\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\ttoolsJSON := \"[]\"\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\ttJSON := `{\"name\":\"\",\"description\":\"\",\"input_schema\":{}}`\n\t\t\tif n := tool.Get(\"name\"); n.Exists() {\n\t\t\t\ttJSON, _ = sjson.Set(tJSON, \"name\", n.String())\n\t\t\t}\n\t\t\tif d := tool.Get(\"description\"); d.Exists() {\n\t\t\t\ttJSON, _ = sjson.Set(tJSON, \"description\", d.String())\n\t\t\t}\n\n\t\t\tif params := tool.Get(\"parameters\"); params.Exists() {\n\t\t\t\ttJSON, _ = sjson.SetRaw(tJSON, \"input_schema\", params.Raw)\n\t\t\t} else if params = tool.Get(\"parametersJsonSchema\"); params.Exists() {\n\t\t\t\ttJSON, _ = sjson.SetRaw(tJSON, \"input_schema\", params.Raw)\n\t\t\t}\n\n\t\t\ttoolsJSON, _ = sjson.SetRaw(toolsJSON, \"-1\", tJSON)\n\t\t\treturn true\n\t\t})\n\t\tif gjson.Parse(toolsJSON).IsArray() && len(gjson.Parse(toolsJSON).Array()) > 0 {\n\t\t\tout, _ = sjson.SetRaw(out, \"tools\", toolsJSON)\n\t\t}\n\t}\n\n\t// Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle)\n\tif toolChoice := root.Get(\"tool_choice\"); toolChoice.Exists() {\n\t\tswitch toolChoice.Type {\n\t\tcase gjson.String:\n\t\t\tswitch toolChoice.String() {\n\t\t\tcase \"auto\":\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"auto\"}`)\n\t\t\tcase \"none\":\n\t\t\t\t// Leave unset; implies no tools\n\t\t\tcase \"required\":\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", `{\"type\":\"any\"}`)\n\t\t\t}\n\t\tcase gjson.JSON:\n\t\t\tif toolChoice.Get(\"type\").String() == \"function\" {\n\t\t\t\tfn := toolChoice.Get(\"function.name\").String()\n\t\t\t\ttoolChoiceJSON := `{\"name\":\"\",\"type\":\"tool\"}`\n\t\t\t\ttoolChoiceJSON, _ = sjson.Set(toolChoiceJSON, \"name\", fn)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", toolChoiceJSON)\n\t\t\t}\n\t\tdefault:\n\n\t\t}\n\t}\n\n\treturn []byte(out)\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/responses/claude_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\ntype claudeToResponsesState struct {\n\tSeq          int\n\tResponseID   string\n\tCreatedAt    int64\n\tCurrentMsgID string\n\tCurrentFCID  string\n\tInTextBlock  bool\n\tInFuncBlock  bool\n\tFuncArgsBuf  map[int]*strings.Builder // index -> args\n\t// function call bookkeeping for output aggregation\n\tFuncNames   map[int]string // index -> function name\n\tFuncCallIDs map[int]string // index -> call id\n\t// message text aggregation\n\tTextBuf strings.Builder\n\t// reasoning state\n\tReasoningActive    bool\n\tReasoningItemID    string\n\tReasoningBuf       strings.Builder\n\tReasoningPartAdded bool\n\tReasoningIndex     int\n\t// usage aggregation\n\tInputTokens  int64\n\tOutputTokens int64\n\tUsageSeen    bool\n}\n\nvar dataTag = []byte(\"data:\")\n\nfunc pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {\n\tif len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {\n\t\treturn originalRequestRawJSON\n\t}\n\tif len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {\n\t\treturn requestRawJSON\n\t}\n\treturn nil\n}\n\nfunc emitEvent(event string, payload string) string {\n\treturn fmt.Sprintf(\"event: %s\\ndata: %s\", event, payload)\n}\n\n// ConvertClaudeResponseToOpenAIResponses converts Claude SSE to OpenAI Responses SSE events.\nfunc ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &claudeToResponsesState{FuncArgsBuf: make(map[int]*strings.Builder), FuncNames: make(map[int]string), FuncCallIDs: make(map[int]string)}\n\t}\n\tst := (*param).(*claudeToResponsesState)\n\n\t// Expect `data: {..}` from Claude clients\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\troot := gjson.ParseBytes(rawJSON)\n\tev := root.Get(\"type\").String()\n\tvar out []string\n\n\tnextSeq := func() int { st.Seq++; return st.Seq }\n\n\tswitch ev {\n\tcase \"message_start\":\n\t\tif msg := root.Get(\"message\"); msg.Exists() {\n\t\t\tst.ResponseID = msg.Get(\"id\").String()\n\t\t\tst.CreatedAt = time.Now().Unix()\n\t\t\t// Reset per-message aggregation state\n\t\t\tst.TextBuf.Reset()\n\t\t\tst.ReasoningBuf.Reset()\n\t\t\tst.ReasoningActive = false\n\t\t\tst.InTextBlock = false\n\t\t\tst.InFuncBlock = false\n\t\t\tst.CurrentMsgID = \"\"\n\t\t\tst.CurrentFCID = \"\"\n\t\t\tst.ReasoningItemID = \"\"\n\t\t\tst.ReasoningIndex = 0\n\t\t\tst.ReasoningPartAdded = false\n\t\t\tst.FuncArgsBuf = make(map[int]*strings.Builder)\n\t\t\tst.FuncNames = make(map[int]string)\n\t\t\tst.FuncCallIDs = make(map[int]string)\n\t\t\tst.InputTokens = 0\n\t\t\tst.OutputTokens = 0\n\t\t\tst.UsageSeen = false\n\t\t\tif usage := msg.Get(\"usage\"); usage.Exists() {\n\t\t\t\tif v := usage.Get(\"input_tokens\"); v.Exists() {\n\t\t\t\t\tst.InputTokens = v.Int()\n\t\t\t\t\tst.UsageSeen = true\n\t\t\t\t}\n\t\t\t\tif v := usage.Get(\"output_tokens\"); v.Exists() {\n\t\t\t\t\tst.OutputTokens = v.Int()\n\t\t\t\t\tst.UsageSeen = true\n\t\t\t\t}\n\t\t\t}\n\t\t\t// response.created\n\t\t\tcreated := `{\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"output\":[]}}`\n\t\t\tcreated, _ = sjson.Set(created, \"sequence_number\", nextSeq())\n\t\t\tcreated, _ = sjson.Set(created, \"response.id\", st.ResponseID)\n\t\t\tcreated, _ = sjson.Set(created, \"response.created_at\", st.CreatedAt)\n\t\t\tout = append(out, emitEvent(\"response.created\", created))\n\t\t\t// response.in_progress\n\t\t\tinprog := `{\"type\":\"response.in_progress\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\"}}`\n\t\t\tinprog, _ = sjson.Set(inprog, \"sequence_number\", nextSeq())\n\t\t\tinprog, _ = sjson.Set(inprog, \"response.id\", st.ResponseID)\n\t\t\tinprog, _ = sjson.Set(inprog, \"response.created_at\", st.CreatedAt)\n\t\t\tout = append(out, emitEvent(\"response.in_progress\", inprog))\n\t\t}\n\tcase \"content_block_start\":\n\t\tcb := root.Get(\"content_block\")\n\t\tif !cb.Exists() {\n\t\t\treturn out\n\t\t}\n\t\tidx := int(root.Get(\"index\").Int())\n\t\ttyp := cb.Get(\"type\").String()\n\t\tif typ == \"text\" {\n\t\t\t// open message item + content part\n\t\t\tst.InTextBlock = true\n\t\t\tst.CurrentMsgID = fmt.Sprintf(\"msg_%s_0\", st.ResponseID)\n\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}`\n\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\titem, _ = sjson.Set(item, \"item.id\", st.CurrentMsgID)\n\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\n\t\t\tpart := `{\"type\":\"response.content_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\tpart, _ = sjson.Set(part, \"item_id\", st.CurrentMsgID)\n\t\t\tout = append(out, emitEvent(\"response.content_part.added\", part))\n\t\t} else if typ == \"tool_use\" {\n\t\t\tst.InFuncBlock = true\n\t\t\tst.CurrentFCID = cb.Get(\"id\").String()\n\t\t\tname := cb.Get(\"name\").String()\n\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\titem, _ = sjson.Set(item, \"output_index\", idx)\n\t\t\titem, _ = sjson.Set(item, \"item.id\", fmt.Sprintf(\"fc_%s\", st.CurrentFCID))\n\t\t\titem, _ = sjson.Set(item, \"item.call_id\", st.CurrentFCID)\n\t\t\titem, _ = sjson.Set(item, \"item.name\", name)\n\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\t\t\tif st.FuncArgsBuf[idx] == nil {\n\t\t\t\tst.FuncArgsBuf[idx] = &strings.Builder{}\n\t\t\t}\n\t\t\t// record function metadata for aggregation\n\t\t\tst.FuncCallIDs[idx] = st.CurrentFCID\n\t\t\tst.FuncNames[idx] = name\n\t\t} else if typ == \"thinking\" {\n\t\t\t// start reasoning item\n\t\t\tst.ReasoningActive = true\n\t\t\tst.ReasoningIndex = idx\n\t\t\tst.ReasoningBuf.Reset()\n\t\t\tst.ReasoningItemID = fmt.Sprintf(\"rs_%s_%d\", st.ResponseID, idx)\n\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"summary\":[]}}`\n\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\titem, _ = sjson.Set(item, \"output_index\", idx)\n\t\t\titem, _ = sjson.Set(item, \"item.id\", st.ReasoningItemID)\n\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\t\t\t// add a summary part placeholder\n\t\t\tpart := `{\"type\":\"response.reasoning_summary_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\tpart, _ = sjson.Set(part, \"item_id\", st.ReasoningItemID)\n\t\t\tpart, _ = sjson.Set(part, \"output_index\", idx)\n\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_part.added\", part))\n\t\t\tst.ReasoningPartAdded = true\n\t\t}\n\tcase \"content_block_delta\":\n\t\td := root.Get(\"delta\")\n\t\tif !d.Exists() {\n\t\t\treturn out\n\t\t}\n\t\tdt := d.Get(\"type\").String()\n\t\tif dt == \"text_delta\" {\n\t\t\tif t := d.Get(\"text\"); t.Exists() {\n\t\t\t\tmsg := `{\"type\":\"response.output_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"delta\":\"\",\"logprobs\":[]}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.CurrentMsgID)\n\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", t.String())\n\t\t\t\tout = append(out, emitEvent(\"response.output_text.delta\", msg))\n\t\t\t\t// aggregate text for response.output\n\t\t\t\tst.TextBuf.WriteString(t.String())\n\t\t\t}\n\t\t} else if dt == \"input_json_delta\" {\n\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\tif pj := d.Get(\"partial_json\"); pj.Exists() {\n\t\t\t\tif st.FuncArgsBuf[idx] == nil {\n\t\t\t\t\tst.FuncArgsBuf[idx] = &strings.Builder{}\n\t\t\t\t}\n\t\t\t\tst.FuncArgsBuf[idx].WriteString(pj.String())\n\t\t\t\tmsg := `{\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"delta\":\"\"}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", fmt.Sprintf(\"fc_%s\", st.CurrentFCID))\n\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", idx)\n\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", pj.String())\n\t\t\t\tout = append(out, emitEvent(\"response.function_call_arguments.delta\", msg))\n\t\t\t}\n\t\t} else if dt == \"thinking_delta\" {\n\t\t\tif st.ReasoningActive {\n\t\t\t\tif t := d.Get(\"thinking\"); t.Exists() {\n\t\t\t\t\tst.ReasoningBuf.WriteString(t.String())\n\t\t\t\t\tmsg := `{\"type\":\"response.reasoning_summary_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"delta\":\"\"}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.ReasoningItemID)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", t.String())\n\t\t\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_text.delta\", msg))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"content_block_stop\":\n\t\tidx := int(root.Get(\"index\").Int())\n\t\tif st.InTextBlock {\n\t\t\tdone := `{\"type\":\"response.output_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"text\":\"\",\"logprobs\":[]}`\n\t\t\tdone, _ = sjson.Set(done, \"sequence_number\", nextSeq())\n\t\t\tdone, _ = sjson.Set(done, \"item_id\", st.CurrentMsgID)\n\t\t\tout = append(out, emitEvent(\"response.output_text.done\", done))\n\t\t\tpartDone := `{\"type\":\"response.content_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.CurrentMsgID)\n\t\t\tout = append(out, emitEvent(\"response.content_part.done\", partDone))\n\t\t\tfinal := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"\"}],\"role\":\"assistant\"}}`\n\t\t\tfinal, _ = sjson.Set(final, \"sequence_number\", nextSeq())\n\t\t\tfinal, _ = sjson.Set(final, \"item.id\", st.CurrentMsgID)\n\t\t\tout = append(out, emitEvent(\"response.output_item.done\", final))\n\t\t\tst.InTextBlock = false\n\t\t} else if st.InFuncBlock {\n\t\t\targs := \"{}\"\n\t\t\tif buf := st.FuncArgsBuf[idx]; buf != nil {\n\t\t\t\tif buf.Len() > 0 {\n\t\t\t\t\targs = buf.String()\n\t\t\t\t}\n\t\t\t}\n\t\t\tfcDone := `{\"type\":\"response.function_call_arguments.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"arguments\":\"\"}`\n\t\t\tfcDone, _ = sjson.Set(fcDone, \"sequence_number\", nextSeq())\n\t\t\tfcDone, _ = sjson.Set(fcDone, \"item_id\", fmt.Sprintf(\"fc_%s\", st.CurrentFCID))\n\t\t\tfcDone, _ = sjson.Set(fcDone, \"output_index\", idx)\n\t\t\tfcDone, _ = sjson.Set(fcDone, \"arguments\", args)\n\t\t\tout = append(out, emitEvent(\"response.function_call_arguments.done\", fcDone))\n\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", idx)\n\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"fc_%s\", st.CurrentFCID))\n\t\t\titemDone, _ = sjson.Set(itemDone, \"item.arguments\", args)\n\t\t\titemDone, _ = sjson.Set(itemDone, \"item.call_id\", st.CurrentFCID)\n\t\t\titemDone, _ = sjson.Set(itemDone, \"item.name\", st.FuncNames[idx])\n\t\t\tout = append(out, emitEvent(\"response.output_item.done\", itemDone))\n\t\t\tst.InFuncBlock = false\n\t\t} else if st.ReasoningActive {\n\t\t\tfull := st.ReasoningBuf.String()\n\t\t\ttextDone := `{\"type\":\"response.reasoning_summary_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"text\":\"\"}`\n\t\t\ttextDone, _ = sjson.Set(textDone, \"sequence_number\", nextSeq())\n\t\t\ttextDone, _ = sjson.Set(textDone, \"item_id\", st.ReasoningItemID)\n\t\t\ttextDone, _ = sjson.Set(textDone, \"output_index\", st.ReasoningIndex)\n\t\t\ttextDone, _ = sjson.Set(textDone, \"text\", full)\n\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_text.done\", textDone))\n\t\t\tpartDone := `{\"type\":\"response.reasoning_summary_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.ReasoningItemID)\n\t\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", st.ReasoningIndex)\n\t\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", full)\n\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_part.done\", partDone))\n\t\t\tst.ReasoningActive = false\n\t\t\tst.ReasoningPartAdded = false\n\t\t}\n\tcase \"message_delta\":\n\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\tif v := usage.Get(\"output_tokens\"); v.Exists() {\n\t\t\t\tst.OutputTokens = v.Int()\n\t\t\t\tst.UsageSeen = true\n\t\t\t}\n\t\t\tif v := usage.Get(\"input_tokens\"); v.Exists() {\n\t\t\t\tst.InputTokens = v.Int()\n\t\t\t\tst.UsageSeen = true\n\t\t\t}\n\t\t}\n\tcase \"message_stop\":\n\n\t\tcompleted := `{\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}`\n\t\tcompleted, _ = sjson.Set(completed, \"sequence_number\", nextSeq())\n\t\tcompleted, _ = sjson.Set(completed, \"response.id\", st.ResponseID)\n\t\tcompleted, _ = sjson.Set(completed, \"response.created_at\", st.CreatedAt)\n\t\t// Inject original request fields into response as per docs/response.completed.json\n\n\t\treqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)\n\t\tif len(reqBytes) > 0 {\n\t\t\treq := gjson.ParseBytes(reqBytes)\n\t\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.instructions\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_output_tokens\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_tool_calls\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.model\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.parallel_tool_calls\", v.Bool())\n\t\t\t}\n\t\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.previous_response_id\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.prompt_cache_key\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.reasoning\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.safety_identifier\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.service_tier\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.store\", v.Bool())\n\t\t\t}\n\t\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.temperature\", v.Float())\n\t\t\t}\n\t\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.text\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tool_choice\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tools\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_logprobs\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_p\", v.Float())\n\t\t\t}\n\t\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.truncation\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.user\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.metadata\", v.Value())\n\t\t\t}\n\t\t}\n\n\t\t// Build response.output from aggregated state\n\t\toutputsWrapper := `{\"arr\":[]}`\n\t\t// reasoning item (if any)\n\t\tif st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded {\n\t\t\titem := `{\"id\":\"\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]}`\n\t\t\titem, _ = sjson.Set(item, \"id\", st.ReasoningItemID)\n\t\t\titem, _ = sjson.Set(item, \"summary.0.text\", st.ReasoningBuf.String())\n\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t}\n\t\t// assistant message item (if any text)\n\t\tif st.TextBuf.Len() > 0 || st.InTextBlock || st.CurrentMsgID != \"\" {\n\t\t\titem := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\t\titem, _ = sjson.Set(item, \"id\", st.CurrentMsgID)\n\t\t\titem, _ = sjson.Set(item, \"content.0.text\", st.TextBuf.String())\n\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t}\n\t\t// function_call items (in ascending index order for determinism)\n\t\tif len(st.FuncArgsBuf) > 0 {\n\t\t\t// collect indices\n\t\t\tidxs := make([]int, 0, len(st.FuncArgsBuf))\n\t\t\tfor idx := range st.FuncArgsBuf {\n\t\t\t\tidxs = append(idxs, idx)\n\t\t\t}\n\t\t\t// simple sort (small N), avoid adding new imports\n\t\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, idx := range idxs {\n\t\t\t\targs := \"\"\n\t\t\t\tif b := st.FuncArgsBuf[idx]; b != nil {\n\t\t\t\t\targs = b.String()\n\t\t\t\t}\n\t\t\t\tcallID := st.FuncCallIDs[idx]\n\t\t\t\tname := st.FuncNames[idx]\n\t\t\t\tif callID == \"\" && st.CurrentFCID != \"\" {\n\t\t\t\t\tcallID = st.CurrentFCID\n\t\t\t\t}\n\t\t\t\titem := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\titem, _ = sjson.Set(item, \"arguments\", args)\n\t\t\t\titem, _ = sjson.Set(item, \"call_id\", callID)\n\t\t\t\titem, _ = sjson.Set(item, \"name\", name)\n\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t}\n\t\t}\n\t\tif gjson.Get(outputsWrapper, \"arr.#\").Int() > 0 {\n\t\t\tcompleted, _ = sjson.SetRaw(completed, \"response.output\", gjson.Get(outputsWrapper, \"arr\").Raw)\n\t\t}\n\n\t\treasoningTokens := int64(0)\n\t\tif st.ReasoningBuf.Len() > 0 {\n\t\t\treasoningTokens = int64(st.ReasoningBuf.Len() / 4)\n\t\t}\n\t\tusagePresent := st.UsageSeen || reasoningTokens > 0\n\t\tif usagePresent {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens\", st.InputTokens)\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens_details.cached_tokens\", 0)\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens\", st.OutputTokens)\n\t\t\tif reasoningTokens > 0 {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens_details.reasoning_tokens\", reasoningTokens)\n\t\t\t}\n\t\t\ttotal := st.InputTokens + st.OutputTokens\n\t\t\tif total > 0 || st.UsageSeen {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.total_tokens\", total)\n\t\t\t}\n\t\t}\n\t\tout = append(out, emitEvent(\"response.completed\", completed))\n\t}\n\n\treturn out\n}\n\n// ConvertClaudeResponseToOpenAIResponsesNonStream aggregates Claude SSE into a single OpenAI Responses JSON.\nfunc ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t// Aggregate Claude SSE lines into a single OpenAI Responses JSON (non-stream)\n\t// We follow the same aggregation logic as the streaming variant but produce\n\t// one final object matching docs/out.json structure.\n\n\t// Collect SSE data: lines start with \"data: \"; ignore others\n\tvar chunks [][]byte\n\t{\n\t\t// Use a simple scanner to iterate through raw bytes\n\t\t// Note: extremely large responses may require increasing the buffer\n\t\tscanner := bufio.NewScanner(bytes.NewReader(rawJSON))\n\t\tbuf := make([]byte, 52_428_800) // 50MB\n\t\tscanner.Buffer(buf, 52_428_800)\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tif !bytes.HasPrefix(line, dataTag) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchunks = append(chunks, line[len(dataTag):])\n\t\t}\n\t}\n\n\t// Base OpenAI Responses (non-stream) object\n\tout := `{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"output\":[],\"usage\":{\"input_tokens\":0,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{},\"total_tokens\":0}}`\n\n\t// Aggregation state\n\tvar (\n\t\tresponseID      string\n\t\tcreatedAt       int64\n\t\tcurrentMsgID    string\n\t\tcurrentFCID     string\n\t\ttextBuf         strings.Builder\n\t\treasoningBuf    strings.Builder\n\t\treasoningActive bool\n\t\treasoningItemID string\n\t\tinputTokens     int64\n\t\toutputTokens    int64\n\t)\n\n\t// Per-index tool call aggregation\n\ttype toolState struct {\n\t\tid   string\n\t\tname string\n\t\targs strings.Builder\n\t}\n\ttoolCalls := make(map[int]*toolState)\n\n\t// Walk through SSE chunks to fill state\n\tfor _, ch := range chunks {\n\t\troot := gjson.ParseBytes(ch)\n\t\tev := root.Get(\"type\").String()\n\n\t\tswitch ev {\n\t\tcase \"message_start\":\n\t\t\tif msg := root.Get(\"message\"); msg.Exists() {\n\t\t\t\tresponseID = msg.Get(\"id\").String()\n\t\t\t\tcreatedAt = time.Now().Unix()\n\t\t\t\tif usage := msg.Get(\"usage\"); usage.Exists() {\n\t\t\t\t\tinputTokens = usage.Get(\"input_tokens\").Int()\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"content_block_start\":\n\t\t\tcb := root.Get(\"content_block\")\n\t\t\tif !cb.Exists() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\ttyp := cb.Get(\"type\").String()\n\t\t\tswitch typ {\n\t\t\tcase \"text\":\n\t\t\t\tcurrentMsgID = \"msg_\" + responseID + \"_0\"\n\t\t\tcase \"tool_use\":\n\t\t\t\tcurrentFCID = cb.Get(\"id\").String()\n\t\t\t\tname := cb.Get(\"name\").String()\n\t\t\t\tif toolCalls[idx] == nil {\n\t\t\t\t\ttoolCalls[idx] = &toolState{id: currentFCID, name: name}\n\t\t\t\t} else {\n\t\t\t\t\ttoolCalls[idx].id = currentFCID\n\t\t\t\t\ttoolCalls[idx].name = name\n\t\t\t\t}\n\t\t\tcase \"thinking\":\n\t\t\t\treasoningActive = true\n\t\t\t\treasoningItemID = fmt.Sprintf(\"rs_%s_%d\", responseID, idx)\n\t\t\t}\n\n\t\tcase \"content_block_delta\":\n\t\t\td := root.Get(\"delta\")\n\t\t\tif !d.Exists() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdt := d.Get(\"type\").String()\n\t\t\tswitch dt {\n\t\t\tcase \"text_delta\":\n\t\t\t\tif t := d.Get(\"text\"); t.Exists() {\n\t\t\t\t\ttextBuf.WriteString(t.String())\n\t\t\t\t}\n\t\t\tcase \"input_json_delta\":\n\t\t\t\tif pj := d.Get(\"partial_json\"); pj.Exists() {\n\t\t\t\t\tidx := int(root.Get(\"index\").Int())\n\t\t\t\t\tif toolCalls[idx] == nil {\n\t\t\t\t\t\ttoolCalls[idx] = &toolState{}\n\t\t\t\t\t}\n\t\t\t\t\ttoolCalls[idx].args.WriteString(pj.String())\n\t\t\t\t}\n\t\t\tcase \"thinking_delta\":\n\t\t\t\tif reasoningActive {\n\t\t\t\t\tif t := d.Get(\"thinking\"); t.Exists() {\n\t\t\t\t\t\treasoningBuf.WriteString(t.String())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"content_block_stop\":\n\t\t\t// Nothing special to finalize for non-stream aggregation\n\t\t\t_ = root\n\n\t\tcase \"message_delta\":\n\t\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t\toutputTokens = usage.Get(\"output_tokens\").Int()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Populate base fields\n\tout, _ = sjson.Set(out, \"id\", responseID)\n\tout, _ = sjson.Set(out, \"created_at\", createdAt)\n\n\t// Inject request echo fields as top-level (similar to streaming variant)\n\treqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)\n\tif len(reqBytes) > 0 {\n\t\treq := gjson.ParseBytes(reqBytes)\n\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"instructions\", v.String())\n\t\t}\n\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"max_output_tokens\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"max_tool_calls\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"model\", v.String())\n\t\t}\n\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"parallel_tool_calls\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"previous_response_id\", v.String())\n\t\t}\n\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"prompt_cache_key\", v.String())\n\t\t}\n\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"reasoning\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"safety_identifier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"service_tier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"store\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"temperature\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"text\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"tools\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"top_logprobs\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"top_p\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"truncation\", v.String())\n\t\t}\n\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"user\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"metadata\", v.Value())\n\t\t}\n\t}\n\n\t// Build output array\n\toutputsWrapper := `{\"arr\":[]}`\n\tif reasoningBuf.Len() > 0 {\n\t\titem := `{\"id\":\"\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]}`\n\t\titem, _ = sjson.Set(item, \"id\", reasoningItemID)\n\t\titem, _ = sjson.Set(item, \"summary.0.text\", reasoningBuf.String())\n\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t}\n\tif currentMsgID != \"\" || textBuf.Len() > 0 {\n\t\titem := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\titem, _ = sjson.Set(item, \"id\", currentMsgID)\n\t\titem, _ = sjson.Set(item, \"content.0.text\", textBuf.String())\n\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t}\n\tif len(toolCalls) > 0 {\n\t\t// Preserve index order\n\t\tidxs := make([]int, 0, len(toolCalls))\n\t\tfor i := range toolCalls {\n\t\t\tidxs = append(idxs, i)\n\t\t}\n\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor _, i := range idxs {\n\t\t\tst := toolCalls[i]\n\t\t\targs := st.args.String()\n\t\t\tif args == \"\" {\n\t\t\t\targs = \"{}\"\n\t\t\t}\n\t\t\titem := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"fc_%s\", st.id))\n\t\t\titem, _ = sjson.Set(item, \"arguments\", args)\n\t\t\titem, _ = sjson.Set(item, \"call_id\", st.id)\n\t\t\titem, _ = sjson.Set(item, \"name\", st.name)\n\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t}\n\t}\n\tif gjson.Get(outputsWrapper, \"arr.#\").Int() > 0 {\n\t\tout, _ = sjson.SetRaw(out, \"output\", gjson.Get(outputsWrapper, \"arr\").Raw)\n\t}\n\n\t// Usage\n\ttotal := inputTokens + outputTokens\n\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\tout, _ = sjson.Set(out, \"usage.total_tokens\", total)\n\tif reasoningBuf.Len() > 0 {\n\t\t// Rough estimate similar to chat completions\n\t\treasoningTokens := int64(len(reasoningBuf.String()) / 4)\n\t\tif reasoningTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.output_tokens_details.reasoning_tokens\", reasoningTokens)\n\t\t}\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "internal/translator/claude/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tClaude,\n\t\tConvertOpenAIResponsesRequestToClaude,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertClaudeResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertClaudeResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/codex/claude/codex_claude_request.go",
    "content": "// Package claude provides request translation functionality for Claude Code API compatibility.\n// It handles parsing and transforming Claude Code API requests into the internal client format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package also performs JSON data cleaning and transformation to ensure compatibility\n// between Claude Code API format and the internal client's expected format.\npackage claude\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertClaudeRequestToCodex parses and transforms a Claude Code API request into the internal client format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the internal client.\n// The function performs the following transformations:\n// 1. Sets up a template with the model name and empty instructions field\n// 2. Processes system messages and converts them to developer input content\n// 3. Transforms message contents (text, image, tool_use, tool_result) to appropriate formats\n// 4. Converts tools declarations to the expected format\n// 5. Adds additional configuration parameters for the Codex API\n// 6. Maps Claude thinking configuration to Codex reasoning settings\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Claude Code API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in internal client format\nfunc ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\n\ttemplate := `{\"model\":\"\",\"instructions\":\"\",\"input\":[]}`\n\n\trootResult := gjson.ParseBytes(rawJSON)\n\ttemplate, _ = sjson.Set(template, \"model\", modelName)\n\n\t// Process system messages and convert them to input content format.\n\tsystemsResult := rootResult.Get(\"system\")\n\tif systemsResult.Exists() {\n\t\tmessage := `{\"type\":\"message\",\"role\":\"developer\",\"content\":[]}`\n\t\tcontentIndex := 0\n\n\t\tappendSystemText := func(text string) {\n\t\t\tif text == \"\" || strings.HasPrefix(text, \"x-anthropic-billing-header: \") {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.type\", contentIndex), \"input_text\")\n\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.text\", contentIndex), text)\n\t\t\tcontentIndex++\n\t\t}\n\n\t\tif systemsResult.Type == gjson.String {\n\t\t\tappendSystemText(systemsResult.String())\n\t\t} else if systemsResult.IsArray() {\n\t\t\tsystemResults := systemsResult.Array()\n\t\t\tfor i := 0; i < len(systemResults); i++ {\n\t\t\t\tsystemResult := systemResults[i]\n\t\t\t\tif systemResult.Get(\"type\").String() == \"text\" {\n\t\t\t\t\tappendSystemText(systemResult.Get(\"text\").String())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif contentIndex > 0 {\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"input.-1\", message)\n\t\t}\n\t}\n\n\t// Process messages and transform their contents to appropriate formats.\n\tmessagesResult := rootResult.Get(\"messages\")\n\tif messagesResult.IsArray() {\n\t\tmessageResults := messagesResult.Array()\n\n\t\tfor i := 0; i < len(messageResults); i++ {\n\t\t\tmessageResult := messageResults[i]\n\t\t\tmessageRole := messageResult.Get(\"role\").String()\n\n\t\t\tnewMessage := func() string {\n\t\t\t\tmsg := `{\"type\": \"message\",\"role\":\"\",\"content\":[]}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", messageRole)\n\t\t\t\treturn msg\n\t\t\t}\n\n\t\t\tmessage := newMessage()\n\t\t\tcontentIndex := 0\n\t\t\thasContent := false\n\n\t\t\tflushMessage := func() {\n\t\t\t\tif hasContent {\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"input.-1\", message)\n\t\t\t\t\tmessage = newMessage()\n\t\t\t\t\tcontentIndex = 0\n\t\t\t\t\thasContent = false\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tappendTextContent := func(text string) {\n\t\t\t\tpartType := \"input_text\"\n\t\t\t\tif messageRole == \"assistant\" {\n\t\t\t\t\tpartType = \"output_text\"\n\t\t\t\t}\n\t\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.type\", contentIndex), partType)\n\t\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.text\", contentIndex), text)\n\t\t\t\tcontentIndex++\n\t\t\t\thasContent = true\n\t\t\t}\n\n\t\t\tappendImageContent := func(dataURL string) {\n\t\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.type\", contentIndex), \"input_image\")\n\t\t\t\tmessage, _ = sjson.Set(message, fmt.Sprintf(\"content.%d.image_url\", contentIndex), dataURL)\n\t\t\t\tcontentIndex++\n\t\t\t\thasContent = true\n\t\t\t}\n\n\t\t\tmessageContentsResult := messageResult.Get(\"content\")\n\t\t\tif messageContentsResult.IsArray() {\n\t\t\t\tmessageContentResults := messageContentsResult.Array()\n\t\t\t\tfor j := 0; j < len(messageContentResults); j++ {\n\t\t\t\t\tmessageContentResult := messageContentResults[j]\n\t\t\t\t\tcontentType := messageContentResult.Get(\"type\").String()\n\n\t\t\t\t\tswitch contentType {\n\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\tappendTextContent(messageContentResult.Get(\"text\").String())\n\t\t\t\t\tcase \"image\":\n\t\t\t\t\t\tsourceResult := messageContentResult.Get(\"source\")\n\t\t\t\t\t\tif sourceResult.Exists() {\n\t\t\t\t\t\t\tdata := sourceResult.Get(\"data\").String()\n\t\t\t\t\t\t\tif data == \"\" {\n\t\t\t\t\t\t\t\tdata = sourceResult.Get(\"base64\").String()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif data != \"\" {\n\t\t\t\t\t\t\t\tmediaType := sourceResult.Get(\"media_type\").String()\n\t\t\t\t\t\t\t\tif mediaType == \"\" {\n\t\t\t\t\t\t\t\t\tmediaType = sourceResult.Get(\"mime_type\").String()\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif mediaType == \"\" {\n\t\t\t\t\t\t\t\t\tmediaType = \"application/octet-stream\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdataURL := fmt.Sprintf(\"data:%s;base64,%s\", mediaType, data)\n\t\t\t\t\t\t\t\tappendImageContent(dataURL)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\tcase \"tool_use\":\n\t\t\t\t\t\tflushMessage()\n\t\t\t\t\t\tfunctionCallMessage := `{\"type\":\"function_call\"}`\n\t\t\t\t\t\tfunctionCallMessage, _ = sjson.Set(functionCallMessage, \"call_id\", messageContentResult.Get(\"id\").String())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname := messageContentResult.Get(\"name\").String()\n\t\t\t\t\t\t\ttoolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)\n\t\t\t\t\t\t\tif short, ok := toolMap[name]; ok {\n\t\t\t\t\t\t\t\tname = short\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfunctionCallMessage, _ = sjson.Set(functionCallMessage, \"name\", name)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunctionCallMessage, _ = sjson.Set(functionCallMessage, \"arguments\", messageContentResult.Get(\"input\").Raw)\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"input.-1\", functionCallMessage)\n\t\t\t\t\tcase \"tool_result\":\n\t\t\t\t\t\tflushMessage()\n\t\t\t\t\t\tfunctionCallOutputMessage := `{\"type\":\"function_call_output\"}`\n\t\t\t\t\t\tfunctionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, \"call_id\", messageContentResult.Get(\"tool_use_id\").String())\n\n\t\t\t\t\t\tcontentResult := messageContentResult.Get(\"content\")\n\t\t\t\t\t\tif contentResult.IsArray() {\n\t\t\t\t\t\t\ttoolResultContentIndex := 0\n\t\t\t\t\t\t\ttoolResultContent := `[]`\n\t\t\t\t\t\t\tcontentResults := contentResult.Array()\n\t\t\t\t\t\t\tfor k := 0; k < len(contentResults); k++ {\n\t\t\t\t\t\t\t\ttoolResultContentType := contentResults[k].Get(\"type\").String()\n\t\t\t\t\t\t\t\tif toolResultContentType == \"image\" {\n\t\t\t\t\t\t\t\t\tsourceResult := contentResults[k].Get(\"source\")\n\t\t\t\t\t\t\t\t\tif sourceResult.Exists() {\n\t\t\t\t\t\t\t\t\t\tdata := sourceResult.Get(\"data\").String()\n\t\t\t\t\t\t\t\t\t\tif data == \"\" {\n\t\t\t\t\t\t\t\t\t\t\tdata = sourceResult.Get(\"base64\").String()\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tif data != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tmediaType := sourceResult.Get(\"media_type\").String()\n\t\t\t\t\t\t\t\t\t\t\tif mediaType == \"\" {\n\t\t\t\t\t\t\t\t\t\t\t\tmediaType = sourceResult.Get(\"mime_type\").String()\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tif mediaType == \"\" {\n\t\t\t\t\t\t\t\t\t\t\t\tmediaType = \"application/octet-stream\"\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tdataURL := fmt.Sprintf(\"data:%s;base64,%s\", mediaType, data)\n\n\t\t\t\t\t\t\t\t\t\t\ttoolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf(\"%d.type\", toolResultContentIndex), \"input_image\")\n\t\t\t\t\t\t\t\t\t\t\ttoolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf(\"%d.image_url\", toolResultContentIndex), dataURL)\n\t\t\t\t\t\t\t\t\t\t\ttoolResultContentIndex++\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} else if toolResultContentType == \"text\" {\n\t\t\t\t\t\t\t\t\ttoolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf(\"%d.type\", toolResultContentIndex), \"input_text\")\n\t\t\t\t\t\t\t\t\ttoolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf(\"%d.text\", toolResultContentIndex), contentResults[k].Get(\"text\").String())\n\t\t\t\t\t\t\t\t\ttoolResultContentIndex++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif toolResultContent != `[]` {\n\t\t\t\t\t\t\t\tfunctionCallOutputMessage, _ = sjson.SetRaw(functionCallOutputMessage, \"output\", toolResultContent)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tfunctionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, \"output\", messageContentResult.Get(\"content\").String())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tfunctionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, \"output\", messageContentResult.Get(\"content\").String())\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"input.-1\", functionCallOutputMessage)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tflushMessage()\n\t\t\t} else if messageContentsResult.Type == gjson.String {\n\t\t\t\tappendTextContent(messageContentsResult.String())\n\t\t\t\tflushMessage()\n\t\t\t}\n\t\t}\n\n\t}\n\n\t// Convert tools declarations to the expected format for the Codex API.\n\ttoolsResult := rootResult.Get(\"tools\")\n\tif toolsResult.IsArray() {\n\t\ttemplate, _ = sjson.SetRaw(template, \"tools\", `[]`)\n\t\ttemplate, _ = sjson.Set(template, \"tool_choice\", `auto`)\n\t\ttoolResults := toolsResult.Array()\n\t\t// Build short name map from declared tools\n\t\tvar names []string\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\tn := toolResults[i].Get(\"name\").String()\n\t\t\tif n != \"\" {\n\t\t\t\tnames = append(names, n)\n\t\t\t}\n\t\t}\n\t\tshortMap := buildShortNameMap(names)\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\ttoolResult := toolResults[i]\n\t\t\t// Special handling: map Claude web search tool to Codex web_search\n\t\t\tif toolResult.Get(\"type\").String() == \"web_search_20250305\" {\n\t\t\t\t// Replace the tool content entirely with {\"type\":\"web_search\"}\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"tools.-1\", `{\"type\":\"web_search\"}`)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttool := toolResult.Raw\n\t\t\ttool, _ = sjson.Set(tool, \"type\", \"function\")\n\t\t\t// Apply shortened name if needed\n\t\t\tif v := toolResult.Get(\"name\"); v.Exists() {\n\t\t\t\tname := v.String()\n\t\t\t\tif short, ok := shortMap[name]; ok {\n\t\t\t\t\tname = short\n\t\t\t\t} else {\n\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t}\n\t\t\t\ttool, _ = sjson.Set(tool, \"name\", name)\n\t\t\t}\n\t\t\ttool, _ = sjson.SetRaw(tool, \"parameters\", normalizeToolParameters(toolResult.Get(\"input_schema\").Raw))\n\t\t\ttool, _ = sjson.Delete(tool, \"input_schema\")\n\t\t\ttool, _ = sjson.Delete(tool, \"parameters.$schema\")\n\t\t\ttool, _ = sjson.Delete(tool, \"cache_control\")\n\t\t\ttool, _ = sjson.Delete(tool, \"defer_loading\")\n\t\t\ttool, _ = sjson.Set(tool, \"strict\", false)\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"tools.-1\", tool)\n\t\t}\n\t}\n\n\t// Add additional configuration parameters for the Codex API.\n\ttemplate, _ = sjson.Set(template, \"parallel_tool_calls\", true)\n\n\t// Convert thinking.budget_tokens to reasoning.effort.\n\treasoningEffort := \"medium\"\n\tif thinkingConfig := rootResult.Get(\"thinking\"); thinkingConfig.Exists() && thinkingConfig.IsObject() {\n\t\tswitch thinkingConfig.Get(\"type\").String() {\n\t\tcase \"enabled\":\n\t\t\tif budgetTokens := thinkingConfig.Get(\"budget_tokens\"); budgetTokens.Exists() {\n\t\t\t\tbudget := int(budgetTokens.Int())\n\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != \"\" {\n\t\t\t\t\treasoningEffort = effort\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"adaptive\", \"auto\":\n\t\t\t// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).\n\t\t\t// Pass through directly; ApplyThinking handles clamping to target model's levels.\n\t\t\teffort := \"\"\n\t\t\tif v := rootResult.Get(\"output_config.effort\"); v.Exists() && v.Type == gjson.String {\n\t\t\t\teffort = strings.ToLower(strings.TrimSpace(v.String()))\n\t\t\t}\n\t\t\tif effort != \"\" {\n\t\t\t\treasoningEffort = effort\n\t\t\t} else {\n\t\t\t\treasoningEffort = string(thinking.LevelXHigh)\n\t\t\t}\n\t\tcase \"disabled\":\n\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != \"\" {\n\t\t\t\treasoningEffort = effort\n\t\t\t}\n\t\t}\n\t}\n\ttemplate, _ = sjson.Set(template, \"reasoning.effort\", reasoningEffort)\n\ttemplate, _ = sjson.Set(template, \"reasoning.summary\", \"auto\")\n\ttemplate, _ = sjson.Set(template, \"stream\", true)\n\ttemplate, _ = sjson.Set(template, \"store\", false)\n\ttemplate, _ = sjson.Set(template, \"include\", []string{\"reasoning.encrypted_content\"})\n\n\treturn []byte(template)\n}\n\n// shortenNameIfNeeded applies a simple shortening rule for a single name.\nfunc shortenNameIfNeeded(name string) string {\n\tconst limit = 64\n\tif len(name) <= limit {\n\t\treturn name\n\t}\n\tif strings.HasPrefix(name, \"mcp__\") {\n\t\tidx := strings.LastIndex(name, \"__\")\n\t\tif idx > 0 {\n\t\t\tcand := \"mcp__\" + name[idx+2:]\n\t\t\tif len(cand) > limit {\n\t\t\t\treturn cand[:limit]\n\t\t\t}\n\t\t\treturn cand\n\t\t}\n\t}\n\treturn name[:limit]\n}\n\n// buildShortNameMap ensures uniqueness of shortened names within a request.\nfunc buildShortNameMap(names []string) map[string]string {\n\tconst limit = 64\n\tused := map[string]struct{}{}\n\tm := map[string]string{}\n\n\tbaseCandidate := func(n string) string {\n\t\tif len(n) <= limit {\n\t\t\treturn n\n\t\t}\n\t\tif strings.HasPrefix(n, \"mcp__\") {\n\t\t\tidx := strings.LastIndex(n, \"__\")\n\t\t\tif idx > 0 {\n\t\t\t\tcand := \"mcp__\" + n[idx+2:]\n\t\t\t\tif len(cand) > limit {\n\t\t\t\t\tcand = cand[:limit]\n\t\t\t\t}\n\t\t\t\treturn cand\n\t\t\t}\n\t\t}\n\t\treturn n[:limit]\n\t}\n\n\tmakeUnique := func(cand string) string {\n\t\tif _, ok := used[cand]; !ok {\n\t\t\treturn cand\n\t\t}\n\t\tbase := cand\n\t\tfor i := 1; ; i++ {\n\t\t\tsuffix := \"_\" + strconv.Itoa(i)\n\t\t\tallowed := limit - len(suffix)\n\t\t\tif allowed < 0 {\n\t\t\t\tallowed = 0\n\t\t\t}\n\t\t\ttmp := base\n\t\t\tif len(tmp) > allowed {\n\t\t\t\ttmp = tmp[:allowed]\n\t\t\t}\n\t\t\ttmp = tmp + suffix\n\t\t\tif _, ok := used[tmp]; !ok {\n\t\t\t\treturn tmp\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, n := range names {\n\t\tcand := baseCandidate(n)\n\t\tuniq := makeUnique(cand)\n\t\tused[uniq] = struct{}{}\n\t\tm[n] = uniq\n\t}\n\treturn m\n}\n\n// buildReverseMapFromClaudeOriginalToShort builds original->short map, used to map tool_use names to short.\nfunc buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string {\n\ttools := gjson.GetBytes(original, \"tools\")\n\tm := map[string]string{}\n\tif !tools.IsArray() {\n\t\treturn m\n\t}\n\tvar names []string\n\tarr := tools.Array()\n\tfor i := 0; i < len(arr); i++ {\n\t\tn := arr[i].Get(\"name\").String()\n\t\tif n != \"\" {\n\t\t\tnames = append(names, n)\n\t\t}\n\t}\n\tif len(names) > 0 {\n\t\tm = buildShortNameMap(names)\n\t}\n\treturn m\n}\n\n// normalizeToolParameters ensures object schemas contain at least an empty properties map.\nfunc normalizeToolParameters(raw string) string {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" || raw == \"null\" || !gjson.Valid(raw) {\n\t\treturn `{\"type\":\"object\",\"properties\":{}}`\n\t}\n\tschema := raw\n\tresult := gjson.Parse(raw)\n\tschemaType := result.Get(\"type\").String()\n\tif schemaType == \"\" {\n\t\tschema, _ = sjson.Set(schema, \"type\", \"object\")\n\t\tschemaType = \"object\"\n\t}\n\tif schemaType == \"object\" && !result.Get(\"properties\").Exists() {\n\t\tschema, _ = sjson.SetRaw(schema, \"properties\", `{}`)\n\t}\n\treturn schema\n}\n"
  },
  {
    "path": "internal/translator/codex/claude/codex_claude_request_test.go",
    "content": "package claude\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tinputJSON        string\n\t\twantHasDeveloper bool\n\t\twantTexts        []string\n\t}{\n\t\t{\n\t\t\tname: \"No system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasDeveloper: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Empty string system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": \"\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasDeveloper: false,\n\t\t},\n\t\t{\n\t\t\tname: \"String system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": \"Be helpful\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasDeveloper: true,\n\t\t\twantTexts:        []string{\"Be helpful\"},\n\t\t},\n\t\t{\n\t\t\tname: \"Array system field with filtered billing header\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"x-anthropic-billing-header: tenant-123\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Block 1\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Block 2\"}\n\t\t\t\t],\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasDeveloper: true,\n\t\t\twantTexts:        []string{\"Block 1\", \"Block 2\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := ConvertClaudeRequestToCodex(\"test-model\", []byte(tt.inputJSON), false)\n\t\t\tresultJSON := gjson.ParseBytes(result)\n\t\t\tinputs := resultJSON.Get(\"input\").Array()\n\n\t\t\thasDeveloper := len(inputs) > 0 && inputs[0].Get(\"role\").String() == \"developer\"\n\t\t\tif hasDeveloper != tt.wantHasDeveloper {\n\t\t\t\tt.Fatalf(\"got hasDeveloper = %v, want %v. Output: %s\", hasDeveloper, tt.wantHasDeveloper, resultJSON.Get(\"input\").Raw)\n\t\t\t}\n\n\t\t\tif !tt.wantHasDeveloper {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcontent := inputs[0].Get(\"content\").Array()\n\t\t\tif len(content) != len(tt.wantTexts) {\n\t\t\t\tt.Fatalf(\"got %d system content items, want %d. Content: %s\", len(content), len(tt.wantTexts), inputs[0].Get(\"content\").Raw)\n\t\t\t}\n\n\t\t\tfor i, wantText := range tt.wantTexts {\n\t\t\t\tif gotType := content[i].Get(\"type\").String(); gotType != \"input_text\" {\n\t\t\t\t\tt.Fatalf(\"content[%d] type = %q, want %q\", i, gotType, \"input_text\")\n\t\t\t\t}\n\t\t\t\tif gotText := content[i].Get(\"text\").String(); gotText != wantText {\n\t\t\t\t\tt.Fatalf(\"content[%d] text = %q, want %q\", i, gotText, wantText)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/translator/codex/claude/codex_claude_response.go",
    "content": "// Package claude provides response translation functionality for Codex to Claude Code API compatibility.\n// This package handles the conversion of Codex API responses into Claude Code-compatible\n// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages\n// different response types including text content, thinking processes, and function calls.\n// The translation ensures proper sequencing of SSE events and maintains state across\n// multiple response chunks to provide a seamless streaming experience.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertCodexResponseToClaudeParams holds parameters for response conversion.\ntype ConvertCodexResponseToClaudeParams struct {\n\tHasToolCall               bool\n\tBlockIndex                int\n\tHasReceivedArgumentsDelta bool\n}\n\n// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.\n// This function implements a complex state machine that translates Codex API responses\n// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types\n// and handles state transitions between content blocks, thinking processes, and function calls.\n//\n// Response type states: 0=none, 1=content, 2=thinking, 3=function\n// The function maintains state across multiple calls to ensure proper SSE event sequencing.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Claude Code-compatible JSON response\nfunc ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertCodexResponseToClaudeParams{\n\t\t\tHasToolCall: false,\n\t\t\tBlockIndex:  0,\n\t\t}\n\t}\n\n\t// log.Debugf(\"rawJSON: %s\", string(rawJSON))\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\toutput := \"\"\n\trootResult := gjson.ParseBytes(rawJSON)\n\ttypeResult := rootResult.Get(\"type\")\n\ttypeStr := typeResult.String()\n\ttemplate := \"\"\n\tif typeStr == \"response.created\" {\n\t\ttemplate = `{\"type\":\"message_start\",\"message\":{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-4-1-20250805\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0},\"content\":[],\"stop_reason\":null}}`\n\t\ttemplate, _ = sjson.Set(template, \"message.model\", rootResult.Get(\"response.model\").String())\n\t\ttemplate, _ = sjson.Set(template, \"message.id\", rootResult.Get(\"response.id\").String())\n\n\t\toutput = \"event: message_start\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.reasoning_summary_part.added\" {\n\t\ttemplate = `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\n\t\toutput = \"event: content_block_start\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.reasoning_summary_text.delta\" {\n\t\ttemplate = `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\ttemplate, _ = sjson.Set(template, \"delta.thinking\", rootResult.Get(\"delta\").String())\n\n\t\toutput = \"event: content_block_delta\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.reasoning_summary_part.done\" {\n\t\ttemplate = `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\t(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++\n\n\t\toutput = \"event: content_block_stop\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\n\t} else if typeStr == \"response.content_part.added\" {\n\t\ttemplate = `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\n\t\toutput = \"event: content_block_start\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.output_text.delta\" {\n\t\ttemplate = `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\ttemplate, _ = sjson.Set(template, \"delta.text\", rootResult.Get(\"delta\").String())\n\n\t\toutput = \"event: content_block_delta\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.content_part.done\" {\n\t\ttemplate = `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\t(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++\n\n\t\toutput = \"event: content_block_stop\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.completed\" {\n\t\ttemplate = `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\tp := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall\n\t\tstopReason := rootResult.Get(\"response.stop_reason\").String()\n\t\tif p {\n\t\t\ttemplate, _ = sjson.Set(template, \"delta.stop_reason\", \"tool_use\")\n\t\t} else if stopReason == \"max_tokens\" || stopReason == \"stop\" {\n\t\t\ttemplate, _ = sjson.Set(template, \"delta.stop_reason\", stopReason)\n\t\t} else {\n\t\t\ttemplate, _ = sjson.Set(template, \"delta.stop_reason\", \"end_turn\")\n\t\t}\n\t\tinputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get(\"response.usage\"))\n\t\ttemplate, _ = sjson.Set(template, \"usage.input_tokens\", inputTokens)\n\t\ttemplate, _ = sjson.Set(template, \"usage.output_tokens\", outputTokens)\n\t\tif cachedTokens > 0 {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\t}\n\n\t\toutput = \"event: message_delta\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t\toutput += \"event: message_stop\\n\"\n\t\toutput += `data: {\"type\":\"message_stop\"}`\n\t\toutput += \"\\n\\n\"\n\t} else if typeStr == \"response.output_item.added\" {\n\t\titemResult := rootResult.Get(\"item\")\n\t\titemType := itemResult.Get(\"type\").String()\n\t\tif itemType == \"function_call\" {\n\t\t\t(*param).(*ConvertCodexResponseToClaudeParams).HasToolCall = true\n\t\t\t(*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta = false\n\t\t\ttemplate = `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}}`\n\t\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\t\ttemplate, _ = sjson.Set(template, \"content_block.id\", util.SanitizeClaudeToolID(itemResult.Get(\"call_id\").String()))\n\t\t\t{\n\t\t\t\t// Restore original tool name if shortened\n\t\t\t\tname := itemResult.Get(\"name\").String()\n\t\t\t\trev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)\n\t\t\t\tif orig, ok := rev[name]; ok {\n\t\t\t\t\tname = orig\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"content_block.name\", name)\n\t\t\t}\n\n\t\t\toutput = \"event: content_block_start\\n\"\n\t\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\n\t\t\ttemplate = `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`\n\t\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\n\t\t\toutput += \"event: content_block_delta\\n\"\n\t\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t\t}\n\t} else if typeStr == \"response.output_item.done\" {\n\t\titemResult := rootResult.Get(\"item\")\n\t\titemType := itemResult.Get(\"type\").String()\n\t\tif itemType == \"function_call\" {\n\t\t\ttemplate = `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\t\t(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++\n\n\t\t\toutput = \"event: content_block_stop\\n\"\n\t\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t\t}\n\t} else if typeStr == \"response.function_call_arguments.delta\" {\n\t\t(*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta = true\n\t\ttemplate = `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`\n\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\ttemplate, _ = sjson.Set(template, \"delta.partial_json\", rootResult.Get(\"delta\").String())\n\n\t\toutput += \"event: content_block_delta\\n\"\n\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t} else if typeStr == \"response.function_call_arguments.done\" {\n\t\t// Some models (e.g. gpt-5.3-codex-spark) send function call arguments\n\t\t// in a single \"done\" event without preceding \"delta\" events.\n\t\t// Emit the full arguments as a single input_json_delta so the\n\t\t// downstream Claude client receives the complete tool input.\n\t\t// When delta events were already received, skip to avoid duplicating arguments.\n\t\tif !(*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta {\n\t\t\tif args := rootResult.Get(\"arguments\").String(); args != \"\" {\n\t\t\t\ttemplate = `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`\n\t\t\t\ttemplate, _ = sjson.Set(template, \"index\", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"delta.partial_json\", args)\n\n\t\t\t\toutput += \"event: content_block_delta\\n\"\n\t\t\t\toutput += fmt.Sprintf(\"data: %s\\n\\n\", template)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn []string{output}\n}\n\n// ConvertCodexResponseToClaudeNonStream converts a non-streaming Codex response to a non-streaming Claude Code response.\n// This function processes the complete Codex response and transforms it into a single Claude Code-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the Claude Code API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: A Claude Code-compatible JSON response containing all message content and metadata\nfunc ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) string {\n\trevNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)\n\n\trootResult := gjson.ParseBytes(rawJSON)\n\tif rootResult.Get(\"type\").String() != \"response.completed\" {\n\t\treturn \"\"\n\t}\n\n\tresponseData := rootResult.Get(\"response\")\n\tif !responseData.Exists() {\n\t\treturn \"\"\n\t}\n\n\tout := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tout, _ = sjson.Set(out, \"id\", responseData.Get(\"id\").String())\n\tout, _ = sjson.Set(out, \"model\", responseData.Get(\"model\").String())\n\tinputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get(\"usage\"))\n\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\tif cachedTokens > 0 {\n\t\tout, _ = sjson.Set(out, \"usage.cache_read_input_tokens\", cachedTokens)\n\t}\n\n\thasToolCall := false\n\n\tif output := responseData.Get(\"output\"); output.Exists() && output.IsArray() {\n\t\toutput.ForEach(func(_, item gjson.Result) bool {\n\t\t\tswitch item.Get(\"type\").String() {\n\t\t\tcase \"reasoning\":\n\t\t\t\tthinkingBuilder := strings.Builder{}\n\t\t\t\tif summary := item.Get(\"summary\"); summary.Exists() {\n\t\t\t\t\tif summary.IsArray() {\n\t\t\t\t\t\tsummary.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\t\tif txt := part.Get(\"text\"); txt.Exists() {\n\t\t\t\t\t\t\t\tthinkingBuilder.WriteString(txt.String())\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tthinkingBuilder.WriteString(part.String())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthinkingBuilder.WriteString(summary.String())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif thinkingBuilder.Len() == 0 {\n\t\t\t\t\tif content := item.Get(\"content\"); content.Exists() {\n\t\t\t\t\t\tif content.IsArray() {\n\t\t\t\t\t\t\tcontent.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\t\t\tif txt := part.Get(\"text\"); txt.Exists() {\n\t\t\t\t\t\t\t\t\tthinkingBuilder.WriteString(txt.String())\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tthinkingBuilder.WriteString(part.String())\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthinkingBuilder.WriteString(content.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif thinkingBuilder.Len() > 0 {\n\t\t\t\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\t\t\t\tblock, _ = sjson.Set(block, \"thinking\", thinkingBuilder.String())\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t}\n\t\t\tcase \"message\":\n\t\t\t\tif content := item.Get(\"content\"); content.Exists() {\n\t\t\t\t\tif content.IsArray() {\n\t\t\t\t\t\tcontent.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t\t\tif part.Get(\"type\").String() == \"output_text\" {\n\t\t\t\t\t\t\t\ttext := part.Get(\"text\").String()\n\t\t\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\t\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\t\t\t\tblock, _ = sjson.Set(block, \"text\", text)\n\t\t\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttext := content.String()\n\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\t\tblock, _ = sjson.Set(block, \"text\", text)\n\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"function_call\":\n\t\t\t\thasToolCall = true\n\t\t\t\tname := item.Get(\"name\").String()\n\t\t\t\tif original, ok := revNames[name]; ok {\n\t\t\t\t\tname = original\n\t\t\t\t}\n\n\t\t\t\ttoolBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"id\", util.SanitizeClaudeToolID(item.Get(\"call_id\").String()))\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"name\", name)\n\t\t\t\tinputRaw := \"{}\"\n\t\t\t\tif argsStr := item.Get(\"arguments\").String(); argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\tinputRaw = argsJSON.Raw\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttoolBlock, _ = sjson.SetRaw(toolBlock, \"input\", inputRaw)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolBlock)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\tif stopReason := responseData.Get(\"stop_reason\"); stopReason.Exists() && stopReason.String() != \"\" {\n\t\tout, _ = sjson.Set(out, \"stop_reason\", stopReason.String())\n\t} else if hasToolCall {\n\t\tout, _ = sjson.Set(out, \"stop_reason\", \"tool_use\")\n\t} else {\n\t\tout, _ = sjson.Set(out, \"stop_reason\", \"end_turn\")\n\t}\n\n\tif stopSequence := responseData.Get(\"stop_sequence\"); stopSequence.Exists() && stopSequence.String() != \"\" {\n\t\tout, _ = sjson.SetRaw(out, \"stop_sequence\", stopSequence.Raw)\n\t}\n\n\treturn out\n}\n\nfunc extractResponsesUsage(usage gjson.Result) (int64, int64, int64) {\n\tif !usage.Exists() || usage.Type == gjson.Null {\n\t\treturn 0, 0, 0\n\t}\n\n\tinputTokens := usage.Get(\"input_tokens\").Int()\n\toutputTokens := usage.Get(\"output_tokens\").Int()\n\tcachedTokens := usage.Get(\"input_tokens_details.cached_tokens\").Int()\n\n\tif cachedTokens > 0 {\n\t\tif inputTokens >= cachedTokens {\n\t\t\tinputTokens -= cachedTokens\n\t\t} else {\n\t\t\tinputTokens = 0\n\t\t}\n\t}\n\n\treturn inputTokens, outputTokens, cachedTokens\n}\n\n// buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools.\nfunc buildReverseMapFromClaudeOriginalShortToOriginal(original []byte) map[string]string {\n\ttools := gjson.GetBytes(original, \"tools\")\n\trev := map[string]string{}\n\tif !tools.IsArray() {\n\t\treturn rev\n\t}\n\tvar names []string\n\tarr := tools.Array()\n\tfor i := 0; i < len(arr); i++ {\n\t\tn := arr[i].Get(\"name\").String()\n\t\tif n != \"\" {\n\t\t\tnames = append(names, n)\n\t\t}\n\t}\n\tif len(names) > 0 {\n\t\tm := buildShortNameMap(names)\n\t\tfor orig, short := range m {\n\t\t\trev[short] = orig\n\t\t}\n\t}\n\treturn rev\n}\n\nfunc ClaudeTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"input_tokens\":%d}`, count)\n}\n"
  },
  {
    "path": "internal/translator/codex/claude/init.go",
    "content": "package claude\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tClaude,\n\t\tCodex,\n\t\tConvertClaudeRequestToCodex,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertCodexResponseToClaude,\n\t\t\tNonStream:  ConvertCodexResponseToClaudeNonStream,\n\t\t\tTokenCount: ClaudeTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini/codex_gemini_request.go",
    "content": "// Package gemini provides request translation functionality for Codex to Gemini API compatibility.\n// It handles parsing and transforming Codex API requests into Gemini API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Codex API format and Gemini API's expected format.\npackage gemini\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiRequestToCodex parses and transforms a Gemini API request into Codex API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Codex API.\n// The function performs comprehensive transformation including:\n// 1. Model name mapping and generation configuration extraction\n// 2. System instruction conversion to Codex format\n// 3. Message content conversion with proper role mapping\n// 4. Tool call and tool result handling with FIFO queue for ID matching\n// 5. Tool declaration and tool choice configuration mapping\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Gemini API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Codex API format\nfunc ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base template\n\tout := `{\"model\":\"\",\"instructions\":\"\",\"input\":[]}`\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Pre-compute tool name shortening map from declared functionDeclarations\n\tshortMap := map[string]string{}\n\tif tools := root.Get(\"tools\"); tools.IsArray() {\n\t\tvar names []string\n\t\ttarr := tools.Array()\n\t\tfor i := 0; i < len(tarr); i++ {\n\t\t\tfns := tarr[i].Get(\"functionDeclarations\")\n\t\t\tif !fns.IsArray() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, fn := range fns.Array() {\n\t\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\t\tnames = append(names, v.String())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif len(names) > 0 {\n\t\t\tshortMap = buildShortNameMap(names)\n\t\t}\n\t}\n\n\t// helper for generating paired call IDs in the form: call_<alphanum>\n\t// Gemini uses sequential pairing across possibly multiple in-flight\n\t// functionCalls, so we keep a FIFO queue of generated call IDs and\n\t// consume them in order when functionResponses arrive.\n\tvar pendingCallIDs []string\n\n\t// genCallID creates a random call id like: call_<8chars>\n\tgenCallID := func() string {\n\t\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\tvar b strings.Builder\n\t\t// 8 chars random suffix\n\t\tfor i := 0; i < 24; i++ {\n\t\t\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\t\tb.WriteByte(letters[n.Int64()])\n\t\t}\n\t\treturn \"call_\" + b.String()\n\t}\n\n\t// Model\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// System instruction -> as a user message with input_text parts\n\tsysParts := root.Get(\"system_instruction.parts\")\n\tif sysParts.IsArray() {\n\t\tmsg := `{\"type\":\"message\",\"role\":\"developer\",\"content\":[]}`\n\t\tarr := sysParts.Array()\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tp := arr[i]\n\t\t\tif t := p.Get(\"text\"); t.Exists() {\n\t\t\t\tpart := `{}`\n\t\t\t\tpart, _ = sjson.Set(part, \"type\", \"input_text\")\n\t\t\t\tpart, _ = sjson.Set(part, \"text\", t.String())\n\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t}\n\t\t}\n\t\tif len(gjson.Get(msg, \"content\").Array()) > 0 {\n\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", msg)\n\t\t}\n\t}\n\n\t// Contents -> messages and function calls/results\n\tcontents := root.Get(\"contents\")\n\tif contents.IsArray() {\n\t\titems := contents.Array()\n\t\tfor i := 0; i < len(items); i++ {\n\t\t\titem := items[i]\n\t\t\trole := item.Get(\"role\").String()\n\t\t\tif role == \"model\" {\n\t\t\t\trole = \"assistant\"\n\t\t\t}\n\n\t\t\tparts := item.Get(\"parts\")\n\t\t\tif !parts.IsArray() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparr := parts.Array()\n\t\t\tfor j := 0; j < len(parr); j++ {\n\t\t\t\tp := parr[j]\n\t\t\t\t// text part\n\t\t\t\tif t := p.Get(\"text\"); t.Exists() {\n\t\t\t\t\tmsg := `{\"type\":\"message\",\"role\":\"\",\"content\":[]}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\t\t\t\t\tpartType := \"input_text\"\n\t\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\t\tpartType = \"output_text\"\n\t\t\t\t\t}\n\t\t\t\t\tpart := `{}`\n\t\t\t\t\tpart, _ = sjson.Set(part, \"type\", partType)\n\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", t.String())\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", msg)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// function call from model\n\t\t\t\tif fc := p.Get(\"functionCall\"); fc.Exists() {\n\t\t\t\t\tfn := `{\"type\":\"function_call\"}`\n\t\t\t\t\tif name := fc.Get(\"name\"); name.Exists() {\n\t\t\t\t\t\tn := name.String()\n\t\t\t\t\t\tif short, ok := shortMap[n]; ok {\n\t\t\t\t\t\t\tn = short\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tn = shortenNameIfNeeded(n)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfn, _ = sjson.Set(fn, \"name\", n)\n\t\t\t\t\t}\n\t\t\t\t\tif args := fc.Get(\"args\"); args.Exists() {\n\t\t\t\t\t\tfn, _ = sjson.Set(fn, \"arguments\", args.Raw)\n\t\t\t\t\t}\n\t\t\t\t\t// generate a paired random call_id and enqueue it so the\n\t\t\t\t\t// corresponding functionResponse can pop the earliest id\n\t\t\t\t\t// to preserve ordering when multiple calls are present.\n\t\t\t\t\tid := genCallID()\n\t\t\t\t\tfn, _ = sjson.Set(fn, \"call_id\", id)\n\t\t\t\t\tpendingCallIDs = append(pendingCallIDs, id)\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", fn)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// function response from user\n\t\t\t\tif fr := p.Get(\"functionResponse\"); fr.Exists() {\n\t\t\t\t\tfno := `{\"type\":\"function_call_output\"}`\n\t\t\t\t\t// Prefer a string result if present; otherwise embed the raw response as a string\n\t\t\t\t\tif res := fr.Get(\"response.result\"); res.Exists() {\n\t\t\t\t\t\tfno, _ = sjson.Set(fno, \"output\", res.String())\n\t\t\t\t\t} else if resp := fr.Get(\"response\"); resp.Exists() {\n\t\t\t\t\t\tfno, _ = sjson.Set(fno, \"output\", resp.Raw)\n\t\t\t\t\t}\n\t\t\t\t\t// fno, _ = sjson.Set(fno, \"call_id\", \"call_W6nRJzFXyPM2LFBbfo98qAbq\")\n\t\t\t\t\t// attach the oldest queued call_id to pair the response\n\t\t\t\t\t// with its call. If the queue is empty, generate a new id.\n\t\t\t\t\tvar id string\n\t\t\t\t\tif len(pendingCallIDs) > 0 {\n\t\t\t\t\t\tid = pendingCallIDs[0]\n\t\t\t\t\t\t// pop the first element\n\t\t\t\t\t\tpendingCallIDs = pendingCallIDs[1:]\n\t\t\t\t\t} else {\n\t\t\t\t\t\tid = genCallID()\n\t\t\t\t\t}\n\t\t\t\t\tfno, _ = sjson.Set(fno, \"call_id\", id)\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", fno)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Tools mapping: Gemini functionDeclarations -> Codex tools\n\ttools := root.Get(\"tools\")\n\tif tools.IsArray() {\n\t\tout, _ = sjson.SetRaw(out, \"tools\", `[]`)\n\t\tout, _ = sjson.Set(out, \"tool_choice\", \"auto\")\n\t\ttarr := tools.Array()\n\t\tfor i := 0; i < len(tarr); i++ {\n\t\t\ttd := tarr[i]\n\t\t\tfns := td.Get(\"functionDeclarations\")\n\t\t\tif !fns.IsArray() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfarr := fns.Array()\n\t\t\tfor j := 0; j < len(farr); j++ {\n\t\t\t\tfn := farr[j]\n\t\t\t\ttool := `{}`\n\t\t\t\ttool, _ = sjson.Set(tool, \"type\", \"function\")\n\t\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\t\tname := v.String()\n\t\t\t\t\tif short, ok := shortMap[name]; ok {\n\t\t\t\t\t\tname = short\n\t\t\t\t\t} else {\n\t\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t\t}\n\t\t\t\t\ttool, _ = sjson.Set(tool, \"name\", name)\n\t\t\t\t}\n\t\t\t\tif v := fn.Get(\"description\"); v.Exists() {\n\t\t\t\t\ttool, _ = sjson.Set(tool, \"description\", v.String())\n\t\t\t\t}\n\t\t\t\tif prm := fn.Get(\"parameters\"); prm.Exists() {\n\t\t\t\t\t// Remove optional $schema field if present\n\t\t\t\t\tcleaned := prm.Raw\n\t\t\t\t\tcleaned, _ = sjson.Delete(cleaned, \"$schema\")\n\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"additionalProperties\", false)\n\t\t\t\t\ttool, _ = sjson.SetRaw(tool, \"parameters\", cleaned)\n\t\t\t\t} else if prm = fn.Get(\"parametersJsonSchema\"); prm.Exists() {\n\t\t\t\t\t// Remove optional $schema field if present\n\t\t\t\t\tcleaned := prm.Raw\n\t\t\t\t\tcleaned, _ = sjson.Delete(cleaned, \"$schema\")\n\t\t\t\t\tcleaned, _ = sjson.Set(cleaned, \"additionalProperties\", false)\n\t\t\t\t\ttool, _ = sjson.SetRaw(tool, \"parameters\", cleaned)\n\t\t\t\t}\n\t\t\t\ttool, _ = sjson.Set(tool, \"strict\", false)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.-1\", tool)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fixed flags aligning with Codex expectations\n\tout, _ = sjson.Set(out, \"parallel_tool_calls\", true)\n\n\t// Convert Gemini thinkingConfig to Codex reasoning.effort.\n\t// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).\n\teffortSet := false\n\tif genConfig := root.Get(\"generationConfig\"); genConfig.Exists() {\n\t\tif thinkingConfig := genConfig.Get(\"thinkingConfig\"); thinkingConfig.Exists() && thinkingConfig.IsObject() {\n\t\t\tthinkingLevel := thinkingConfig.Get(\"thinkingLevel\")\n\t\t\tif !thinkingLevel.Exists() {\n\t\t\t\tthinkingLevel = thinkingConfig.Get(\"thinking_level\")\n\t\t\t}\n\t\t\tif thinkingLevel.Exists() {\n\t\t\t\teffort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))\n\t\t\t\tif effort != \"\" {\n\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning.effort\", effort)\n\t\t\t\t\teffortSet = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthinkingBudget := thinkingConfig.Get(\"thinkingBudget\")\n\t\t\t\tif !thinkingBudget.Exists() {\n\t\t\t\t\tthinkingBudget = thinkingConfig.Get(\"thinking_budget\")\n\t\t\t\t}\n\t\t\t\tif thinkingBudget.Exists() {\n\t\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning.effort\", effort)\n\t\t\t\t\t\teffortSet = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif !effortSet {\n\t\t// No thinking config, set default effort\n\t\tout, _ = sjson.Set(out, \"reasoning.effort\", \"medium\")\n\t}\n\tout, _ = sjson.Set(out, \"reasoning.summary\", \"auto\")\n\tout, _ = sjson.Set(out, \"stream\", true)\n\tout, _ = sjson.Set(out, \"store\", false)\n\tout, _ = sjson.Set(out, \"include\", []string{\"reasoning.encrypted_content\"})\n\n\tvar pathsToLower []string\n\ttoolsResult := gjson.Get(out, \"tools\")\n\tutil.Walk(toolsResult, \"\", \"type\", &pathsToLower)\n\tfor _, p := range pathsToLower {\n\t\tfullPath := fmt.Sprintf(\"tools.%s\", p)\n\t\tout, _ = sjson.Set(out, fullPath, strings.ToLower(gjson.Get(out, fullPath).String()))\n\t}\n\n\treturn []byte(out)\n}\n\n// shortenNameIfNeeded applies the simple shortening rule for a single name.\nfunc shortenNameIfNeeded(name string) string {\n\tconst limit = 64\n\tif len(name) <= limit {\n\t\treturn name\n\t}\n\tif strings.HasPrefix(name, \"mcp__\") {\n\t\tidx := strings.LastIndex(name, \"__\")\n\t\tif idx > 0 {\n\t\t\tcand := \"mcp__\" + name[idx+2:]\n\t\t\tif len(cand) > limit {\n\t\t\t\treturn cand[:limit]\n\t\t\t}\n\t\t\treturn cand\n\t\t}\n\t}\n\treturn name[:limit]\n}\n\n// buildShortNameMap ensures uniqueness of shortened names within a request.\nfunc buildShortNameMap(names []string) map[string]string {\n\tconst limit = 64\n\tused := map[string]struct{}{}\n\tm := map[string]string{}\n\n\tbaseCandidate := func(n string) string {\n\t\tif len(n) <= limit {\n\t\t\treturn n\n\t\t}\n\t\tif strings.HasPrefix(n, \"mcp__\") {\n\t\t\tidx := strings.LastIndex(n, \"__\")\n\t\t\tif idx > 0 {\n\t\t\t\tcand := \"mcp__\" + n[idx+2:]\n\t\t\t\tif len(cand) > limit {\n\t\t\t\t\tcand = cand[:limit]\n\t\t\t\t}\n\t\t\t\treturn cand\n\t\t\t}\n\t\t}\n\t\treturn n[:limit]\n\t}\n\n\tmakeUnique := func(cand string) string {\n\t\tif _, ok := used[cand]; !ok {\n\t\t\treturn cand\n\t\t}\n\t\tbase := cand\n\t\tfor i := 1; ; i++ {\n\t\t\tsuffix := \"_\" + strconv.Itoa(i)\n\t\t\tallowed := limit - len(suffix)\n\t\t\tif allowed < 0 {\n\t\t\t\tallowed = 0\n\t\t\t}\n\t\t\ttmp := base\n\t\t\tif len(tmp) > allowed {\n\t\t\t\ttmp = tmp[:allowed]\n\t\t\t}\n\t\t\ttmp = tmp + suffix\n\t\t\tif _, ok := used[tmp]; !ok {\n\t\t\t\treturn tmp\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, n := range names {\n\t\tcand := baseCandidate(n)\n\t\tuniq := makeUnique(cand)\n\t\tused[uniq] = struct{}{}\n\t\tm[n] = uniq\n\t}\n\treturn m\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini/codex_gemini_response.go",
    "content": "// Package gemini provides response translation functionality for Codex to Gemini API compatibility.\n// This package handles the conversion of Codex API responses into Gemini-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini API clients.\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertCodexResponseToGeminiParams holds parameters for response conversion.\ntype ConvertCodexResponseToGeminiParams struct {\n\tModel             string\n\tCreatedAt         int64\n\tResponseID        string\n\tLastStorageOutput string\n}\n\n// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.\n// This function processes various Codex event types and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.\n// The function maintains state across multiple calls to ensure proper response sequencing.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response\nfunc ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertCodexResponseToGeminiParams{\n\t\t\tModel:             modelName,\n\t\t\tCreatedAt:         0,\n\t\t\tResponseID:        \"\",\n\t\t\tLastStorageOutput: \"\",\n\t\t}\n\t}\n\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\trootResult := gjson.ParseBytes(rawJSON)\n\ttypeResult := rootResult.Get(\"type\")\n\ttypeStr := typeResult.String()\n\n\t// Base Gemini response template\n\ttemplate := `{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[]}}],\"usageMetadata\":{\"trafficType\":\"PROVISIONED_THROUGHPUT\"},\"modelVersion\":\"gemini-2.5-pro\",\"createTime\":\"2025-08-15T02:52:03.884209Z\",\"responseId\":\"06CeaPH7NaCU48APvNXDyA4\"}`\n\tif (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput != \"\" && typeStr == \"response.output_item.done\" {\n\t\ttemplate = (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"modelVersion\", (*param).(*ConvertCodexResponseToGeminiParams).Model)\n\t\tcreatedAtResult := rootResult.Get(\"response.created_at\")\n\t\tif createdAtResult.Exists() {\n\t\t\t(*param).(*ConvertCodexResponseToGeminiParams).CreatedAt = createdAtResult.Int()\n\t\t\ttemplate, _ = sjson.Set(template, \"createTime\", time.Unix((*param).(*ConvertCodexResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"responseId\", (*param).(*ConvertCodexResponseToGeminiParams).ResponseID)\n\t}\n\n\t// Handle function call completion\n\tif typeStr == \"response.output_item.done\" {\n\t\titemResult := rootResult.Get(\"item\")\n\t\titemType := itemResult.Get(\"type\").String()\n\t\tif itemType == \"function_call\" {\n\t\t\t// Create function call part\n\t\t\tfunctionCall := `{\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\t{\n\t\t\t\t// Restore original tool name if shortened\n\t\t\t\tn := itemResult.Get(\"name\").String()\n\t\t\t\trev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)\n\t\t\t\tif orig, ok := rev[n]; ok {\n\t\t\t\t\tn = orig\n\t\t\t\t}\n\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"functionCall.name\", n)\n\t\t\t}\n\n\t\t\t// Parse and set arguments\n\t\t\targsStr := itemResult.Get(\"arguments\").String()\n\t\t\tif argsStr != \"\" {\n\t\t\t\targsResult := gjson.Parse(argsStr)\n\t\t\t\tif argsResult.IsObject() {\n\t\t\t\t\tfunctionCall, _ = sjson.SetRaw(functionCall, \"functionCall.args\", argsStr)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", functionCall)\n\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\n\t\t\t(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput = template\n\n\t\t\t// Use this return to storage message\n\t\t\treturn []string{}\n\t\t}\n\t}\n\n\tif typeStr == \"response.created\" { // Handle response creation - set model and response ID\n\t\ttemplate, _ = sjson.Set(template, \"modelVersion\", rootResult.Get(\"response.model\").String())\n\t\ttemplate, _ = sjson.Set(template, \"responseId\", rootResult.Get(\"response.id\").String())\n\t\t(*param).(*ConvertCodexResponseToGeminiParams).ResponseID = rootResult.Get(\"response.id\").String()\n\t} else if typeStr == \"response.reasoning_summary_text.delta\" { // Handle reasoning/thinking content delta\n\t\tpart := `{\"thought\":true,\"text\":\"\"}`\n\t\tpart, _ = sjson.Set(part, \"text\", rootResult.Get(\"delta\").String())\n\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", part)\n\t} else if typeStr == \"response.output_text.delta\" { // Handle regular text content delta\n\t\tpart := `{\"text\":\"\"}`\n\t\tpart, _ = sjson.Set(part, \"text\", rootResult.Get(\"delta\").String())\n\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", part)\n\t} else if typeStr == \"response.completed\" { // Handle response completion with usage metadata\n\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.promptTokenCount\", rootResult.Get(\"response.usage.input_tokens\").Int())\n\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.candidatesTokenCount\", rootResult.Get(\"response.usage.output_tokens\").Int())\n\t\ttotalTokens := rootResult.Get(\"response.usage.input_tokens\").Int() + rootResult.Get(\"response.usage.output_tokens\").Int()\n\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.totalTokenCount\", totalTokens)\n\t} else {\n\t\treturn []string{}\n\t}\n\n\tif (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput != \"\" {\n\t\treturn []string{(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput, template}\n\t} else {\n\t\treturn []string{template}\n\t}\n\n}\n\n// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.\n// This function processes the complete Codex response and transforms it into a single Gemini-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the Gemini API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response containing all message content and metadata\nfunc ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\trootResult := gjson.ParseBytes(rawJSON)\n\n\t// Verify this is a response.completed event\n\tif rootResult.Get(\"type\").String() != \"response.completed\" {\n\t\treturn \"\"\n\t}\n\n\t// Base Gemini response template for non-streaming\n\ttemplate := `{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"trafficType\":\"PROVISIONED_THROUGHPUT\"},\"modelVersion\":\"\",\"createTime\":\"\",\"responseId\":\"\"}`\n\n\t// Set model version\n\ttemplate, _ = sjson.Set(template, \"modelVersion\", modelName)\n\n\t// Set response metadata from the completed response\n\tresponseData := rootResult.Get(\"response\")\n\tif responseData.Exists() {\n\t\t// Set response ID\n\t\tif responseId := responseData.Get(\"id\"); responseId.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"responseId\", responseId.String())\n\t\t}\n\n\t\t// Set creation time\n\t\tif createdAt := responseData.Get(\"created_at\"); createdAt.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"createTime\", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))\n\t\t}\n\n\t\t// Set usage metadata\n\t\tif usage := responseData.Get(\"usage\"); usage.Exists() {\n\t\t\tinputTokens := usage.Get(\"input_tokens\").Int()\n\t\t\toutputTokens := usage.Get(\"output_tokens\").Int()\n\t\t\ttotalTokens := inputTokens + outputTokens\n\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.promptTokenCount\", inputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.candidatesTokenCount\", outputTokens)\n\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.totalTokenCount\", totalTokens)\n\t\t}\n\n\t\t// Process output content to build parts array\n\t\thasToolCall := false\n\t\tvar pendingFunctionCalls []string\n\n\t\tflushPendingFunctionCalls := func() {\n\t\t\tif len(pendingFunctionCalls) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Add all pending function calls as individual parts\n\t\t\t// This maintains the original Gemini API format while ensuring consecutive calls are grouped together\n\t\t\tfor _, fc := range pendingFunctionCalls {\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", fc)\n\t\t\t}\n\t\t\tpendingFunctionCalls = nil\n\t\t}\n\n\t\tif output := responseData.Get(\"output\"); output.Exists() && output.IsArray() {\n\t\t\toutput.ForEach(func(key, value gjson.Result) bool {\n\t\t\t\titemType := value.Get(\"type\").String()\n\n\t\t\t\tswitch itemType {\n\t\t\t\tcase \"reasoning\":\n\t\t\t\t\t// Flush any pending function calls before adding non-function content\n\t\t\t\t\tflushPendingFunctionCalls()\n\n\t\t\t\t\t// Add thinking content\n\t\t\t\t\tif content := value.Get(\"content\"); content.Exists() {\n\t\t\t\t\t\tpart := `{\"text\":\"\",\"thought\":true}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", content.String())\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", part)\n\t\t\t\t\t}\n\n\t\t\t\tcase \"message\":\n\t\t\t\t\t// Flush any pending function calls before adding non-function content\n\t\t\t\t\tflushPendingFunctionCalls()\n\n\t\t\t\t\t// Add regular text content\n\t\t\t\t\tif content := value.Get(\"content\"); content.Exists() && content.IsArray() {\n\t\t\t\t\t\tcontent.ForEach(func(_, contentItem gjson.Result) bool {\n\t\t\t\t\t\t\tif contentItem.Get(\"type\").String() == \"output_text\" {\n\t\t\t\t\t\t\t\tif text := contentItem.Get(\"text\"); text.Exists() {\n\t\t\t\t\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", text.String())\n\t\t\t\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"candidates.0.content.parts.-1\", part)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\n\t\t\t\tcase \"function_call\":\n\t\t\t\t\t// Collect function call for potential merging with consecutive ones\n\t\t\t\t\thasToolCall = true\n\t\t\t\t\tfunctionCall := `{\"functionCall\":{\"args\":{},\"name\":\"\"}}`\n\t\t\t\t\t{\n\t\t\t\t\t\tn := value.Get(\"name\").String()\n\t\t\t\t\t\trev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)\n\t\t\t\t\t\tif orig, ok := rev[n]; ok {\n\t\t\t\t\t\t\tn = orig\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"functionCall.name\", n)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Parse and set arguments\n\t\t\t\t\tif argsStr := value.Get(\"arguments\").String(); argsStr != \"\" {\n\t\t\t\t\t\targsResult := gjson.Parse(argsStr)\n\t\t\t\t\t\tif argsResult.IsObject() {\n\t\t\t\t\t\t\tfunctionCall, _ = sjson.SetRaw(functionCall, \"functionCall.args\", argsStr)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tpendingFunctionCalls = append(pendingFunctionCalls, functionCall)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\t// Handle any remaining pending function calls at the end\n\t\t\tflushPendingFunctionCalls()\n\t\t}\n\n\t\t// Set finish reason based on whether there were tool calls\n\t\tif hasToolCall {\n\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t} else {\n\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", \"STOP\")\n\t\t}\n\t}\n\treturn template\n}\n\n// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.\nfunc buildReverseMapFromGeminiOriginal(original []byte) map[string]string {\n\ttools := gjson.GetBytes(original, \"tools\")\n\trev := map[string]string{}\n\tif !tools.IsArray() {\n\t\treturn rev\n\t}\n\tvar names []string\n\ttarr := tools.Array()\n\tfor i := 0; i < len(tarr); i++ {\n\t\tfns := tarr[i].Get(\"functionDeclarations\")\n\t\tif !fns.IsArray() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, fn := range fns.Array() {\n\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\tnames = append(names, v.String())\n\t\t\t}\n\t\t}\n\t}\n\tif len(names) > 0 {\n\t\tm := buildShortNameMap(names)\n\t\tfor orig, short := range m {\n\t\t\trev[short] = orig\n\t\t}\n\t}\n\treturn rev\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tCodex,\n\t\tConvertGeminiRequestToCodex,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertCodexResponseToGemini,\n\t\t\tNonStream:  ConvertCodexResponseToGeminiNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini-cli/codex_gemini-cli_request.go",
    "content": "// Package geminiCLI provides request translation functionality for Gemini CLI to Codex API compatibility.\n// It handles parsing and transforming Gemini CLI API requests into Codex API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini CLI API format and Codex API's expected format.\npackage geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiCLIRequestToCodex parses and transforms a Gemini CLI API request into Codex API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Codex API.\n// The function performs the following transformations:\n// 1. Extracts the inner request object and promotes it to the top level\n// 2. Restores the model information at the top level\n// 3. Converts systemInstruction field to system_instruction for Codex compatibility\n// 4. Delegates to the Gemini-to-Codex conversion function for further processing\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in Codex API format\nfunc ConvertGeminiCLIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\trawJSON = []byte(gjson.GetBytes(rawJSON, \"request\").Raw)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"model\", modelName)\n\tif gjson.GetBytes(rawJSON, \"systemInstruction\").Exists() {\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"system_instruction\", []byte(gjson.GetBytes(rawJSON, \"systemInstruction\").Raw))\n\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"systemInstruction\")\n\t}\n\n\treturn ConvertGeminiRequestToCodex(modelName, rawJSON, stream)\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini-cli/codex_gemini-cli_response.go",
    "content": "// Package geminiCLI provides response translation functionality for Codex to Gemini CLI API compatibility.\n// This package handles the conversion of Codex API responses into Gemini CLI-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini CLI API clients.\npackage geminiCLI\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertCodexResponseToGeminiCLI converts Codex streaming response format to Gemini CLI format.\n// This function processes various Codex event types and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini CLI API format.\n// The function wraps each converted response in a \"response\" object to match the Gemini CLI API structure.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response wrapped in a response object\nfunc ConvertCodexResponseToGeminiCLI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\toutputs := ConvertCodexResponseToGemini(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\tnewOutputs := make([]string, 0)\n\tfor i := 0; i < len(outputs); i++ {\n\t\tjson := `{\"response\": {}}`\n\t\toutput, _ := sjson.SetRaw(json, \"response\", outputs[i])\n\t\tnewOutputs = append(newOutputs, output)\n\t}\n\treturn newOutputs\n}\n\n// ConvertCodexResponseToGeminiCLINonStream converts a non-streaming Codex response to a non-streaming Gemini CLI response.\n// This function processes the complete Codex response and transforms it into a single Gemini-compatible\n// JSON response. It wraps the converted response in a \"response\" object to match the Gemini CLI API structure.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for the conversion\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response wrapped in a response object\nfunc ConvertCodexResponseToGeminiCLINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\t// log.Debug(string(rawJSON))\n\tstrJSON := ConvertCodexResponseToGeminiNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\tjson := `{\"response\": {}}`\n\tstrJSON, _ = sjson.SetRaw(json, \"response\", strJSON)\n\treturn strJSON\n}\n\nfunc GeminiCLITokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/codex/gemini-cli/init.go",
    "content": "package geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGeminiCLI,\n\t\tCodex,\n\t\tConvertGeminiCLIRequestToCodex,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertCodexResponseToGeminiCLI,\n\t\t\tNonStream:  ConvertCodexResponseToGeminiCLINonStream,\n\t\t\tTokenCount: GeminiCLITokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/chat-completions/codex_openai_request.go",
    "content": "// Package openai provides utilities to translate OpenAI Chat Completions\n// request JSON into OpenAI Responses API request JSON using gjson/sjson.\n// It supports tools, multimodal text/image inputs, and Structured Outputs.\n// The package handles the conversion of OpenAI API requests into the format\n// expected by the OpenAI Responses API, including proper mapping of messages,\n// tools, and generation parameters.\npackage chat_completions\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertOpenAIRequestToCodex converts an OpenAI Chat Completions request JSON\n// into an OpenAI Responses API request JSON. The transformation follows the\n// examples defined in docs/2.md exactly, including tools, multi-turn dialog,\n// multimodal text/image handling, and Structured Outputs mapping.\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI Chat Completions API\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in OpenAI Responses API format\nfunc ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\t// Start with empty JSON object\n\tout := `{\"instructions\":\"\"}`\n\n\t// Stream must be set to true\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Codex not support temperature, top_p, top_k, max_output_tokens, so comment them\n\t// if v := gjson.GetBytes(rawJSON, \"temperature\"); v.Exists() {\n\t// \tout, _ = sjson.Set(out, \"temperature\", v.Value())\n\t// }\n\t// if v := gjson.GetBytes(rawJSON, \"top_p\"); v.Exists() {\n\t// \tout, _ = sjson.Set(out, \"top_p\", v.Value())\n\t// }\n\t// if v := gjson.GetBytes(rawJSON, \"top_k\"); v.Exists() {\n\t// \tout, _ = sjson.Set(out, \"top_k\", v.Value())\n\t// }\n\n\t// Map token limits\n\t// if v := gjson.GetBytes(rawJSON, \"max_tokens\"); v.Exists() {\n\t// \tout, _ = sjson.Set(out, \"max_output_tokens\", v.Value())\n\t// }\n\t// if v := gjson.GetBytes(rawJSON, \"max_completion_tokens\"); v.Exists() {\n\t// \tout, _ = sjson.Set(out, \"max_output_tokens\", v.Value())\n\t// }\n\n\t// Map reasoning effort\n\tif v := gjson.GetBytes(rawJSON, \"reasoning_effort\"); v.Exists() {\n\t\tout, _ = sjson.Set(out, \"reasoning.effort\", v.Value())\n\t} else {\n\t\tout, _ = sjson.Set(out, \"reasoning.effort\", \"medium\")\n\t}\n\tout, _ = sjson.Set(out, \"parallel_tool_calls\", true)\n\tout, _ = sjson.Set(out, \"reasoning.summary\", \"auto\")\n\tout, _ = sjson.Set(out, \"include\", []string{\"reasoning.encrypted_content\"})\n\n\t// Model\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Build tool name shortening map from original tools (if any)\n\toriginalToolNameMap := map[string]string{}\n\t{\n\t\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\t\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\t\t// Collect original tool names\n\t\t\tvar names []string\n\t\t\tarr := tools.Array()\n\t\t\tfor i := 0; i < len(arr); i++ {\n\t\t\t\tt := arr[i]\n\t\t\t\tif t.Get(\"type\").String() == \"function\" {\n\t\t\t\t\tfn := t.Get(\"function\")\n\t\t\t\t\tif fn.Exists() {\n\t\t\t\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\t\t\t\tnames = append(names, v.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(names) > 0 {\n\t\t\t\toriginalToolNameMap = buildShortNameMap(names)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Extract system instructions from first system message (string or text object)\n\tmessages := gjson.GetBytes(rawJSON, \"messages\")\n\t// if messages.IsArray() {\n\t// \tarr := messages.Array()\n\t// \tfor i := 0; i < len(arr); i++ {\n\t// \t\tm := arr[i]\n\t// \t\tif m.Get(\"role\").String() == \"system\" {\n\t// \t\t\tc := m.Get(\"content\")\n\t// \t\t\tif c.Type == gjson.String {\n\t// \t\t\t\tout, _ = sjson.Set(out, \"instructions\", c.String())\n\t// \t\t\t} else if c.IsObject() && c.Get(\"type\").String() == \"text\" {\n\t// \t\t\t\tout, _ = sjson.Set(out, \"instructions\", c.Get(\"text\").String())\n\t// \t\t\t}\n\t// \t\t\tbreak\n\t// \t\t}\n\t// \t}\n\t// }\n\n\t// Build input from messages, handling all message types including tool calls\n\tout, _ = sjson.SetRaw(out, \"input\", `[]`)\n\tif messages.IsArray() {\n\t\tarr := messages.Array()\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\n\t\t\tswitch role {\n\t\t\tcase \"tool\":\n\t\t\t\t// Handle tool response messages as top-level function_call_output objects\n\t\t\t\ttoolCallID := m.Get(\"tool_call_id\").String()\n\t\t\t\tcontent := m.Get(\"content\").String()\n\n\t\t\t\t// Create function_call_output object\n\t\t\t\tfuncOutput := `{}`\n\t\t\t\tfuncOutput, _ = sjson.Set(funcOutput, \"type\", \"function_call_output\")\n\t\t\t\tfuncOutput, _ = sjson.Set(funcOutput, \"call_id\", toolCallID)\n\t\t\t\tfuncOutput, _ = sjson.Set(funcOutput, \"output\", content)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", funcOutput)\n\n\t\t\tdefault:\n\t\t\t\t// Handle regular messages\n\t\t\t\tmsg := `{}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"type\", \"message\")\n\t\t\t\tif role == \"system\" {\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", \"developer\")\n\t\t\t\t} else {\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\t\t\t\t}\n\n\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content\", `[]`)\n\n\t\t\t\t// Handle regular content\n\t\t\t\tc := m.Get(\"content\")\n\t\t\t\tif c.Exists() && c.Type == gjson.String && c.String() != \"\" {\n\t\t\t\t\t// Single string content\n\t\t\t\t\tpartType := \"input_text\"\n\t\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\t\tpartType = \"output_text\"\n\t\t\t\t\t}\n\t\t\t\t\tpart := `{}`\n\t\t\t\t\tpart, _ = sjson.Set(part, \"type\", partType)\n\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", c.String())\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t} else if c.Exists() && c.IsArray() {\n\t\t\t\t\titems := c.Array()\n\t\t\t\t\tfor j := 0; j < len(items); j++ {\n\t\t\t\t\t\tit := items[j]\n\t\t\t\t\t\tt := it.Get(\"type\").String()\n\t\t\t\t\t\tswitch t {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\tpartType := \"input_text\"\n\t\t\t\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\t\t\t\tpartType = \"output_text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tpart := `{}`\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"type\", partType)\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", it.Get(\"text\").String())\n\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\t// Map image inputs to input_image for Responses API\n\t\t\t\t\t\t\tif role == \"user\" {\n\t\t\t\t\t\t\t\tpart := `{}`\n\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"type\", \"input_image\")\n\t\t\t\t\t\t\t\tif u := it.Get(\"image_url.url\"); u.Exists() {\n\t\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"image_url\", u.String())\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"file\":\n\t\t\t\t\t\t\tif role == \"user\" {\n\t\t\t\t\t\t\t\tfileData := it.Get(\"file.file_data\").String()\n\t\t\t\t\t\t\t\tfilename := it.Get(\"file.filename\").String()\n\t\t\t\t\t\t\t\tif fileData != \"\" {\n\t\t\t\t\t\t\t\t\tpart := `{}`\n\t\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"type\", \"input_file\")\n\t\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"file_data\", fileData)\n\t\t\t\t\t\t\t\t\tif filename != \"\" {\n\t\t\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"filename\", filename)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", part)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Don't emit empty assistant messages when only tool_calls\n\t\t\t\t// are present — Responses API needs function_call items\n\t\t\t\t// directly, otherwise call_id matching fails (#2132).\n\t\t\t\tif role != \"assistant\" || len(gjson.Get(msg, \"content\").Array()) > 0 {\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", msg)\n\t\t\t\t}\n\n\t\t\t\t// Handle tool calls for assistant messages as separate top-level objects\n\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\ttoolCalls := m.Get(\"tool_calls\")\n\t\t\t\t\tif toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\t\t\t\ttoolCallsArr := toolCalls.Array()\n\t\t\t\t\t\tfor j := 0; j < len(toolCallsArr); j++ {\n\t\t\t\t\t\t\ttc := toolCallsArr[j]\n\t\t\t\t\t\t\tif tc.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\t\t\t// Create function_call as top-level object\n\t\t\t\t\t\t\t\tfuncCall := `{}`\n\t\t\t\t\t\t\t\tfuncCall, _ = sjson.Set(funcCall, \"type\", \"function_call\")\n\t\t\t\t\t\t\t\tfuncCall, _ = sjson.Set(funcCall, \"call_id\", tc.Get(\"id\").String())\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\t\t\t\tif short, ok := originalToolNameMap[name]; ok {\n\t\t\t\t\t\t\t\t\t\tname = short\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tfuncCall, _ = sjson.Set(funcCall, \"name\", name)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tfuncCall, _ = sjson.Set(funcCall, \"arguments\", tc.Get(\"function.arguments\").String())\n\t\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"input.-1\", funcCall)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Map response_format and text settings to Responses API text.format\n\trf := gjson.GetBytes(rawJSON, \"response_format\")\n\ttext := gjson.GetBytes(rawJSON, \"text\")\n\tif rf.Exists() {\n\t\t// Always create text object when response_format provided\n\t\tif !gjson.Get(out, \"text\").Exists() {\n\t\t\tout, _ = sjson.SetRaw(out, \"text\", `{}`)\n\t\t}\n\n\t\trft := rf.Get(\"type\").String()\n\t\tswitch rft {\n\t\tcase \"text\":\n\t\t\tout, _ = sjson.Set(out, \"text.format.type\", \"text\")\n\t\tcase \"json_schema\":\n\t\t\tjs := rf.Get(\"json_schema\")\n\t\t\tif js.Exists() {\n\t\t\t\tout, _ = sjson.Set(out, \"text.format.type\", \"json_schema\")\n\t\t\t\tif v := js.Get(\"name\"); v.Exists() {\n\t\t\t\t\tout, _ = sjson.Set(out, \"text.format.name\", v.Value())\n\t\t\t\t}\n\t\t\t\tif v := js.Get(\"strict\"); v.Exists() {\n\t\t\t\t\tout, _ = sjson.Set(out, \"text.format.strict\", v.Value())\n\t\t\t\t}\n\t\t\t\tif v := js.Get(\"schema\"); v.Exists() {\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"text.format.schema\", v.Raw)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Map verbosity if provided\n\t\tif text.Exists() {\n\t\t\tif v := text.Get(\"verbosity\"); v.Exists() {\n\t\t\t\tout, _ = sjson.Set(out, \"text.verbosity\", v.Value())\n\t\t\t}\n\t\t}\n\t} else if text.Exists() {\n\t\t// If only text.verbosity present (no response_format), map verbosity\n\t\tif v := text.Get(\"verbosity\"); v.Exists() {\n\t\t\tif !gjson.Get(out, \"text\").Exists() {\n\t\t\t\tout, _ = sjson.SetRaw(out, \"text\", `{}`)\n\t\t\t}\n\t\t\tout, _ = sjson.Set(out, \"text.verbosity\", v.Value())\n\t\t}\n\t}\n\n\t// Map tools (flatten function fields)\n\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\tout, _ = sjson.SetRaw(out, \"tools\", `[]`)\n\t\tarr := tools.Array()\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tt := arr[i]\n\t\t\ttoolType := t.Get(\"type\").String()\n\t\t\t// Pass through built-in tools (e.g. {\"type\":\"web_search\"}) directly for the Responses API.\n\t\t\t// Only \"function\" needs structural conversion because Chat Completions nests details under \"function\".\n\t\t\tif toolType != \"\" && toolType != \"function\" && t.IsObject() {\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.-1\", t.Raw)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif toolType == \"function\" {\n\t\t\t\titem := `{}`\n\t\t\t\titem, _ = sjson.Set(item, \"type\", \"function\")\n\t\t\t\tfn := t.Get(\"function\")\n\t\t\t\tif fn.Exists() {\n\t\t\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\t\t\tname := v.String()\n\t\t\t\t\t\tif short, ok := originalToolNameMap[name]; ok {\n\t\t\t\t\t\t\tname = short\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t\t\t}\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"name\", name)\n\t\t\t\t\t}\n\t\t\t\t\tif v := fn.Get(\"description\"); v.Exists() {\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"description\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := fn.Get(\"parameters\"); v.Exists() {\n\t\t\t\t\t\titem, _ = sjson.SetRaw(item, \"parameters\", v.Raw)\n\t\t\t\t\t}\n\t\t\t\t\tif v := fn.Get(\"strict\"); v.Exists() {\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"strict\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.-1\", item)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Map tool_choice when present.\n\t// Chat Completions: \"tool_choice\" can be a string (\"auto\"/\"none\") or an object (e.g. {\"type\":\"function\",\"function\":{\"name\":\"...\"}}).\n\t// Responses API: keep built-in tool choices as-is; flatten function choice to {\"type\":\"function\",\"name\":\"...\"}.\n\tif tc := gjson.GetBytes(rawJSON, \"tool_choice\"); tc.Exists() {\n\t\tswitch {\n\t\tcase tc.Type == gjson.String:\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", tc.String())\n\t\tcase tc.IsObject():\n\t\t\ttcType := tc.Get(\"type\").String()\n\t\t\tif tcType == \"function\" {\n\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\tif name != \"\" {\n\t\t\t\t\tif short, ok := originalToolNameMap[name]; ok {\n\t\t\t\t\t\tname = short\n\t\t\t\t\t} else {\n\t\t\t\t\t\tname = shortenNameIfNeeded(name)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchoice := `{}`\n\t\t\t\tchoice, _ = sjson.Set(choice, \"type\", \"function\")\n\t\t\t\tif name != \"\" {\n\t\t\t\t\tchoice, _ = sjson.Set(choice, \"name\", name)\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", choice)\n\t\t\t} else if tcType != \"\" {\n\t\t\t\t// Built-in tool choices (e.g. {\"type\":\"web_search\"}) are already Responses-compatible.\n\t\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", tc.Raw)\n\t\t\t}\n\t\t}\n\t}\n\n\tout, _ = sjson.Set(out, \"store\", false)\n\treturn []byte(out)\n}\n\n// shortenNameIfNeeded applies the simple shortening rule for a single name.\n// If the name length exceeds 64, it will try to preserve the \"mcp__\" prefix and last segment.\n// Otherwise it truncates to 64 characters.\nfunc shortenNameIfNeeded(name string) string {\n\tconst limit = 64\n\tif len(name) <= limit {\n\t\treturn name\n\t}\n\tif strings.HasPrefix(name, \"mcp__\") {\n\t\t// Keep prefix and last segment after '__'\n\t\tidx := strings.LastIndex(name, \"__\")\n\t\tif idx > 0 {\n\t\t\tcandidate := \"mcp__\" + name[idx+2:]\n\t\t\tif len(candidate) > limit {\n\t\t\t\treturn candidate[:limit]\n\t\t\t}\n\t\t\treturn candidate\n\t\t}\n\t}\n\treturn name[:limit]\n}\n\n// buildShortNameMap generates unique short names (<=64) for the given list of names.\n// It preserves the \"mcp__\" prefix with the last segment when possible and ensures uniqueness\n// by appending suffixes like \"~1\", \"~2\" if needed.\nfunc buildShortNameMap(names []string) map[string]string {\n\tconst limit = 64\n\tused := map[string]struct{}{}\n\tm := map[string]string{}\n\n\tbaseCandidate := func(n string) string {\n\t\tif len(n) <= limit {\n\t\t\treturn n\n\t\t}\n\t\tif strings.HasPrefix(n, \"mcp__\") {\n\t\t\tidx := strings.LastIndex(n, \"__\")\n\t\t\tif idx > 0 {\n\t\t\t\tcand := \"mcp__\" + n[idx+2:]\n\t\t\t\tif len(cand) > limit {\n\t\t\t\t\tcand = cand[:limit]\n\t\t\t\t}\n\t\t\t\treturn cand\n\t\t\t}\n\t\t}\n\t\treturn n[:limit]\n\t}\n\n\tmakeUnique := func(cand string) string {\n\t\tif _, ok := used[cand]; !ok {\n\t\t\treturn cand\n\t\t}\n\t\tbase := cand\n\t\tfor i := 1; ; i++ {\n\t\t\tsuffix := \"_\" + strconv.Itoa(i)\n\t\t\tallowed := limit - len(suffix)\n\t\t\tif allowed < 0 {\n\t\t\t\tallowed = 0\n\t\t\t}\n\t\t\ttmp := base\n\t\t\tif len(tmp) > allowed {\n\t\t\t\ttmp = tmp[:allowed]\n\t\t\t}\n\t\t\ttmp = tmp + suffix\n\t\t\tif _, ok := used[tmp]; !ok {\n\t\t\t\treturn tmp\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, n := range names {\n\t\tcand := baseCandidate(n)\n\t\tuniq := makeUnique(cand)\n\t\tused[uniq] = struct{}{}\n\t\tm[n] = uniq\n\t}\n\treturn m\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/chat-completions/codex_openai_request_test.go",
    "content": "package chat_completions\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\n// Basic tool-call: system + user + assistant(tool_calls, no content) + tool result.\n// Expects developer msg + user msg + function_call + function_call_output.\n// No empty assistant message should appear between user and function_call.\nfunc TestToolCallSimple(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n\t\t\t{\"role\": \"user\", \"content\": \"What is the weather in Paris?\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_1\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"Paris\\\"}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"tool\",\n\t\t\t\t\"tool_call_id\": \"call_1\",\n\t\t\t\t\"content\": \"sunny, 22C\"\n\t\t\t}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\"description\": \"Get weather for a city\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\tif len(items) != 4 {\n\t\tt.Fatalf(\"expected 4 input items, got %d: %s\", len(items), gjson.Get(result, \"input\").Raw)\n\t}\n\n\t// system -> developer\n\tif items[0].Get(\"type\").String() != \"message\" {\n\t\tt.Errorf(\"item 0: expected type 'message', got '%s'\", items[0].Get(\"type\").String())\n\t}\n\tif items[0].Get(\"role\").String() != \"developer\" {\n\t\tt.Errorf(\"item 0: expected role 'developer', got '%s'\", items[0].Get(\"role\").String())\n\t}\n\n\t// user\n\tif items[1].Get(\"type\").String() != \"message\" {\n\t\tt.Errorf(\"item 1: expected type 'message', got '%s'\", items[1].Get(\"type\").String())\n\t}\n\tif items[1].Get(\"role\").String() != \"user\" {\n\t\tt.Errorf(\"item 1: expected role 'user', got '%s'\", items[1].Get(\"role\").String())\n\t}\n\n\t// function_call, not an empty assistant msg\n\tif items[2].Get(\"type\").String() != \"function_call\" {\n\t\tt.Errorf(\"item 2: expected type 'function_call', got '%s'\", items[2].Get(\"type\").String())\n\t}\n\tif items[2].Get(\"call_id\").String() != \"call_1\" {\n\t\tt.Errorf(\"item 2: expected call_id 'call_1', got '%s'\", items[2].Get(\"call_id\").String())\n\t}\n\tif items[2].Get(\"name\").String() != \"get_weather\" {\n\t\tt.Errorf(\"item 2: expected name 'get_weather', got '%s'\", items[2].Get(\"name\").String())\n\t}\n\tif items[2].Get(\"arguments\").String() != `{\"city\":\"Paris\"}` {\n\t\tt.Errorf(\"item 2: unexpected arguments: %s\", items[2].Get(\"arguments\").String())\n\t}\n\n\t// function_call_output\n\tif items[3].Get(\"type\").String() != \"function_call_output\" {\n\t\tt.Errorf(\"item 3: expected type 'function_call_output', got '%s'\", items[3].Get(\"type\").String())\n\t}\n\tif items[3].Get(\"call_id\").String() != \"call_1\" {\n\t\tt.Errorf(\"item 3: expected call_id 'call_1', got '%s'\", items[3].Get(\"call_id\").String())\n\t}\n\tif items[3].Get(\"output\").String() != \"sunny, 22C\" {\n\t\tt.Errorf(\"item 3: expected output 'sunny, 22C', got '%s'\", items[3].Get(\"output\").String())\n\t}\n}\n\n// Assistant has both text content and tool_calls — the message should\n// be emitted (non-empty content), followed by function_call items.\nfunc TestToolCallWithContent(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"What is the weather?\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": \"Let me check the weather for you.\",\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_abc\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"arguments\": \"{}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"tool\",\n\t\t\t\t\"tool_call_id\": \"call_abc\",\n\t\t\t\t\"content\": \"rainy, 15C\"\n\t\t\t}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\t// user + assistant(with content) + function_call + function_call_output\n\tif len(items) != 4 {\n\t\tt.Fatalf(\"expected 4 input items, got %d: %s\", len(items), gjson.Get(result, \"input\").Raw)\n\t}\n\n\tif items[0].Get(\"role\").String() != \"user\" {\n\t\tt.Errorf(\"item 0: expected role 'user', got '%s'\", items[0].Get(\"role\").String())\n\t}\n\n\t// assistant with content — should be kept\n\tif items[1].Get(\"type\").String() != \"message\" {\n\t\tt.Errorf(\"item 1: expected type 'message', got '%s'\", items[1].Get(\"type\").String())\n\t}\n\tif items[1].Get(\"role\").String() != \"assistant\" {\n\t\tt.Errorf(\"item 1: expected role 'assistant', got '%s'\", items[1].Get(\"role\").String())\n\t}\n\tcontentParts := items[1].Get(\"content\").Array()\n\tif len(contentParts) == 0 {\n\t\tt.Errorf(\"item 1: assistant message should have content parts\")\n\t}\n\n\tif items[2].Get(\"type\").String() != \"function_call\" {\n\t\tt.Errorf(\"item 2: expected type 'function_call', got '%s'\", items[2].Get(\"type\").String())\n\t}\n\tif items[2].Get(\"call_id\").String() != \"call_abc\" {\n\t\tt.Errorf(\"item 2: expected call_id 'call_abc', got '%s'\", items[2].Get(\"call_id\").String())\n\t}\n\n\tif items[3].Get(\"type\").String() != \"function_call_output\" {\n\t\tt.Errorf(\"item 3: expected type 'function_call_output', got '%s'\", items[3].Get(\"type\").String())\n\t}\n\tif items[3].Get(\"call_id\").String() != \"call_abc\" {\n\t\tt.Errorf(\"item 3: expected call_id 'call_abc', got '%s'\", items[3].Get(\"call_id\").String())\n\t}\n}\n\n// Parallel tool calls: assistant invokes 3 tools at once, all call_ids\n// and outputs must be translated and paired correctly.\nfunc TestMultipleToolCalls(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Compare weather in Paris, London and Tokyo\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_paris\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"Paris\\\"}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_london\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"London\\\"}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_tokyo\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"Tokyo\\\"}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_paris\", \"content\": \"sunny, 22C\"},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_london\", \"content\": \"cloudy, 14C\"},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_tokyo\", \"content\": \"humid, 28C\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\t// user + 3 function_call + 3 function_call_output = 7\n\tif len(items) != 7 {\n\t\tt.Fatalf(\"expected 7 input items, got %d: %s\", len(items), gjson.Get(result, \"input\").Raw)\n\t}\n\n\tif items[0].Get(\"role\").String() != \"user\" {\n\t\tt.Errorf(\"item 0: expected role 'user', got '%s'\", items[0].Get(\"role\").String())\n\t}\n\n\texpectedCallIDs := []string{\"call_paris\", \"call_london\", \"call_tokyo\"}\n\tfor i, expectedID := range expectedCallIDs {\n\t\tidx := i + 1\n\t\tif items[idx].Get(\"type\").String() != \"function_call\" {\n\t\t\tt.Errorf(\"item %d: expected type 'function_call', got '%s'\", idx, items[idx].Get(\"type\").String())\n\t\t}\n\t\tif items[idx].Get(\"call_id\").String() != expectedID {\n\t\t\tt.Errorf(\"item %d: expected call_id '%s', got '%s'\", idx, expectedID, items[idx].Get(\"call_id\").String())\n\t\t}\n\t}\n\n\texpectedOutputs := []string{\"sunny, 22C\", \"cloudy, 14C\", \"humid, 28C\"}\n\tfor i, expectedOutput := range expectedOutputs {\n\t\tidx := i + 4\n\t\tif items[idx].Get(\"type\").String() != \"function_call_output\" {\n\t\t\tt.Errorf(\"item %d: expected type 'function_call_output', got '%s'\", idx, items[idx].Get(\"type\").String())\n\t\t}\n\t\tif items[idx].Get(\"call_id\").String() != expectedCallIDs[i] {\n\t\t\tt.Errorf(\"item %d: expected call_id '%s', got '%s'\", idx, expectedCallIDs[i], items[idx].Get(\"call_id\").String())\n\t\t}\n\t\tif items[idx].Get(\"output\").String() != expectedOutput {\n\t\t\tt.Errorf(\"item %d: expected output '%s', got '%s'\", idx, expectedOutput, items[idx].Get(\"output\").String())\n\t\t}\n\t}\n}\n\n// Regression test for #2132: tool-call-only assistant messages (content:null)\n// must not produce an empty message item in the translated output.\nfunc TestNoSpuriousEmptyAssistantMessage(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Call a tool\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_x\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\"name\": \"do_thing\", \"arguments\": \"{}\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_x\", \"content\": \"done\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"do_thing\",\n\t\t\t\t\t\"description\": \"Do a thing\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\n\tfor i, item := range items {\n\t\ttyp := item.Get(\"type\").String()\n\t\trole := item.Get(\"role\").String()\n\t\tif typ == \"message\" && role == \"assistant\" {\n\t\t\tcontentArr := item.Get(\"content\").Array()\n\t\t\tif len(contentArr) == 0 {\n\t\t\t\tt.Errorf(\"item %d: empty assistant message breaks call_id matching. item: %s\", i, item.Raw)\n\t\t\t}\n\t\t}\n\t}\n\n\t// should be exactly: user + function_call + function_call_output\n\tif len(items) != 3 {\n\t\tt.Fatalf(\"expected 3 input items (user + function_call + function_call_output), got %d: %s\", len(items), gjson.Get(result, \"input\").Raw)\n\t}\n\tif items[0].Get(\"type\").String() != \"message\" || items[0].Get(\"role\").String() != \"user\" {\n\t\tt.Errorf(\"item 0: expected user message\")\n\t}\n\tif items[1].Get(\"type\").String() != \"function_call\" {\n\t\tt.Errorf(\"item 1: expected function_call, got %s\", items[1].Get(\"type\").String())\n\t}\n\tif items[2].Get(\"type\").String() != \"function_call_output\" {\n\t\tt.Errorf(\"item 2: expected function_call_output, got %s\", items[2].Get(\"type\").String())\n\t}\n}\n\n// Two rounds of tool calling in one conversation, with a text reply in between.\nfunc TestMultiTurnToolCalling(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Weather in Paris?\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [{\"id\": \"call_r1\", \"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"arguments\": \"{\\\"city\\\":\\\"Paris\\\"}\"}}]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_r1\", \"content\": \"sunny\"},\n\t\t\t{\"role\": \"assistant\", \"content\": \"It is sunny in Paris.\"},\n\t\t\t{\"role\": \"user\", \"content\": \"And London?\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [{\"id\": \"call_r2\", \"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"arguments\": \"{\\\"city\\\":\\\"London\\\"}\"}}]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_r2\", \"content\": \"rainy\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\"description\": \"Get weather\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\t// user, func_call(r1), func_output(r1), assistant text, user, func_call(r2), func_output(r2)\n\tif len(items) != 7 {\n\t\tt.Fatalf(\"expected 7 input items, got %d: %s\", len(items), gjson.Get(result, \"input\").Raw)\n\t}\n\n\tfor i, item := range items {\n\t\tif item.Get(\"type\").String() == \"message\" && item.Get(\"role\").String() == \"assistant\" {\n\t\t\tif len(item.Get(\"content\").Array()) == 0 {\n\t\t\t\tt.Errorf(\"item %d: unexpected empty assistant message\", i)\n\t\t\t}\n\t\t}\n\t}\n\n\t// round 1\n\tif items[1].Get(\"type\").String() != \"function_call\" {\n\t\tt.Errorf(\"item 1: expected function_call, got %s\", items[1].Get(\"type\").String())\n\t}\n\tif items[1].Get(\"call_id\").String() != \"call_r1\" {\n\t\tt.Errorf(\"item 1: expected call_id 'call_r1', got '%s'\", items[1].Get(\"call_id\").String())\n\t}\n\tif items[2].Get(\"type\").String() != \"function_call_output\" {\n\t\tt.Errorf(\"item 2: expected function_call_output, got %s\", items[2].Get(\"type\").String())\n\t}\n\n\t// text reply between rounds\n\tif items[3].Get(\"type\").String() != \"message\" || items[3].Get(\"role\").String() != \"assistant\" {\n\t\tt.Errorf(\"item 3: expected assistant message, got type=%s role=%s\", items[3].Get(\"type\").String(), items[3].Get(\"role\").String())\n\t}\n\n\t// round 2\n\tif items[5].Get(\"type\").String() != \"function_call\" {\n\t\tt.Errorf(\"item 5: expected function_call, got %s\", items[5].Get(\"type\").String())\n\t}\n\tif items[5].Get(\"call_id\").String() != \"call_r2\" {\n\t\tt.Errorf(\"item 5: expected call_id 'call_r2', got '%s'\", items[5].Get(\"call_id\").String())\n\t}\n\tif items[6].Get(\"type\").String() != \"function_call_output\" {\n\t\tt.Errorf(\"item 6: expected function_call_output, got %s\", items[6].Get(\"type\").String())\n\t}\n}\n\n// Tool names over 64 chars get shortened, call_id stays the same.\nfunc TestToolNameShortening(t *testing.T) {\n\tlongName := \"a_very_long_tool_name_that_exceeds_sixty_four_characters_limit_here_test\"\n\tif len(longName) <= 64 {\n\t\tt.Fatalf(\"test setup error: name must be > 64 chars, got %d\", len(longName))\n\t}\n\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Do it\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_long\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"` + longName + `\",\n\t\t\t\t\t\t\t\"arguments\": \"{}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_long\", \"content\": \"ok\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"` + longName + `\",\n\t\t\t\t\t\"description\": \"A tool with a very long name\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\n\t// find function_call\n\tvar funcCallItem gjson.Result\n\tfor _, item := range items {\n\t\tif item.Get(\"type\").String() == \"function_call\" {\n\t\t\tfuncCallItem = item\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !funcCallItem.Exists() {\n\t\tt.Fatal(\"no function_call item found in output\")\n\t}\n\n\t// call_id unchanged\n\tif funcCallItem.Get(\"call_id\").String() != \"call_long\" {\n\t\tt.Errorf(\"call_id changed: expected 'call_long', got '%s'\", funcCallItem.Get(\"call_id\").String())\n\t}\n\n\t// name must be truncated\n\ttranslatedName := funcCallItem.Get(\"name\").String()\n\tif translatedName == longName {\n\t\tt.Errorf(\"tool name was NOT shortened: still '%s'\", translatedName)\n\t}\n\tif len(translatedName) > 64 {\n\t\tt.Errorf(\"shortened name still > 64 chars: len=%d name='%s'\", len(translatedName), translatedName)\n\t}\n}\n\n// content:\"\" (empty string, not null) should be treated the same as null.\nfunc TestEmptyStringContent(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Do something\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": \"\",\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_empty\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\"name\": \"action\", \"arguments\": \"{}\"}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"call_empty\", \"content\": \"result\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"action\",\n\t\t\t\t\t\"description\": \"An action\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {}}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\n\tfor i, item := range items {\n\t\tif item.Get(\"type\").String() == \"message\" && item.Get(\"role\").String() == \"assistant\" {\n\t\t\tif len(item.Get(\"content\").Array()) == 0 {\n\t\t\t\tt.Errorf(\"item %d: empty assistant message from content:\\\"\\\"\", i)\n\t\t\t}\n\t\t}\n\t}\n\n\t// user + function_call + function_call_output\n\tif len(items) != 3 {\n\t\tt.Errorf(\"expected 3 input items, got %d\", len(items))\n\t}\n}\n\n// Every function_call_output must have a matching function_call by call_id.\nfunc TestCallIDsMatchBetweenCallAndOutput(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Multi-tool\"},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\"id\": \"id_a\", \"type\": \"function\", \"function\": {\"name\": \"tool_a\", \"arguments\": \"{}\"}},\n\t\t\t\t\t{\"id\": \"id_b\", \"type\": \"function\", \"function\": {\"name\": \"tool_b\", \"arguments\": \"{}\"}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"id_a\", \"content\": \"res_a\"},\n\t\t\t{\"role\": \"tool\", \"tool_call_id\": \"id_b\", \"content\": \"res_b\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\"type\": \"function\", \"function\": {\"name\": \"tool_a\", \"description\": \"A\", \"parameters\": {\"type\": \"object\", \"properties\": {}}}},\n\t\t\t{\"type\": \"function\", \"function\": {\"name\": \"tool_b\", \"description\": \"B\", \"parameters\": {\"type\": \"object\", \"properties\": {}}}}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\titems := gjson.Get(result, \"input\").Array()\n\n\t// collect call_ids from function_call items\n\tcallIDs := make(map[string]bool)\n\tfor _, item := range items {\n\t\tif item.Get(\"type\").String() == \"function_call\" {\n\t\t\tcallIDs[item.Get(\"call_id\").String()] = true\n\t\t}\n\t}\n\n\tfor i, item := range items {\n\t\tif item.Get(\"type\").String() == \"function_call_output\" {\n\t\t\toutID := item.Get(\"call_id\").String()\n\t\t\tif !callIDs[outID] {\n\t\t\t\tt.Errorf(\"item %d: function_call_output has call_id '%s' with no matching function_call\", i, outID)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 2 calls, 2 outputs\n\tfuncCallCount := 0\n\tfuncOutputCount := 0\n\tfor _, item := range items {\n\t\tswitch item.Get(\"type\").String() {\n\t\tcase \"function_call\":\n\t\t\tfuncCallCount++\n\t\tcase \"function_call_output\":\n\t\t\tfuncOutputCount++\n\t\t}\n\t}\n\tif funcCallCount != 2 {\n\t\tt.Errorf(\"expected 2 function_calls, got %d\", funcCallCount)\n\t}\n\tif funcOutputCount != 2 {\n\t\tt.Errorf(\"expected 2 function_call_outputs, got %d\", funcOutputCount)\n\t}\n}\n\n// Tools array should carry over to the Responses format output.\nfunc TestToolsDefinitionTranslated(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"model\": \"gpt-4o\",\n\t\t\"messages\": [\n\t\t\t{\"role\": \"user\", \"content\": \"Hi\"}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": {\n\t\t\t\t\t\"name\": \"search\",\n\t\t\t\t\t\"description\": \"Search the web\",\n\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {\"query\": {\"type\": \"string\"}}, \"required\": [\"query\"]}\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertOpenAIRequestToCodex(\"gpt-4o\", input, true)\n\tresult := string(out)\n\n\ttools := gjson.Get(result, \"tools\").Array()\n\tif len(tools) == 0 {\n\t\tt.Fatal(\"no tools found in output\")\n\t}\n\n\tfound := false\n\tfor _, tool := range tools {\n\t\tif tool.Get(\"name\").String() == \"search\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Errorf(\"tool 'search' not found in output tools: %s\", gjson.Get(result, \"tools\").Raw)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/chat-completions/codex_openai_response.go",
    "content": "// Package openai provides response translation functionality for Codex to OpenAI API compatibility.\n// This package handles the conversion of Codex API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertCliToOpenAIParams holds parameters for response conversion.\ntype ConvertCliToOpenAIParams struct {\n\tResponseID                string\n\tCreatedAt                 int64\n\tModel                     string\n\tFunctionCallIndex         int\n\tHasReceivedArgumentsDelta bool\n\tHasToolCallAnnounced      bool\n}\n\n// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the\n// Codex API format to the OpenAI Chat Completions streaming format.\n// It processes various Codex event types and transforms them into OpenAI-compatible JSON responses.\n// The function handles text content, tool calls, reasoning content, and usage metadata, outputting\n// responses that match the OpenAI API format. It supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertCliToOpenAIParams{\n\t\t\tModel:                     modelName,\n\t\t\tCreatedAt:                 0,\n\t\t\tResponseID:                \"\",\n\t\t\tFunctionCallIndex:         -1,\n\t\t\tHasReceivedArgumentsDelta: false,\n\t\t\tHasToolCallAnnounced:      false,\n\t\t}\n\t}\n\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\t// Initialize the OpenAI SSE template.\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion.chunk\",\"created\":12345,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}]}`\n\n\trootResult := gjson.ParseBytes(rawJSON)\n\n\ttypeResult := rootResult.Get(\"type\")\n\tdataType := typeResult.String()\n\tif dataType == \"response.created\" {\n\t\t(*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get(\"response.id\").String()\n\t\t(*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get(\"response.created_at\").Int()\n\t\t(*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get(\"response.model\").String()\n\t\treturn []string{}\n\t}\n\n\t// Extract and set the model version.\n\tcachedModel := (*param).(*ConvertCliToOpenAIParams).Model\n\tif modelResult := gjson.GetBytes(rawJSON, \"model\"); modelResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelResult.String())\n\t} else if cachedModel != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"model\", cachedModel)\n\t} else if modelName != \"\" {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelName)\n\t}\n\n\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*ConvertCliToOpenAIParams).CreatedAt)\n\n\t// Extract and set the response ID.\n\ttemplate, _ = sjson.Set(template, \"id\", (*param).(*ConvertCliToOpenAIParams).ResponseID)\n\n\t// Extract and set usage metadata (token counts).\n\tif usageResult := gjson.GetBytes(rawJSON, \"response.usage\"); usageResult.Exists() {\n\t\tif outputTokensResult := usageResult.Get(\"output_tokens\"); outputTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", outputTokensResult.Int())\n\t\t}\n\t\tif totalTokensResult := usageResult.Get(\"total_tokens\"); totalTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", totalTokensResult.Int())\n\t\t}\n\t\tif inputTokensResult := usageResult.Get(\"input_tokens\"); inputTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", inputTokensResult.Int())\n\t\t}\n\t\tif cachedTokensResult := usageResult.Get(\"input_tokens_details.cached_tokens\"); cachedTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cachedTokensResult.Int())\n\t\t}\n\t\tif reasoningTokensResult := usageResult.Get(\"output_tokens_details.reasoning_tokens\"); reasoningTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens_details.reasoning_tokens\", reasoningTokensResult.Int())\n\t\t}\n\t}\n\n\tif dataType == \"response.reasoning_summary_text.delta\" {\n\t\tif deltaResult := rootResult.Get(\"delta\"); deltaResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", deltaResult.String())\n\t\t}\n\t} else if dataType == \"response.reasoning_summary_text.done\" {\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", \"\\n\\n\")\n\t} else if dataType == \"response.output_text.delta\" {\n\t\tif deltaResult := rootResult.Get(\"delta\"); deltaResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.content\", deltaResult.String())\n\t\t}\n\t} else if dataType == \"response.completed\" {\n\t\tfinishReason := \"stop\"\n\t\tif (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 {\n\t\t\tfinishReason = \"tool_calls\"\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", finishReason)\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", finishReason)\n\t} else if dataType == \"response.output_item.added\" {\n\t\titemResult := rootResult.Get(\"item\")\n\t\tif !itemResult.Exists() || itemResult.Get(\"type\").String() != \"function_call\" {\n\t\t\treturn []string{}\n\t\t}\n\n\t\t// Increment index for this new function call item.\n\t\t(*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++\n\t\t(*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = false\n\t\t(*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = true\n\n\t\tfunctionCallItemTemplate := `{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"index\", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"id\", itemResult.Get(\"call_id\").String())\n\n\t\t// Restore original tool name if it was shortened.\n\t\tname := itemResult.Get(\"name\").String()\n\t\trev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)\n\t\tif orig, ok := rev[name]; ok {\n\t\t\tname = orig\n\t\t}\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.name\", name)\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.arguments\", \"\")\n\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallItemTemplate)\n\n\t} else if dataType == \"response.function_call_arguments.delta\" {\n\t\t(*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = true\n\n\t\tdeltaValue := rootResult.Get(\"delta\").String()\n\t\tfunctionCallItemTemplate := `{\"index\":0,\"function\":{\"arguments\":\"\"}}`\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"index\", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.arguments\", deltaValue)\n\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallItemTemplate)\n\n\t} else if dataType == \"response.function_call_arguments.done\" {\n\t\tif (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta {\n\t\t\t// Arguments were already streamed via delta events; nothing to emit.\n\t\t\treturn []string{}\n\t\t}\n\n\t\t// Fallback: no delta events were received, emit the full arguments as a single chunk.\n\t\tfullArgs := rootResult.Get(\"arguments\").String()\n\t\tfunctionCallItemTemplate := `{\"index\":0,\"function\":{\"arguments\":\"\"}}`\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"index\", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.arguments\", fullArgs)\n\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallItemTemplate)\n\n\t} else if dataType == \"response.output_item.done\" {\n\t\titemResult := rootResult.Get(\"item\")\n\t\tif !itemResult.Exists() || itemResult.Get(\"type\").String() != \"function_call\" {\n\t\t\treturn []string{}\n\t\t}\n\n\t\tif (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced {\n\t\t\t// Tool call was already announced via output_item.added; skip emission.\n\t\t\t(*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = false\n\t\t\treturn []string{}\n\t\t}\n\n\t\t// Fallback path: model skipped output_item.added, so emit complete tool call now.\n\t\t(*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++\n\n\t\tfunctionCallItemTemplate := `{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"index\", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)\n\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"id\", itemResult.Get(\"call_id\").String())\n\n\t\t// Restore original tool name if it was shortened.\n\t\tname := itemResult.Get(\"name\").String()\n\t\trev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)\n\t\tif orig, ok := rev[name]; ok {\n\t\t\tname = orig\n\t\t}\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.name\", name)\n\n\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.arguments\", itemResult.Get(\"arguments\").String())\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallItemTemplate)\n\n\t} else {\n\t\treturn []string{}\n\t}\n\n\treturn []string{template}\n}\n\n// ConvertCodexResponseToOpenAINonStream converts a non-streaming Codex response to a non-streaming OpenAI response.\n// This function processes the complete Codex response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Codex API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\trootResult := gjson.ParseBytes(rawJSON)\n\t// Verify this is a response.completed event\n\tif rootResult.Get(\"type\").String() != \"response.completed\" {\n\t\treturn \"\"\n\t}\n\n\tunixTimestamp := time.Now().Unix()\n\n\tresponseResult := rootResult.Get(\"response\")\n\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion\",\"created\":123456,\"model\":\"model\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}]}`\n\n\t// Extract and set the model version.\n\tif modelResult := responseResult.Get(\"model\"); modelResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelResult.String())\n\t}\n\n\t// Extract and set the creation timestamp.\n\tif createdAtResult := responseResult.Get(\"created_at\"); createdAtResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"created\", createdAtResult.Int())\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"created\", unixTimestamp)\n\t}\n\n\t// Extract and set the response ID.\n\tif idResult := responseResult.Get(\"id\"); idResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"id\", idResult.String())\n\t}\n\n\t// Extract and set usage metadata (token counts).\n\tif usageResult := responseResult.Get(\"usage\"); usageResult.Exists() {\n\t\tif outputTokensResult := usageResult.Get(\"output_tokens\"); outputTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", outputTokensResult.Int())\n\t\t}\n\t\tif totalTokensResult := usageResult.Get(\"total_tokens\"); totalTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", totalTokensResult.Int())\n\t\t}\n\t\tif inputTokensResult := usageResult.Get(\"input_tokens\"); inputTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", inputTokensResult.Int())\n\t\t}\n\t\tif cachedTokensResult := usageResult.Get(\"input_tokens_details.cached_tokens\"); cachedTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cachedTokensResult.Int())\n\t\t}\n\t\tif reasoningTokensResult := usageResult.Get(\"output_tokens_details.reasoning_tokens\"); reasoningTokensResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens_details.reasoning_tokens\", reasoningTokensResult.Int())\n\t\t}\n\t}\n\n\t// Process the output array for content and function calls\n\toutputResult := responseResult.Get(\"output\")\n\tif outputResult.IsArray() {\n\t\toutputArray := outputResult.Array()\n\t\tvar contentText string\n\t\tvar reasoningText string\n\t\tvar toolCalls []string\n\n\t\tfor _, outputItem := range outputArray {\n\t\t\toutputType := outputItem.Get(\"type\").String()\n\n\t\t\tswitch outputType {\n\t\t\tcase \"reasoning\":\n\t\t\t\t// Extract reasoning content from summary\n\t\t\t\tif summaryResult := outputItem.Get(\"summary\"); summaryResult.IsArray() {\n\t\t\t\t\tsummaryArray := summaryResult.Array()\n\t\t\t\t\tfor _, summaryItem := range summaryArray {\n\t\t\t\t\t\tif summaryItem.Get(\"type\").String() == \"summary_text\" {\n\t\t\t\t\t\t\treasoningText = summaryItem.Get(\"text\").String()\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"message\":\n\t\t\t\t// Extract message content\n\t\t\t\tif contentResult := outputItem.Get(\"content\"); contentResult.IsArray() {\n\t\t\t\t\tcontentArray := contentResult.Array()\n\t\t\t\t\tfor _, contentItem := range contentArray {\n\t\t\t\t\t\tif contentItem.Get(\"type\").String() == \"output_text\" {\n\t\t\t\t\t\t\tcontentText = contentItem.Get(\"text\").String()\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"function_call\":\n\t\t\t\t// Handle function call content\n\t\t\t\tfunctionCallTemplate := `{\"id\": \"\",\"type\": \"function\",\"function\": {\"name\": \"\",\"arguments\": \"\"}}`\n\n\t\t\t\tif callIdResult := outputItem.Get(\"call_id\"); callIdResult.Exists() {\n\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"id\", callIdResult.String())\n\t\t\t\t}\n\n\t\t\t\tif nameResult := outputItem.Get(\"name\"); nameResult.Exists() {\n\t\t\t\t\tn := nameResult.String()\n\t\t\t\t\trev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)\n\t\t\t\t\tif orig, ok := rev[n]; ok {\n\t\t\t\t\t\tn = orig\n\t\t\t\t\t}\n\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.name\", n)\n\t\t\t\t}\n\n\t\t\t\tif argsResult := outputItem.Get(\"arguments\"); argsResult.Exists() {\n\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.arguments\", argsResult.String())\n\t\t\t\t}\n\n\t\t\t\ttoolCalls = append(toolCalls, functionCallTemplate)\n\t\t\t}\n\t\t}\n\n\t\t// Set content and reasoning content if found\n\t\tif contentText != \"\" {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.message.content\", contentText)\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.message.role\", \"assistant\")\n\t\t}\n\n\t\tif reasoningText != \"\" {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.message.reasoning_content\", reasoningText)\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.message.role\", \"assistant\")\n\t\t}\n\n\t\t// Add tool calls if any\n\t\tif len(toolCalls) > 0 {\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.message.tool_calls\", `[]`)\n\t\t\tfor _, toolCall := range toolCalls {\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.message.tool_calls.-1\", toolCall)\n\t\t\t}\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.message.role\", \"assistant\")\n\t\t}\n\t}\n\n\t// Extract and set the finish reason based on status\n\tif statusResult := responseResult.Get(\"status\"); statusResult.Exists() {\n\t\tstatus := statusResult.String()\n\t\tif status == \"completed\" {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", \"stop\")\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", \"stop\")\n\t\t}\n\t}\n\n\treturn template\n}\n\n// buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name\n// from the original OpenAI-style request JSON using the same shortening logic.\nfunc buildReverseMapFromOriginalOpenAI(original []byte) map[string]string {\n\ttools := gjson.GetBytes(original, \"tools\")\n\trev := map[string]string{}\n\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\tvar names []string\n\t\tarr := tools.Array()\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tt := arr[i]\n\t\t\tif t.Get(\"type\").String() != \"function\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfn := t.Get(\"function\")\n\t\t\tif !fn.Exists() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif v := fn.Get(\"name\"); v.Exists() {\n\t\t\t\tnames = append(names, v.String())\n\t\t\t}\n\t\t}\n\t\tif len(names) > 0 {\n\t\t\tm := buildShortNameMap(names)\n\t\t\tfor orig, short := range m {\n\t\t\t\trev[short] = orig\n\t\t\t}\n\t\t}\n\t}\n\treturn rev\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/chat-completions/codex_openai_response_test.go",
    "content": "package chat_completions\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertCodexResponseToOpenAI_StreamSetsModelFromResponseCreated(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\tmodelName := \"gpt-5.3-codex\"\n\n\tout := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"created_at\":1700000000,\"model\":\"gpt-5.3-codex\"}}`), &param)\n\tif len(out) != 0 {\n\t\tt.Fatalf(\"expected no output for response.created, got %d chunks\", len(out))\n\t}\n\n\tout = ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}`), &param)\n\tif len(out) != 1 {\n\t\tt.Fatalf(\"expected 1 chunk, got %d\", len(out))\n\t}\n\n\tgotModel := gjson.Get(out[0], \"model\").String()\n\tif gotModel != modelName {\n\t\tt.Fatalf(\"expected model %q, got %q\", modelName, gotModel)\n\t}\n}\n\nfunc TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.T) {\n\tctx := context.Background()\n\tvar param any\n\n\tmodelName := \"gpt-5.3-codex\"\n\n\tout := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}`), &param)\n\tif len(out) != 1 {\n\t\tt.Fatalf(\"expected 1 chunk, got %d\", len(out))\n\t}\n\n\tgotModel := gjson.Get(out[0], \"model\").String()\n\tif gotModel != modelName {\n\t\tt.Fatalf(\"expected model %q, got %q\", modelName, gotModel)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tCodex,\n\t\tConvertOpenAIRequestToCodex,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertCodexResponseToOpenAI,\n\t\t\tNonStream: ConvertCodexResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/responses/codex_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nfunc ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\n\tinputResult := gjson.GetBytes(rawJSON, \"input\")\n\tif inputResult.Type == gjson.String {\n\t\tinput, _ := sjson.Set(`[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"\"}]}]`, \"0.content.0.text\", inputResult.String())\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"input\", []byte(input))\n\t}\n\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"stream\", true)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"store\", false)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"parallel_tool_calls\", true)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"include\", []string{\"reasoning.encrypted_content\"})\n\t// Codex Responses rejects token limit fields, so strip them out before forwarding.\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"max_output_tokens\")\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"max_completion_tokens\")\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"temperature\")\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"top_p\")\n\tif v := gjson.GetBytes(rawJSON, \"service_tier\"); v.Exists() {\n\t\tif v.String() != \"priority\" {\n\t\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"service_tier\")\n\t\t}\n\t}\n\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"truncation\")\n\trawJSON = applyResponsesCompactionCompatibility(rawJSON)\n\n\t// Delete the user field as it is not supported by the Codex upstream.\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"user\")\n\n\t// Convert role \"system\" to \"developer\" in input array to comply with Codex API requirements.\n\trawJSON = convertSystemRoleToDeveloper(rawJSON)\n\n\treturn rawJSON\n}\n\n// applyResponsesCompactionCompatibility handles OpenAI Responses context_management.compaction\n// for Codex upstream compatibility.\n//\n// Codex /responses currently rejects context_management with:\n// {\"detail\":\"Unsupported parameter: context_management\"}.\n//\n// Compatibility strategy:\n// 1) Remove context_management before forwarding to Codex upstream.\nfunc applyResponsesCompactionCompatibility(rawJSON []byte) []byte {\n\tif !gjson.GetBytes(rawJSON, \"context_management\").Exists() {\n\t\treturn rawJSON\n\t}\n\n\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"context_management\")\n\treturn rawJSON\n}\n\n// convertSystemRoleToDeveloper traverses the input array and converts any message items\n// with role \"system\" to role \"developer\". This is necessary because Codex API does not\n// accept \"system\" role in the input array.\nfunc convertSystemRoleToDeveloper(rawJSON []byte) []byte {\n\tinputResult := gjson.GetBytes(rawJSON, \"input\")\n\tif !inputResult.IsArray() {\n\t\treturn rawJSON\n\t}\n\n\tinputArray := inputResult.Array()\n\tresult := rawJSON\n\n\t// Directly modify role values for items with \"system\" role\n\tfor i := 0; i < len(inputArray); i++ {\n\t\trolePath := fmt.Sprintf(\"input.%d.role\", i)\n\t\tif gjson.GetBytes(result, rolePath).String() == \"system\" {\n\t\t\tresult, _ = sjson.SetBytes(result, rolePath, \"developer\")\n\t\t}\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/responses/codex_openai-responses_request_test.go",
    "content": "package responses\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\n// TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion\nfunc TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": [\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"You are a pirate.\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"Say hello.\"}]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that system role was converted to developer\n\tfirstItemRole := gjson.Get(outputStr, \"input.0.role\")\n\tif firstItemRole.String() != \"developer\" {\n\t\tt.Errorf(\"Expected role 'developer', got '%s'\", firstItemRole.String())\n\t}\n\n\t// Check that user role remains unchanged\n\tsecondItemRole := gjson.Get(outputStr, \"input.1.role\")\n\tif secondItemRole.String() != \"user\" {\n\t\tt.Errorf(\"Expected role 'user', got '%s'\", secondItemRole.String())\n\t}\n\n\t// Check content is preserved\n\tfirstItemContent := gjson.Get(outputStr, \"input.0.content.0.text\")\n\tif firstItemContent.String() != \"You are a pirate.\" {\n\t\tt.Errorf(\"Expected content 'You are a pirate.', got '%s'\", firstItemContent.String())\n\t}\n}\n\n// TestConvertSystemRoleToDeveloper_MultipleSystemMessages tests conversion with multiple system messages\nfunc TestConvertSystemRoleToDeveloper_MultipleSystemMessages(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": [\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"You are helpful.\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"Be concise.\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"Hello\"}]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that both system roles were converted\n\tfirstRole := gjson.Get(outputStr, \"input.0.role\")\n\tif firstRole.String() != \"developer\" {\n\t\tt.Errorf(\"Expected first role 'developer', got '%s'\", firstRole.String())\n\t}\n\n\tsecondRole := gjson.Get(outputStr, \"input.1.role\")\n\tif secondRole.String() != \"developer\" {\n\t\tt.Errorf(\"Expected second role 'developer', got '%s'\", secondRole.String())\n\t}\n\n\t// Check that user role is unchanged\n\tthirdRole := gjson.Get(outputStr, \"input.2.role\")\n\tif thirdRole.String() != \"user\" {\n\t\tt.Errorf(\"Expected third role 'user', got '%s'\", thirdRole.String())\n\t}\n}\n\n// TestConvertSystemRoleToDeveloper_NoSystemMessages tests that requests without system messages are unchanged\nfunc TestConvertSystemRoleToDeveloper_NoSystemMessages(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": [\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"Hello\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [{\"type\": \"output_text\", \"text\": \"Hi there!\"}]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that user and assistant roles are unchanged\n\tfirstRole := gjson.Get(outputStr, \"input.0.role\")\n\tif firstRole.String() != \"user\" {\n\t\tt.Errorf(\"Expected role 'user', got '%s'\", firstRole.String())\n\t}\n\n\tsecondRole := gjson.Get(outputStr, \"input.1.role\")\n\tif secondRole.String() != \"assistant\" {\n\t\tt.Errorf(\"Expected role 'assistant', got '%s'\", secondRole.String())\n\t}\n}\n\n// TestConvertSystemRoleToDeveloper_EmptyInput tests that empty input arrays are handled correctly\nfunc TestConvertSystemRoleToDeveloper_EmptyInput(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": []\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that input is still an empty array\n\tinputArray := gjson.Get(outputStr, \"input\")\n\tif !inputArray.IsArray() {\n\t\tt.Error(\"Input should still be an array\")\n\t}\n\tif len(inputArray.Array()) != 0 {\n\t\tt.Errorf(\"Expected empty array, got %d items\", len(inputArray.Array()))\n\t}\n}\n\n// TestConvertSystemRoleToDeveloper_NoInputField tests that requests without input field are unchanged\nfunc TestConvertSystemRoleToDeveloper_NoInputField(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"stream\": false\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check that other fields are still set correctly\n\tstream := gjson.Get(outputStr, \"stream\")\n\tif !stream.Bool() {\n\t\tt.Error(\"Stream should be set to true by conversion\")\n\t}\n\n\tstore := gjson.Get(outputStr, \"store\")\n\tif store.Bool() {\n\t\tt.Error(\"Store should be set to false by conversion\")\n\t}\n}\n\n// TestConvertOpenAIResponsesRequestToCodex_OriginalIssue tests the exact issue reported by the user\nfunc TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) {\n\t// This is the exact input that was failing with \"System messages are not allowed\"\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": [\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": \"You are a pirate. Always respond in pirate speak.\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": \"Say hello.\"\n\t\t\t}\n\t\t],\n\t\t\"stream\": false\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Verify system role was converted to developer\n\tfirstRole := gjson.Get(outputStr, \"input.0.role\")\n\tif firstRole.String() != \"developer\" {\n\t\tt.Errorf(\"Expected role 'developer', got '%s'\", firstRole.String())\n\t}\n\n\t// Verify stream was set to true (as required by Codex)\n\tstream := gjson.Get(outputStr, \"stream\")\n\tif !stream.Bool() {\n\t\tt.Error(\"Stream should be set to true\")\n\t}\n\n\t// Verify other required fields for Codex\n\tstore := gjson.Get(outputStr, \"store\")\n\tif store.Bool() {\n\t\tt.Error(\"Store should be false\")\n\t}\n\n\tparallelCalls := gjson.Get(outputStr, \"parallel_tool_calls\")\n\tif !parallelCalls.Bool() {\n\t\tt.Error(\"parallel_tool_calls should be true\")\n\t}\n\n\tinclude := gjson.Get(outputStr, \"include\")\n\tif !include.IsArray() || len(include.Array()) != 1 {\n\t\tt.Error(\"include should be an array with one element\")\n\t} else if include.Array()[0].String() != \"reasoning.encrypted_content\" {\n\t\tt.Errorf(\"Expected include[0] to be 'reasoning.encrypted_content', got '%s'\", include.Array()[0].String())\n\t}\n}\n\n// TestConvertSystemRoleToDeveloper_AssistantRole tests that assistant role is preserved\nfunc TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"input\": [\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"You are helpful.\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"input_text\", \"text\": \"Hello\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [{\"type\": \"output_text\", \"text\": \"Hi!\"}]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Check system -> developer\n\tfirstRole := gjson.Get(outputStr, \"input.0.role\")\n\tif firstRole.String() != \"developer\" {\n\t\tt.Errorf(\"Expected first role 'developer', got '%s'\", firstRole.String())\n\t}\n\n\t// Check user unchanged\n\tsecondRole := gjson.Get(outputStr, \"input.1.role\")\n\tif secondRole.String() != \"user\" {\n\t\tt.Errorf(\"Expected second role 'user', got '%s'\", secondRole.String())\n\t}\n\n\t// Check assistant unchanged\n\tthirdRole := gjson.Get(outputStr, \"input.2.role\")\n\tif thirdRole.String() != \"assistant\" {\n\t\tt.Errorf(\"Expected third role 'assistant', got '%s'\", thirdRole.String())\n\t}\n}\n\nfunc TestUserFieldDeletion(t *testing.T) {\n\tinputJSON := []byte(`{  \n\t\t\"model\": \"gpt-5.2\",  \n\t\t\"user\": \"test-user\",  \n\t\t\"input\": [{\"role\": \"user\", \"content\": \"Hello\"}]  \n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\t// Verify user field is deleted\n\tuserField := gjson.Get(outputStr, \"user\")\n\tif userField.Exists() {\n\t\tt.Errorf(\"user field should be deleted, but it was found with value: %s\", userField.Raw)\n\t}\n}\n\nfunc TestContextManagementCompactionCompatibility(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"context_management\": [\n\t\t\t{\n\t\t\t\t\"type\": \"compaction\",\n\t\t\t\t\"compact_threshold\": 12000\n\t\t\t}\n\t\t],\n\t\t\"input\": [{\"role\":\"user\",\"content\":\"hello\"}]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif gjson.Get(outputStr, \"context_management\").Exists() {\n\t\tt.Fatalf(\"context_management should be removed for Codex compatibility\")\n\t}\n\tif gjson.Get(outputStr, \"truncation\").Exists() {\n\t\tt.Fatalf(\"truncation should be removed for Codex compatibility\")\n\t}\n}\n\nfunc TestTruncationRemovedForCodexCompatibility(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gpt-5.2\",\n\t\t\"truncation\": \"disabled\",\n\t\t\"input\": [{\"role\":\"user\",\"content\":\"hello\"}]\n\t}`)\n\n\toutput := ConvertOpenAIResponsesRequestToCodex(\"gpt-5.2\", inputJSON, false)\n\toutputStr := string(output)\n\n\tif gjson.Get(outputStr, \"truncation\").Exists() {\n\t\tt.Fatalf(\"truncation should be removed for Codex compatibility\")\n\t}\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/responses/codex_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\n// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks\n// to OpenAI Responses SSE events (response.*).\n\nfunc ConvertCodexResponseToOpenAIResponses(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []string {\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t\tout := fmt.Sprintf(\"data: %s\", string(rawJSON))\n\t\treturn []string{out}\n\t}\n\treturn []string{string(rawJSON)}\n}\n\n// ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON\n// from a non-streaming OpenAI Chat Completions response.\nfunc ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) string {\n\trootResult := gjson.ParseBytes(rawJSON)\n\t// Verify this is a response.completed event\n\tif rootResult.Get(\"type\").String() != \"response.completed\" {\n\t\treturn \"\"\n\t}\n\tresponseResult := rootResult.Get(\"response\")\n\treturn responseResult.Raw\n}\n"
  },
  {
    "path": "internal/translator/codex/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tCodex,\n\t\tConvertOpenAIResponsesRequestToCodex,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertCodexResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertCodexResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini/claude/gemini_claude_request.go",
    "content": "// Package claude provides request translation functionality for Claude API.\n// It handles parsing and transforming Claude API requests into the internal client format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package also performs JSON data cleaning and transformation to ensure compatibility\n// between Claude API format and the internal client's expected format.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiClaudeThoughtSignature = \"skip_thought_signature_validator\"\n\n// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete\n// Gemini CLI request body (as JSON bytes) ready to be sent via SendRawMessageStream.\n// All JSON transformations are performed using gjson/sjson.\n//\n// Parameters:\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON request from the Claude API.\n//   - stream: A boolean indicating if the request is for a streaming response.\n//\n// Returns:\n//   - []byte: The transformed request in Gemini CLI format.\nfunc ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\trawJSON = bytes.Replace(rawJSON, []byte(`\"url\":{\"type\":\"string\",\"format\":\"uri\",`), []byte(`\"url\":{\"type\":\"string\",`), -1)\n\n\t// Build output Gemini CLI request JSON\n\tout := `{\"contents\":[]}`\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// system instruction\n\tif systemResult := gjson.GetBytes(rawJSON, \"system\"); systemResult.IsArray() {\n\t\tsystemInstruction := `{\"role\":\"user\",\"parts\":[]}`\n\t\thasSystemParts := false\n\t\tsystemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {\n\t\t\tif systemPromptResult.Get(\"type\").String() == \"text\" {\n\t\t\t\ttextResult := systemPromptResult.Get(\"text\")\n\t\t\t\tif textResult.Type == gjson.String {\n\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", textResult.String())\n\t\t\t\t\tsystemInstruction, _ = sjson.SetRaw(systemInstruction, \"parts.-1\", part)\n\t\t\t\t\thasSystemParts = true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif hasSystemParts {\n\t\t\tout, _ = sjson.SetRaw(out, \"system_instruction\", systemInstruction)\n\t\t}\n\t} else if systemResult.Type == gjson.String {\n\t\tout, _ = sjson.Set(out, \"system_instruction.parts.-1.text\", systemResult.String())\n\t}\n\n\t// contents\n\tif messagesResult := gjson.GetBytes(rawJSON, \"messages\"); messagesResult.IsArray() {\n\t\tmessagesResult.ForEach(func(_, messageResult gjson.Result) bool {\n\t\t\troleResult := messageResult.Get(\"role\")\n\t\t\tif roleResult.Type != gjson.String {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\trole := roleResult.String()\n\t\t\tif role == \"assistant\" {\n\t\t\t\trole = \"model\"\n\t\t\t}\n\n\t\t\tcontentJSON := `{\"role\":\"\",\"parts\":[]}`\n\t\t\tcontentJSON, _ = sjson.Set(contentJSON, \"role\", role)\n\n\t\t\tcontentsResult := messageResult.Get(\"content\")\n\t\t\tif contentsResult.IsArray() {\n\t\t\t\tcontentsResult.ForEach(func(_, contentResult gjson.Result) bool {\n\t\t\t\t\tswitch contentResult.Get(\"type\").String() {\n\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentResult.Get(\"text\").String())\n\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\n\t\t\t\t\tcase \"tool_use\":\n\t\t\t\t\t\tfunctionName := contentResult.Get(\"name\").String()\n\t\t\t\t\t\tif toolUseID := contentResult.Get(\"id\").String(); toolUseID != \"\" {\n\t\t\t\t\t\t\tif derived := toolNameFromClaudeToolUseID(toolUseID); derived != \"\" {\n\t\t\t\t\t\t\t\tfunctionName = derived\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunctionArgs := contentResult.Get(\"input\").String()\n\t\t\t\t\t\targsResult := gjson.Parse(functionArgs)\n\t\t\t\t\t\tif argsResult.IsObject() && gjson.Valid(functionArgs) {\n\t\t\t\t\t\t\tpart := `{\"thoughtSignature\":\"\",\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"thoughtSignature\", geminiClaudeThoughtSignature)\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionCall.name\", functionName)\n\t\t\t\t\t\t\tpart, _ = sjson.SetRaw(part, \"functionCall.args\", functionArgs)\n\t\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool_result\":\n\t\t\t\t\t\ttoolCallID := contentResult.Get(\"tool_use_id\").String()\n\t\t\t\t\t\tif toolCallID == \"\" {\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfuncName := toolNameFromClaudeToolUseID(toolCallID)\n\t\t\t\t\t\tif funcName == \"\" {\n\t\t\t\t\t\t\tfuncName = toolCallID\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresponseData := contentResult.Get(\"content\").Raw\n\t\t\t\t\t\tpart := `{\"functionResponse\":{\"name\":\"\",\"response\":{\"result\":\"\"}}}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionResponse.name\", funcName)\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionResponse.response.result\", responseData)\n\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\n\t\t\t\t\tcase \"image\":\n\t\t\t\t\t\tsource := contentResult.Get(\"source\")\n\t\t\t\t\t\tif source.Get(\"type\").String() != \"base64\" {\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmimeType := source.Get(\"media_type\").String()\n\t\t\t\t\t\tdata := source.Get(\"data\").String()\n\t\t\t\t\t\tif mimeType == \"\" || data == \"\" {\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpart := `{\"inline_data\":{\"mime_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"inline_data.mime_type\", mimeType)\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"inline_data.data\", data)\n\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", contentJSON)\n\t\t\t} else if contentsResult.Type == gjson.String {\n\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentsResult.String())\n\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", contentJSON)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// tools\n\tif toolsResult := gjson.GetBytes(rawJSON, \"tools\"); toolsResult.IsArray() {\n\t\thasTools := false\n\t\ttoolsResult.ForEach(func(_, toolResult gjson.Result) bool {\n\t\t\tinputSchemaResult := toolResult.Get(\"input_schema\")\n\t\t\tif inputSchemaResult.Exists() && inputSchemaResult.IsObject() {\n\t\t\t\tinputSchema := inputSchemaResult.Raw\n\t\t\t\ttool, _ := sjson.Delete(toolResult.Raw, \"input_schema\")\n\t\t\t\ttool, _ = sjson.SetRaw(tool, \"parametersJsonSchema\", inputSchema)\n\t\t\t\ttool, _ = sjson.Delete(tool, \"strict\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"input_examples\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"type\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"cache_control\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"defer_loading\")\n\t\t\t\tif gjson.Valid(tool) && gjson.Parse(tool).IsObject() {\n\t\t\t\t\tif !hasTools {\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tools\", `[{\"functionDeclarations\":[]}]`)\n\t\t\t\t\t\thasTools = true\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.0.functionDeclarations.-1\", tool)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif !hasTools {\n\t\t\tout, _ = sjson.Delete(out, \"tools\")\n\t\t}\n\t}\n\n\t// tool_choice\n\ttoolChoiceResult := gjson.GetBytes(rawJSON, \"tool_choice\")\n\tif toolChoiceResult.Exists() {\n\t\ttoolChoiceType := \"\"\n\t\ttoolChoiceName := \"\"\n\t\tif toolChoiceResult.IsObject() {\n\t\t\ttoolChoiceType = toolChoiceResult.Get(\"type\").String()\n\t\t\ttoolChoiceName = toolChoiceResult.Get(\"name\").String()\n\t\t} else if toolChoiceResult.Type == gjson.String {\n\t\t\ttoolChoiceType = toolChoiceResult.String()\n\t\t}\n\n\t\tswitch toolChoiceType {\n\t\tcase \"auto\":\n\t\t\tout, _ = sjson.Set(out, \"toolConfig.functionCallingConfig.mode\", \"AUTO\")\n\t\tcase \"none\":\n\t\t\tout, _ = sjson.Set(out, \"toolConfig.functionCallingConfig.mode\", \"NONE\")\n\t\tcase \"any\":\n\t\t\tout, _ = sjson.Set(out, \"toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\tcase \"tool\":\n\t\t\tout, _ = sjson.Set(out, \"toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\t\tif toolChoiceName != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"toolConfig.functionCallingConfig.allowedFunctionNames\", []string{toolChoiceName})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Map Anthropic thinking -> Gemini thinking config when enabled\n\t// Translator only does format conversion, ApplyThinking handles model capability validation.\n\tif t := gjson.GetBytes(rawJSON, \"thinking\"); t.Exists() && t.IsObject() {\n\t\tswitch t.Get(\"type\").String() {\n\t\tcase \"enabled\":\n\t\t\tif b := t.Get(\"budget_tokens\"); b.Exists() && b.Type == gjson.Number {\n\t\t\t\tbudget := int(b.Int())\n\t\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t\t}\n\t\tcase \"adaptive\", \"auto\":\n\t\t\t// For adaptive thinking:\n\t\t\t// - If output_config.effort is explicitly present, pass through as thinkingLevel.\n\t\t\t// - Otherwise, treat it as \"enabled with target-model maximum\" and emit thinkingBudget=max.\n\t\t\t// ApplyThinking handles clamping to target model's supported levels.\n\t\t\teffort := \"\"\n\t\t\tif v := gjson.GetBytes(rawJSON, \"output_config.effort\"); v.Exists() && v.Type == gjson.String {\n\t\t\t\teffort = strings.ToLower(strings.TrimSpace(v.String()))\n\t\t\t}\n\t\t\tif effort != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.thinkingLevel\", effort)\n\t\t\t} else {\n\t\t\t\tmaxBudget := 0\n\t\t\t\tif mi := registry.LookupModelInfo(modelName, \"gemini\"); mi != nil && mi.Thinking != nil {\n\t\t\t\t\tmaxBudget = mi.Thinking.Max\n\t\t\t\t}\n\t\t\t\tif maxBudget > 0 {\n\t\t\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.thinkingBudget\", maxBudget)\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.thinkingLevel\", \"high\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tout, _ = sjson.Set(out, \"generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t}\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"temperature\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"generationConfig.temperature\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_p\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"generationConfig.topP\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_k\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"generationConfig.topK\", v.Num)\n\t}\n\n\tresult := []byte(out)\n\tresult = common.AttachDefaultSafetySettings(result, \"safetySettings\")\n\n\treturn result\n}\n\nfunc toolNameFromClaudeToolUseID(toolUseID string) string {\n\tparts := strings.Split(toolUseID, \"-\")\n\tif len(parts) <= 1 {\n\t\treturn \"\"\n\t}\n\treturn strings.Join(parts[0:len(parts)-1], \"-\")\n}\n"
  },
  {
    "path": "internal/translator/gemini/claude/gemini_claude_request_test.go",
    "content": "package claude\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-flash-preview\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"hi\"}\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"json\",\n\t\t\t\t\"description\": \"A JSON tool\",\n\t\t\t\t\"input_schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {}\n\t\t\t\t}\n\t\t\t}\n\t\t],\n\t\t\"tool_choice\": {\"type\": \"tool\", \"name\": \"json\"}\n\t}`)\n\n\toutput := ConvertClaudeRequestToGemini(\"gemini-3-flash-preview\", inputJSON, false)\n\n\tif got := gjson.GetBytes(output, \"toolConfig.functionCallingConfig.mode\").String(); got != \"ANY\" {\n\t\tt.Fatalf(\"Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'\", got)\n\t}\n\tallowed := gjson.GetBytes(output, \"toolConfig.functionCallingConfig.allowedFunctionNames\").Array()\n\tif len(allowed) != 1 || allowed[0].String() != \"json\" {\n\t\tt.Fatalf(\"Expected allowedFunctionNames ['json'], got %s\", gjson.GetBytes(output, \"toolConfig.functionCallingConfig.allowedFunctionNames\").Raw)\n\t}\n}\n\nfunc TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-flash-preview\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"describe this image\"},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\"type\": \"base64\",\n\t\t\t\t\t\t\t\"media_type\": \"image/png\",\n\t\t\t\t\t\t\t\"data\": \"aGVsbG8=\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\toutput := ConvertClaudeRequestToGemini(\"gemini-3-flash-preview\", inputJSON, false)\n\n\tparts := gjson.GetBytes(output, \"contents.0.parts\").Array()\n\tif len(parts) != 2 {\n\t\tt.Fatalf(\"Expected 2 parts, got %d\", len(parts))\n\t}\n\tif got := parts[0].Get(\"text\").String(); got != \"describe this image\" {\n\t\tt.Fatalf(\"Expected first part text 'describe this image', got '%s'\", got)\n\t}\n\tif got := parts[1].Get(\"inline_data.mime_type\").String(); got != \"image/png\" {\n\t\tt.Fatalf(\"Expected image mime type 'image/png', got '%s'\", got)\n\t}\n\tif got := parts[1].Get(\"inline_data.data\").String(); got != \"aGVsbG8=\" {\n\t\tt.Fatalf(\"Expected image data 'aGVsbG8=', got '%s'\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/gemini/claude/gemini_claude_response.go",
    "content": "// Package claude provides response translation functionality for Claude API.\n// This package handles the conversion of backend client responses into Claude-compatible\n// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages\n// different response types including text content, thinking processes, and function calls.\n// The translation ensures proper sequencing of SSE events and maintains state across\n// multiple response chunks to provide a seamless streaming experience.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Params holds parameters for response conversion.\ntype Params struct {\n\tIsGlAPIKey       bool\n\tHasFirstResponse bool\n\tResponseType     int\n\tResponseIndex    int\n\tHasContent       bool // Tracks whether any content (text, thinking, or tool use) has been output\n\tToolNameMap      map[string]string\n\tSawToolCall      bool\n}\n\n// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.\nvar toolUseIDCounter uint64\n\n// ConvertGeminiResponseToClaude performs sophisticated streaming response format conversion.\n// This function implements a complex state machine that translates backend client responses\n// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types\n// and handles state transitions between content blocks, thinking processes, and function calls.\n//\n// Response type states: 0=none, 1=content, 2=thinking, 3=function\n// The function maintains state across multiple calls to ensure proper SSE event sequencing.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Claude-compatible JSON response.\nfunc ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &Params{\n\t\t\tIsGlAPIKey:       false,\n\t\t\tHasFirstResponse: false,\n\t\t\tResponseType:     0,\n\t\t\tResponseIndex:    0,\n\t\t\tToolNameMap:      util.ToolNameMapFromClaudeRequest(originalRequestRawJSON),\n\t\t\tSawToolCall:      false,\n\t\t}\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\t// Only send message_stop if we have actually output content\n\t\tif (*param).(*Params).HasContent {\n\t\t\treturn []string{\n\t\t\t\t\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\\n\",\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\t}\n\n\toutput := \"\"\n\n\t// Initialize the streaming session with a message_start event\n\t// This is only sent for the very first response chunk\n\tif !(*param).(*Params).HasFirstResponse {\n\t\toutput = \"event: message_start\\n\"\n\n\t\t// Create the initial message structure with default values\n\t\t// This follows the Claude API specification for streaming message initialization\n\t\tmessageStartTemplate := `{\"type\": \"message_start\", \"message\": {\"id\": \"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [], \"model\": \"claude-3-5-sonnet-20241022\", \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 0, \"output_tokens\": 0}}}`\n\n\t\t// Override default values with actual response metadata if available\n\t\tif modelVersionResult := gjson.GetBytes(rawJSON, \"modelVersion\"); modelVersionResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.model\", modelVersionResult.String())\n\t\t}\n\t\tif responseIDResult := gjson.GetBytes(rawJSON, \"responseId\"); responseIDResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.id\", responseIDResult.String())\n\t\t}\n\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", messageStartTemplate)\n\n\t\t(*param).(*Params).HasFirstResponse = true\n\t}\n\n\t// Process the response parts array from the backend client\n\t// Each part can contain text content, thinking content, or function calls\n\tpartsResult := gjson.GetBytes(rawJSON, \"candidates.0.content.parts\")\n\tif partsResult.IsArray() {\n\t\tpartResults := partsResult.Array()\n\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\tpartResult := partResults[i]\n\n\t\t\t// Extract the different types of content from each part\n\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\n\t\t\t// Handle text content (both regular content and thinking)\n\t\t\tif partTextResult.Exists() {\n\t\t\t\t// Process thinking content (internal reasoning)\n\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\t// Continue existing thinking block\n\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition from another state to thinking\n\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Start a new thinking content block\n\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).ResponseType = 2 // Set state to thinking\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Process regular text content (user-visible output)\n\t\t\t\t\t// Continue existing text block\n\t\t\t\t\tif (*param).(*Params).ResponseType == 1 {\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition from another state to text content\n\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Start a new text content block\n\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).ResponseType = 1 // Set state to content\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t// Handle function/tool calls from the AI model\n\t\t\t\t// This processes tool usage requests and formats them for Claude API compatibility\n\t\t\t\t(*param).(*Params).SawToolCall = true\n\t\t\t\tupstreamToolName := functionCallResult.Get(\"name\").String()\n\t\t\t\tclientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)\n\n\t\t\t\t// FIX: Handle streaming split/delta where name might be empty in subsequent chunks.\n\t\t\t\t// If we are already in tool use mode and name is empty, treat as continuation (delta).\n\t\t\t\tif (*param).(*Params).ResponseType == 3 && upstreamToolName == \"\" {\n\t\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.partial_json\", fcArgsResult.Raw)\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t}\n\t\t\t\t\t// Continue to next part without closing/opening logic\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Handle state transitions when switching to function calls\n\t\t\t\t// Close any existing function call block first\n\t\t\t\tif (*param).(*Params).ResponseType == 3 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t(*param).(*Params).ResponseType = 0\n\t\t\t\t}\n\n\t\t\t\t// Special handling for thinking state transition\n\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t}\n\n\t\t\t\t// Close any other existing content block\n\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t}\n\n\t\t\t\t// Start a new tool use content block\n\t\t\t\t// This creates the structure for a function call in Claude format\n\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\n\t\t\t\t// Create the tool use block with unique ID and function details\n\t\t\t\tdata := fmt.Sprintf(`{\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.id\", util.SanitizeClaudeToolID(fmt.Sprintf(\"%s-%d\", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.name\", clientToolName)\n\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\n\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\tdata, _ = sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.partial_json\", fcArgsResult.Raw)\n\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t}\n\t\t\t\t(*param).(*Params).ResponseType = 3\n\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t}\n\t\t}\n\t}\n\n\tusageResult := gjson.GetBytes(rawJSON, \"usageMetadata\")\n\tif usageResult.Exists() && bytes.Contains(rawJSON, []byte(`\"finishReason\"`)) {\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\t// Only send final events if we have actually output content\n\t\t\tif (*param).(*Params).HasContent {\n\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\toutput = output + \"\\n\\n\\n\"\n\n\t\t\t\toutput = output + \"event: message_delta\\n\"\n\t\t\t\toutput = output + `data: `\n\n\t\t\t\ttemplate := `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\tif (*param).(*Params).SawToolCall {\n\t\t\t\t\ttemplate = `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\t} else if finish := gjson.GetBytes(rawJSON, \"candidates.0.finishReason\"); finish.Exists() && finish.String() == \"MAX_TOKENS\" {\n\t\t\t\t\ttemplate = `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\t}\n\n\t\t\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usage.output_tokens\", candidatesTokenCountResult.Int()+thoughtsTokenCount)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usage.input_tokens\", usageResult.Get(\"promptTokenCount\").Int())\n\n\t\t\t\toutput = output + template + \"\\n\\n\\n\"\n\t\t\t}\n\t\t}\n\t}\n\n\treturn []string{output}\n}\n\n// ConvertGeminiResponseToClaudeNonStream converts a non-streaming Gemini response to a non-streaming Claude response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: A Claude-compatible JSON response.\nfunc ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t_ = requestRawJSON\n\n\troot := gjson.ParseBytes(rawJSON)\n\ttoolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)\n\n\tout := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tout, _ = sjson.Set(out, \"id\", root.Get(\"responseId\").String())\n\tout, _ = sjson.Set(out, \"model\", root.Get(\"modelVersion\").String())\n\n\tinputTokens := root.Get(\"usageMetadata.promptTokenCount\").Int()\n\toutputTokens := root.Get(\"usageMetadata.candidatesTokenCount\").Int() + root.Get(\"usageMetadata.thoughtsTokenCount\").Int()\n\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\n\tparts := root.Get(\"candidates.0.content.parts\")\n\ttextBuilder := strings.Builder{}\n\tthinkingBuilder := strings.Builder{}\n\ttoolIDCounter := 0\n\thasToolCall := false\n\n\tflushText := func() {\n\t\tif textBuilder.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"text\", textBuilder.String())\n\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\ttextBuilder.Reset()\n\t}\n\n\tflushThinking := func() {\n\t\tif thinkingBuilder.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"thinking\", thinkingBuilder.String())\n\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\tthinkingBuilder.Reset()\n\t}\n\n\tif parts.IsArray() {\n\t\tfor _, part := range parts.Array() {\n\t\t\tif text := part.Get(\"text\"); text.Exists() && text.String() != \"\" {\n\t\t\t\tif part.Get(\"thought\").Bool() {\n\t\t\t\t\tflushText()\n\t\t\t\t\tthinkingBuilder.WriteString(text.String())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tflushThinking()\n\t\t\t\ttextBuilder.WriteString(text.String())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif functionCall := part.Get(\"functionCall\"); functionCall.Exists() {\n\t\t\t\tflushThinking()\n\t\t\t\tflushText()\n\t\t\t\thasToolCall = true\n\n\t\t\t\tupstreamToolName := functionCall.Get(\"name\").String()\n\t\t\t\tclientToolName := util.MapToolName(toolNameMap, upstreamToolName)\n\t\t\t\ttoolIDCounter++\n\t\t\t\ttoolBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"id\", util.SanitizeClaudeToolID(fmt.Sprintf(\"%s-%d\", upstreamToolName, toolIDCounter)))\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"name\", clientToolName)\n\t\t\t\tinputRaw := \"{}\"\n\t\t\t\tif args := functionCall.Get(\"args\"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {\n\t\t\t\t\tinputRaw = args.Raw\n\t\t\t\t}\n\t\t\t\ttoolBlock, _ = sjson.SetRaw(toolBlock, \"input\", inputRaw)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolBlock)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\tflushThinking()\n\tflushText()\n\n\tstopReason := \"end_turn\"\n\tif hasToolCall {\n\t\tstopReason = \"tool_use\"\n\t} else {\n\t\tif finish := root.Get(\"candidates.0.finishReason\"); finish.Exists() {\n\t\t\tswitch finish.String() {\n\t\t\tcase \"MAX_TOKENS\":\n\t\t\t\tstopReason = \"max_tokens\"\n\t\t\tcase \"STOP\", \"FINISH_REASON_UNSPECIFIED\", \"UNKNOWN\":\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\tdefault:\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\t}\n\t\t}\n\t}\n\tout, _ = sjson.Set(out, \"stop_reason\", stopReason)\n\n\tif inputTokens == int64(0) && outputTokens == int64(0) && !root.Get(\"usageMetadata\").Exists() {\n\t\tout, _ = sjson.Delete(out, \"usage\")\n\t}\n\n\treturn out\n}\n\nfunc ClaudeTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"input_tokens\":%d}`, count)\n}\n"
  },
  {
    "path": "internal/translator/gemini/claude/init.go",
    "content": "package claude\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tClaude,\n\t\tGemini,\n\t\tConvertClaudeRequestToGemini,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertGeminiResponseToClaude,\n\t\t\tNonStream:  ConvertGeminiResponseToClaudeNonStream,\n\t\t\tTokenCount: ClaudeTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini/common/safety.go",
    "content": "package common\n\nimport (\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// DefaultSafetySettings returns the default Gemini safety configuration we attach to requests.\nfunc DefaultSafetySettings() []map[string]string {\n\treturn []map[string]string{\n\t\t{\n\t\t\t\"category\":  \"HARM_CATEGORY_HARASSMENT\",\n\t\t\t\"threshold\": \"OFF\",\n\t\t},\n\t\t{\n\t\t\t\"category\":  \"HARM_CATEGORY_HATE_SPEECH\",\n\t\t\t\"threshold\": \"OFF\",\n\t\t},\n\t\t{\n\t\t\t\"category\":  \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n\t\t\t\"threshold\": \"OFF\",\n\t\t},\n\t\t{\n\t\t\t\"category\":  \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n\t\t\t\"threshold\": \"OFF\",\n\t\t},\n\t\t{\n\t\t\t\"category\":  \"HARM_CATEGORY_CIVIC_INTEGRITY\",\n\t\t\t\"threshold\": \"BLOCK_NONE\",\n\t\t},\n\t}\n}\n\n// AttachDefaultSafetySettings ensures the default safety settings are present when absent.\n// The caller must provide the target JSON path (e.g. \"safetySettings\" or \"request.safetySettings\").\nfunc AttachDefaultSafetySettings(rawJSON []byte, path string) []byte {\n\tif gjson.GetBytes(rawJSON, path).Exists() {\n\t\treturn rawJSON\n\t}\n\n\tout, err := sjson.SetBytes(rawJSON, path, DefaultSafetySettings())\n\tif err != nil {\n\t\treturn rawJSON\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini/gemini_gemini_request.go",
    "content": "// Package gemini provides in-provider request normalization for Gemini API.\n// It ensures incoming v1beta requests meet minimal schema requirements\n// expected by Google's Generative Language API.\npackage gemini\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiRequestToGemini normalizes Gemini v1beta requests.\n//   - Adds a default role for each content if missing or invalid.\n//     The first message defaults to \"user\", then alternates user/model when needed.\n//\n// It keeps the payload otherwise unchanged.\nfunc ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\t// Fast path: if no contents field, only attach safety settings\n\tcontents := gjson.GetBytes(rawJSON, \"contents\")\n\tif !contents.Exists() {\n\t\treturn common.AttachDefaultSafetySettings(rawJSON, \"safetySettings\")\n\t}\n\n\ttoolsResult := gjson.GetBytes(rawJSON, \"tools\")\n\tif toolsResult.Exists() && toolsResult.IsArray() {\n\t\ttoolResults := toolsResult.Array()\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\tif gjson.GetBytes(rawJSON, fmt.Sprintf(\"tools.%d.functionDeclarations\", i)).Exists() {\n\t\t\t\tstrJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf(\"tools.%d.functionDeclarations\", i), fmt.Sprintf(\"tools.%d.function_declarations\", i))\n\t\t\t\trawJSON = []byte(strJson)\n\t\t\t}\n\n\t\t\tfunctionDeclarationsResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"tools.%d.function_declarations\", i))\n\t\t\tif functionDeclarationsResult.Exists() && functionDeclarationsResult.IsArray() {\n\t\t\t\tfunctionDeclarationsResults := functionDeclarationsResult.Array()\n\t\t\t\tfor j := 0; j < len(functionDeclarationsResults); j++ {\n\t\t\t\t\tparametersResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"tools.%d.function_declarations.%d.parameters\", i, j))\n\t\t\t\t\tif parametersResult.Exists() {\n\t\t\t\t\t\tstrJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf(\"tools.%d.function_declarations.%d.parameters\", i, j), fmt.Sprintf(\"tools.%d.function_declarations.%d.parametersJsonSchema\", i, j))\n\t\t\t\t\t\trawJSON = []byte(strJson)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Walk contents and fix roles\n\tout := rawJSON\n\tprevRole := \"\"\n\tidx := 0\n\tcontents.ForEach(func(_ gjson.Result, value gjson.Result) bool {\n\t\trole := value.Get(\"role\").String()\n\n\t\t// Only user/model are valid for Gemini v1beta requests\n\t\tvalid := role == \"user\" || role == \"model\"\n\t\tif role == \"\" || !valid {\n\t\t\tvar newRole string\n\t\t\tif prevRole == \"\" {\n\t\t\t\tnewRole = \"user\"\n\t\t\t} else if prevRole == \"user\" {\n\t\t\t\tnewRole = \"model\"\n\t\t\t} else {\n\t\t\t\tnewRole = \"user\"\n\t\t\t}\n\t\t\tpath := fmt.Sprintf(\"contents.%d.role\", idx)\n\t\t\tout, _ = sjson.SetBytes(out, path, newRole)\n\t\t\trole = newRole\n\t\t}\n\n\t\tprevRole = role\n\t\tidx++\n\t\treturn true\n\t})\n\n\tgjson.GetBytes(out, \"contents\").ForEach(func(key, content gjson.Result) bool {\n\t\tif content.Get(\"role\").String() == \"model\" {\n\t\t\tcontent.Get(\"parts\").ForEach(func(partKey, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t} else if part.Get(\"thoughtSignature\").Exists() {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t\treturn true\n\t})\n\n\tif gjson.GetBytes(rawJSON, \"generationConfig.responseSchema\").Exists() {\n\t\tstrJson, _ := util.RenameKey(string(out), \"generationConfig.responseSchema\", \"generationConfig.responseJsonSchema\")\n\t\tout = []byte(strJson)\n\t}\n\n\t// Backfill empty functionResponse.name from the preceding functionCall.name.\n\t// Amp may send function responses with empty names; the Gemini API rejects these.\n\tout = backfillEmptyFunctionResponseNames(out)\n\n\tout = common.AttachDefaultSafetySettings(out, \"safetySettings\")\n\treturn out\n}\n\n// backfillEmptyFunctionResponseNames walks the contents array and for each\n// model turn containing functionCall parts, records the call names in order.\n// For the immediately following user/function turn containing functionResponse\n// parts, any empty name is replaced with the corresponding call name.\nfunc backfillEmptyFunctionResponseNames(data []byte) []byte {\n\tcontents := gjson.GetBytes(data, \"contents\")\n\tif !contents.Exists() {\n\t\treturn data\n\t}\n\n\tout := data\n\tvar pendingCallNames []string\n\n\tcontents.ForEach(func(contentIdx, content gjson.Result) bool {\n\t\trole := content.Get(\"role\").String()\n\n\t\t// Collect functionCall names from model turns\n\t\tif role == \"model\" {\n\t\t\tvar names []string\n\t\t\tcontent.Get(\"parts\").ForEach(func(_, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\tnames = append(names, part.Get(\"functionCall.name\").String())\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif len(names) > 0 {\n\t\t\t\tpendingCallNames = names\n\t\t\t} else {\n\t\t\t\tpendingCallNames = nil\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\n\t\t// Backfill empty functionResponse names from pending call names\n\t\tif len(pendingCallNames) > 0 {\n\t\t\tri := 0\n\t\t\tcontent.Get(\"parts\").ForEach(func(partIdx, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionResponse\").Exists() {\n\t\t\t\t\tname := part.Get(\"functionResponse.name\").String()\n\t\t\t\t\tif strings.TrimSpace(name) == \"\" {\n\t\t\t\t\t\tif ri < len(pendingCallNames) {\n\t\t\t\t\t\t\tout, _ = sjson.SetBytes(out,\n\t\t\t\t\t\t\t\tfmt.Sprintf(\"contents.%d.parts.%d.functionResponse.name\", contentIdx.Int(), partIdx.Int()),\n\t\t\t\t\t\t\t\tpendingCallNames[ri])\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tlog.Debugf(\"more function responses than calls at contents[%d], skipping name backfill\", contentIdx.Int())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tri++\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tpendingCallNames = nil\n\t\t}\n\n\t\treturn true\n\t})\n\n\treturn out\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini/gemini_gemini_request_test.go",
    "content": "package gemini\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {\"cmd\": \"ls\"}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"output\": \"file1.txt\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := backfillEmptyFunctionResponseNames(input)\n\n\tname := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tif name != \"Bash\" {\n\t\tt.Errorf(\"Expected backfilled name 'Bash', got '%s'\", name)\n\t}\n}\n\nfunc TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Read\", \"args\": {\"path\": \"/a\"}}},\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Grep\", \"args\": {\"pattern\": \"x\"}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"content a\"}}},\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"match x\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := backfillEmptyFunctionResponseNames(input)\n\n\tname0 := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tname1 := gjson.GetBytes(out, \"contents.1.parts.1.functionResponse.name\").String()\n\tif name0 != \"Read\" {\n\t\tt.Errorf(\"Expected first name 'Read', got '%s'\", name0)\n\t}\n\tif name1 != \"Grep\" {\n\t\tt.Errorf(\"Expected second name 'Grep', got '%s'\", name1)\n\t}\n}\n\nfunc TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"Bash\", \"response\": {\"result\": \"ok\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := backfillEmptyFunctionResponseNames(input)\n\n\tname := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tif name != \"Bash\" {\n\t\tt.Errorf(\"Expected preserved name 'Bash', got '%s'\", name)\n\t}\n}\n\nfunc TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) {\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {\"cmd\": \"ls\"}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"output\": \"file1.txt\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := ConvertGeminiRequestToGemini(\"\", input, false)\n\n\tname := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tif name != \"Bash\" {\n\t\tt.Errorf(\"Expected backfilled name 'Bash', got '%s'\", name)\n\t}\n}\n\nfunc TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T) {\n\t// Extra responses beyond the call count should not panic and should be left unchanged.\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Bash\", \"args\": {}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"ok\"}}},\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"extra\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := backfillEmptyFunctionResponseNames(input)\n\n\tname0 := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tif name0 != \"Bash\" {\n\t\tt.Errorf(\"Expected first name 'Bash', got '%s'\", name0)\n\t}\n\t// Second response has no matching call, should remain empty\n\tname1 := gjson.GetBytes(out, \"contents.1.parts.1.functionResponse.name\").String()\n\tif name1 != \"\" {\n\t\tt.Errorf(\"Expected second name to remain empty, got '%s'\", name1)\n\t}\n}\n\nfunc TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) {\n\t// Two sequential call/response groups should each get correct names.\n\tinput := []byte(`{\n\t\t\"contents\": [\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Read\", \"args\": {}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"content\"}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionCall\": {\"name\": \"Grep\", \"args\": {}}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": [\n\t\t\t\t\t{\"functionResponse\": {\"name\": \"\", \"response\": {\"result\": \"match\"}}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`)\n\n\tout := backfillEmptyFunctionResponseNames(input)\n\n\tname0 := gjson.GetBytes(out, \"contents.1.parts.0.functionResponse.name\").String()\n\tname1 := gjson.GetBytes(out, \"contents.3.parts.0.functionResponse.name\").String()\n\tif name0 != \"Read\" {\n\t\tt.Errorf(\"Expected first group name 'Read', got '%s'\", name0)\n\t}\n\tif name1 != \"Grep\" {\n\t\tt.Errorf(\"Expected second group name 'Grep', got '%s'\", name1)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini/gemini_gemini_response.go",
    "content": "package gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n)\n\n// PassthroughGeminiResponseStream forwards Gemini responses unchanged.\nfunc PassthroughGeminiResponseStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string {\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\treturn []string{string(rawJSON)}\n}\n\n// PassthroughGeminiResponseNonStream forwards Gemini responses unchanged.\nfunc PassthroughGeminiResponseNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\treturn string(rawJSON)\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\n// Register a no-op response translator and a request normalizer for Gemini→Gemini.\n// The request converter ensures missing or invalid roles are normalized to valid values.\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tGemini,\n\t\tConvertGeminiRequestToGemini,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     PassthroughGeminiResponseStream,\n\t\t\tNonStream:  PassthroughGeminiResponseNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go",
    "content": "// Package gemini provides request translation functionality for Claude API.\n// It handles parsing and transforming Claude API requests into the internal client format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package also performs JSON data cleaning and transformation to ensure compatibility\n// between Claude API format and the internal client's expected format.\npackage geminiCLI\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// PrepareClaudeRequest parses and transforms a Claude API request into internal client format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the internal client.\nfunc ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\tmodelResult := gjson.GetBytes(rawJSON, \"model\")\n\trawJSON = []byte(gjson.GetBytes(rawJSON, \"request\").Raw)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"model\", modelResult.String())\n\tif gjson.GetBytes(rawJSON, \"systemInstruction\").Exists() {\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"system_instruction\", []byte(gjson.GetBytes(rawJSON, \"systemInstruction\").Raw))\n\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"systemInstruction\")\n\t}\n\n\ttoolsResult := gjson.GetBytes(rawJSON, \"tools\")\n\tif toolsResult.Exists() && toolsResult.IsArray() {\n\t\ttoolResults := toolsResult.Array()\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\tfunctionDeclarationsResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"tools.%d.function_declarations\", i))\n\t\t\tif functionDeclarationsResult.Exists() && functionDeclarationsResult.IsArray() {\n\t\t\t\tfunctionDeclarationsResults := functionDeclarationsResult.Array()\n\t\t\t\tfor j := 0; j < len(functionDeclarationsResults); j++ {\n\t\t\t\t\tparametersResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"tools.%d.function_declarations.%d.parameters\", i, j))\n\t\t\t\t\tif parametersResult.Exists() {\n\t\t\t\t\t\tstrJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf(\"tools.%d.function_declarations.%d.parameters\", i, j), fmt.Sprintf(\"tools.%d.function_declarations.%d.parametersJsonSchema\", i, j))\n\t\t\t\t\t\trawJSON = []byte(strJson)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tgjson.GetBytes(rawJSON, \"contents\").ForEach(func(key, content gjson.Result) bool {\n\t\tif content.Get(\"role\").String() == \"model\" {\n\t\t\tcontent.Get(\"parts\").ForEach(func(partKey, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t} else if part.Get(\"thoughtSignature\").Exists() {\n\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t\treturn true\n\t})\n\n\treturn common.AttachDefaultSafetySettings(rawJSON, \"safetySettings\")\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go",
    "content": "// Package gemini_cli provides response translation functionality for Gemini API to Gemini CLI API.\n// This package handles the conversion of Gemini API responses into Gemini CLI-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini CLI API clients.\npackage geminiCLI\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/tidwall/sjson\"\n)\n\nvar dataTag = []byte(\"data:\")\n\n// ConvertGeminiResponseToGeminiCLI converts Gemini streaming response format to Gemini CLI single-line JSON format.\n// This function processes various Gemini event types and transforms them into Gemini CLI-compatible JSON responses.\n// It handles thinking content, regular text content, and function calls, outputting single-line JSON\n// that matches the Gemini CLI API response format.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini API.\n//   - param: A pointer to a parameter object for the conversion (unused).\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini CLI-compatible JSON response.\nfunc ConvertGeminiResponseToGeminiCLI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string {\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\tjson := `{\"response\": {}}`\n\trawJSON, _ = sjson.SetRawBytes([]byte(json), \"response\", rawJSON)\n\treturn []string{string(rawJSON)}\n}\n\n// ConvertGeminiResponseToGeminiCLINonStream converts a non-streaming Gemini response to a non-streaming Gemini CLI response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini API.\n//   - param: A pointer to a parameter object for the conversion (unused).\n//\n// Returns:\n//   - string: A Gemini CLI-compatible JSON response.\nfunc ConvertGeminiResponseToGeminiCLINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\tjson := `{\"response\": {}}`\n\trawJSON, _ = sjson.SetRawBytes([]byte(json), \"response\", rawJSON)\n\treturn string(rawJSON)\n}\n\nfunc GeminiCLITokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/gemini/gemini-cli/init.go",
    "content": "package geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGeminiCLI,\n\t\tGemini,\n\t\tConvertGeminiCLIRequestToGemini,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertGeminiResponseToGeminiCLI,\n\t\t\tNonStream:  ConvertGeminiResponseToGeminiCLINonStream,\n\t\t\tTokenCount: GeminiCLITokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/chat-completions/gemini_openai_request.go",
    "content": "// Package openai provides request translation functionality for OpenAI to Gemini API compatibility.\n// It converts OpenAI Chat Completions requests into Gemini compatible JSON using gjson/sjson only.\npackage chat_completions\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiFunctionThoughtSignature = \"skip_thought_signature_validator\"\n\n// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)\n// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini API format\nfunc ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base envelope (no default thinkingConfig)\n\tout := []byte(`{\"contents\":[]}`)\n\n\t// Model\n\tout, _ = sjson.SetBytes(out, \"model\", modelName)\n\n\t// Let user-provided generationConfig pass through\n\tif genConfig := gjson.GetBytes(rawJSON, \"generationConfig\"); genConfig.Exists() {\n\t\tout, _ = sjson.SetRawBytes(out, \"generationConfig\", []byte(genConfig.Raw))\n\t}\n\n\t// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.\n\t// Inline translation-only mapping; capability checks happen later in ApplyThinking.\n\tre := gjson.GetBytes(rawJSON, \"reasoning_effort\")\n\tif re.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(re.String()))\n\t\tif effort != \"\" {\n\t\t\tthinkingPath := \"generationConfig.thinkingConfig\"\n\t\t\tif effort == \"auto\" {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingBudget\", -1)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", true)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingLevel\", effort)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", effort != \"none\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// Temperature/top_p/top_k\n\tif tr := gjson.GetBytes(rawJSON, \"temperature\"); tr.Exists() && tr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"generationConfig.temperature\", tr.Num)\n\t}\n\tif tpr := gjson.GetBytes(rawJSON, \"top_p\"); tpr.Exists() && tpr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"generationConfig.topP\", tpr.Num)\n\t}\n\tif tkr := gjson.GetBytes(rawJSON, \"top_k\"); tkr.Exists() && tkr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"generationConfig.topK\", tkr.Num)\n\t}\n\n\t// Candidate count (OpenAI 'n' parameter)\n\tif n := gjson.GetBytes(rawJSON, \"n\"); n.Exists() && n.Type == gjson.Number {\n\t\tif val := n.Int(); val > 1 {\n\t\t\tout, _ = sjson.SetBytes(out, \"generationConfig.candidateCount\", val)\n\t\t}\n\t}\n\n\t// Map OpenAI modalities -> Gemini generationConfig.responseModalities\n\t// e.g. \"modalities\": [\"image\", \"text\"] -> [\"IMAGE\", \"TEXT\"]\n\tif mods := gjson.GetBytes(rawJSON, \"modalities\"); mods.Exists() && mods.IsArray() {\n\t\tvar responseMods []string\n\t\tfor _, m := range mods.Array() {\n\t\t\tswitch strings.ToLower(m.String()) {\n\t\t\tcase \"text\":\n\t\t\t\tresponseMods = append(responseMods, \"TEXT\")\n\t\t\tcase \"image\":\n\t\t\t\tresponseMods = append(responseMods, \"IMAGE\")\n\t\t\t}\n\t\t}\n\t\tif len(responseMods) > 0 {\n\t\t\tout, _ = sjson.SetBytes(out, \"generationConfig.responseModalities\", responseMods)\n\t\t}\n\t}\n\n\t// OpenRouter-style image_config support\n\t// If the input uses top-level image_config.aspect_ratio, map it into generationConfig.imageConfig.aspectRatio.\n\tif imgCfg := gjson.GetBytes(rawJSON, \"image_config\"); imgCfg.Exists() && imgCfg.IsObject() {\n\t\tif ar := imgCfg.Get(\"aspect_ratio\"); ar.Exists() && ar.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"generationConfig.imageConfig.aspectRatio\", ar.Str)\n\t\t}\n\t\tif size := imgCfg.Get(\"image_size\"); size.Exists() && size.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"generationConfig.imageConfig.imageSize\", size.Str)\n\t\t}\n\t}\n\n\t// messages -> systemInstruction + contents\n\tmessages := gjson.GetBytes(rawJSON, \"messages\")\n\tif messages.IsArray() {\n\t\tarr := messages.Array()\n\t\t// First pass: assistant tool_calls id->name map\n\t\ttcID2Name := map[string]string{}\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\tif m.Get(\"role\").String() == \"assistant\" {\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\t\tid := tc.Get(\"id\").String()\n\t\t\t\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\t\tif id != \"\" && name != \"\" {\n\t\t\t\t\t\t\t\ttcID2Name[id] = name\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Second pass build systemInstruction/tool responses cache\n\t\ttoolResponses := map[string]string{} // tool_call_id -> response text\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tif role == \"tool\" {\n\t\t\t\ttoolCallID := m.Get(\"tool_call_id\").String()\n\t\t\t\tif toolCallID != \"\" {\n\t\t\t\t\tc := m.Get(\"content\")\n\t\t\t\t\ttoolResponses[toolCallID] = c.Raw\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsystemPartIndex := 0\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tcontent := m.Get(\"content\")\n\n\t\t\tif (role == \"system\" || role == \"developer\") && len(arr) > 1 {\n\t\t\t\t// system -> systemInstruction as a user message style\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"systemInstruction.parts.%d.text\", systemPartIndex), content.String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsObject() && content.Get(\"type\").String() == \"text\" {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"systemInstruction.parts.%d.text\", systemPartIndex), content.Get(\"text\").String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\tcontents := content.Array()\n\t\t\t\t\tif len(contents) > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetBytes(out, \"systemInstruction.role\", \"user\")\n\t\t\t\t\t\tfor j := 0; j < len(contents); j++ {\n\t\t\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"systemInstruction.parts.%d.text\", systemPartIndex), contents[j].Get(\"text\").String())\n\t\t\t\t\t\t\tsystemPartIndex++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if role == \"user\" || ((role == \"system\" || role == \"developer\") && len(arr) == 1) {\n\t\t\t\t// Build single user content node to avoid splitting into multiple contents\n\t\t\t\tnode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.0.text\", content.String())\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\titems := content.Array()\n\t\t\t\t\tp := 0\n\t\t\t\t\tfor _, item := range items {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\ttext := item.Get(\"text\").String()\n\t\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", text)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 {\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"file\":\n\t\t\t\t\t\t\tfilename := item.Get(\"file.filename\").String()\n\t\t\t\t\t\t\tfileData := item.Get(\"file.file_data\").String()\n\t\t\t\t\t\t\text := \"\"\n\t\t\t\t\t\t\tif sp := strings.Split(filename, \".\"); len(sp) > 1 {\n\t\t\t\t\t\t\t\text = sp[len(sp)-1]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif mimeType, ok := misc.MimeTypes[ext]; ok {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mimeType)\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", fileData)\n\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Unknown file name extension '%s' in user message, skip\", ext)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRawBytes(out, \"contents.-1\", node)\n\t\t\t} else if role == \"assistant\" {\n\t\t\t\tnode := []byte(`{\"role\":\"model\",\"parts\":[]}`)\n\t\t\t\tp := 0\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\t// Assistant text -> single model content\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.-1.text\", content.String())\n\t\t\t\t\tp++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\t// Assistant multimodal content (e.g. text + image) -> single model content with parts\n\t\t\t\t\tfor _, item := range content.Array() {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\ttext := item.Get(\"text\").String()\n\t\t\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", text)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\t// If the assistant returned an inline data URL, preserve it for history fidelity.\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 { // expect data:...\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Tool calls -> single model content with functionCall parts\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfIDs := make([]string, 0)\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() != \"function\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfid := tc.Get(\"id\").String()\n\t\t\t\t\t\tfname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\tfargs := tc.Get(\"function.arguments\").String()\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".functionCall.name\", fname)\n\t\t\t\t\t\tnode, _ = sjson.SetRawBytes(node, \"parts.\"+itoa(p)+\".functionCall.args\", []byte(fargs))\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiFunctionThoughtSignature)\n\t\t\t\t\t\tp++\n\t\t\t\t\t\tif fid != \"\" {\n\t\t\t\t\t\t\tfIDs = append(fIDs, fid)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"contents.-1\", node)\n\n\t\t\t\t\t// Append a single tool content combining name + response per function\n\t\t\t\t\ttoolNode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\t\tpp := 0\n\t\t\t\t\tfor _, fid := range fIDs {\n\t\t\t\t\t\tif name, ok := tcID2Name[fid]; ok {\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.name\", name)\n\t\t\t\t\t\t\tresp := toolResponses[fid]\n\t\t\t\t\t\t\tif resp == \"\" {\n\t\t\t\t\t\t\t\tresp = \"{}\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.response.result\", []byte(resp))\n\t\t\t\t\t\t\tpp++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif pp > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"contents.-1\", toolNode)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"contents.-1\", node)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough\n\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\tfunctionToolNode := []byte(`{}`)\n\t\thasFunction := false\n\t\tgoogleSearchNodes := make([][]byte, 0)\n\t\tcodeExecutionNodes := make([][]byte, 0)\n\t\turlContextNodes := make([][]byte, 0)\n\t\tfor _, t := range tools.Array() {\n\t\t\tif t.Get(\"type\").String() == \"function\" {\n\t\t\t\tfn := t.Get(\"function\")\n\t\t\t\tif fn.Exists() && fn.IsObject() {\n\t\t\t\t\tfnRaw := fn.Raw\n\t\t\t\t\tif fn.Get(\"parameters\").Exists() {\n\t\t\t\t\t\trenamed, errRename := util.RenameKey(fnRaw, \"parameters\", \"parametersJsonSchema\")\n\t\t\t\t\t\tif errRename != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to rename parameters for tool '%s': %v\", fn.Get(\"name\").String(), errRename)\n\t\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tfnRaw = renamed\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfnRaw, _ = sjson.Delete(fnRaw, \"strict\")\n\t\t\t\t\tif !hasFunction {\n\t\t\t\t\t\tfunctionToolNode, _ = sjson.SetRawBytes(functionToolNode, \"functionDeclarations\", []byte(\"[]\"))\n\t\t\t\t\t}\n\t\t\t\t\ttmp, errSet := sjson.SetRawBytes(functionToolNode, \"functionDeclarations.-1\", []byte(fnRaw))\n\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\tlog.Warnf(\"Failed to append tool declaration for '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tfunctionToolNode = tmp\n\t\t\t\t\thasFunction = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif gs := t.Get(\"google_search\"); gs.Exists() {\n\t\t\t\tgoogleToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tgoogleToolNode, errSet = sjson.SetRawBytes(googleToolNode, \"googleSearch\", []byte(gs.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set googleSearch tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tgoogleSearchNodes = append(googleSearchNodes, googleToolNode)\n\t\t\t}\n\t\t\tif ce := t.Get(\"code_execution\"); ce.Exists() {\n\t\t\t\tcodeToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tcodeToolNode, errSet = sjson.SetRawBytes(codeToolNode, \"codeExecution\", []byte(ce.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set codeExecution tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcodeExecutionNodes = append(codeExecutionNodes, codeToolNode)\n\t\t\t}\n\t\t\tif uc := t.Get(\"url_context\"); uc.Exists() {\n\t\t\t\turlToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\turlToolNode, errSet = sjson.SetRawBytes(urlToolNode, \"urlContext\", []byte(uc.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set urlContext tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\turlContextNodes = append(urlContextNodes, urlToolNode)\n\t\t\t}\n\t\t}\n\t\tif hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {\n\t\t\ttoolsNode := []byte(\"[]\")\n\t\t\tif hasFunction {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", functionToolNode)\n\t\t\t}\n\t\t\tfor _, googleNode := range googleSearchNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", googleNode)\n\t\t\t}\n\t\t\tfor _, codeNode := range codeExecutionNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", codeNode)\n\t\t\t}\n\t\t\tfor _, urlNode := range urlContextNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", urlNode)\n\t\t\t}\n\t\t\tout, _ = sjson.SetRawBytes(out, \"tools\", toolsNode)\n\t\t}\n\t}\n\n\tout = common.AttachDefaultSafetySettings(out, \"safetySettings\")\n\n\treturn out\n}\n\n// itoa converts int to string without strconv import for few usages.\nfunc itoa(i int) string { return fmt.Sprintf(\"%d\", i) }\n"
  },
  {
    "path": "internal/translator/gemini/openai/chat-completions/gemini_openai_response.go",
    "content": "// Package openai provides response translation functionality for Gemini to OpenAI API compatibility.\n// This package handles the conversion of Gemini API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.\ntype convertGeminiResponseToOpenAIChatParams struct {\n\tUnixTimestamp int64\n\t// FunctionIndex tracks tool call indices per candidate index to support multiple candidates.\n\tFunctionIndex map[int]int\n}\n\n// functionCallIDCounter provides a process-wide unique counter for function call identifiers.\nvar functionCallIDCounter uint64\n\n// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the\n// Gemini API format to the OpenAI Chat Completions streaming format.\n// It processes various Gemini event types and transforms them into OpenAI-compatible JSON responses.\n// The function handles text content, tool calls, reasoning content, and usage metadata, outputting\n// responses that match the OpenAI API format. It supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\t// Initialize parameters if nil.\n\tif *param == nil {\n\t\t*param = &convertGeminiResponseToOpenAIChatParams{\n\t\t\tUnixTimestamp: 0,\n\t\t\tFunctionIndex: make(map[int]int),\n\t\t}\n\t}\n\n\t// Ensure the Map is initialized (handling cases where param might be reused from older context).\n\tp := (*param).(*convertGeminiResponseToOpenAIChatParams)\n\tif p.FunctionIndex == nil {\n\t\tp.FunctionIndex = make(map[int]int)\n\t}\n\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\t// Initialize the OpenAI SSE base template.\n\t// We use a base template and clone it for each candidate to support multiple candidates.\n\tbaseTemplate := `{\"id\":\"\",\"object\":\"chat.completion.chunk\",\"created\":12345,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}]}`\n\n\t// Extract and set the model version.\n\tif modelVersionResult := gjson.GetBytes(rawJSON, \"modelVersion\"); modelVersionResult.Exists() {\n\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"model\", modelVersionResult.String())\n\t}\n\n\t// Extract and set the creation timestamp.\n\tif createTimeResult := gjson.GetBytes(rawJSON, \"createTime\"); createTimeResult.Exists() {\n\t\tt, err := time.Parse(time.RFC3339Nano, createTimeResult.String())\n\t\tif err == nil {\n\t\t\tp.UnixTimestamp = t.Unix()\n\t\t}\n\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"created\", p.UnixTimestamp)\n\t} else {\n\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"created\", p.UnixTimestamp)\n\t}\n\n\t// Extract and set the response ID.\n\tif responseIDResult := gjson.GetBytes(rawJSON, \"responseId\"); responseIDResult.Exists() {\n\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"id\", responseIDResult.String())\n\t}\n\n\t// Extract and set usage metadata (token counts).\n\t// Usage is applied to the base template so it appears in the chunks.\n\tif usageResult := gjson.GetBytes(rawJSON, \"usageMetadata\"); usageResult.Exists() {\n\t\tcachedTokenCount := usageResult.Get(\"cachedContentTokenCount\").Int()\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"usage.completion_tokens\", candidatesTokenCountResult.Int())\n\t\t}\n\t\tif totalTokenCountResult := usageResult.Get(\"totalTokenCount\"); totalTokenCountResult.Exists() {\n\t\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"usage.total_tokens\", totalTokenCountResult.Int())\n\t\t}\n\t\tpromptTokenCount := usageResult.Get(\"promptTokenCount\").Int()\n\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"usage.prompt_tokens\", promptTokenCount)\n\t\tif thoughtsTokenCount > 0 {\n\t\t\tbaseTemplate, _ = sjson.Set(baseTemplate, \"usage.completion_tokens_details.reasoning_tokens\", thoughtsTokenCount)\n\t\t}\n\t\t// Include cached token count if present (indicates prompt caching is working)\n\t\tif cachedTokenCount > 0 {\n\t\t\tvar err error\n\t\t\tbaseTemplate, err = sjson.Set(baseTemplate, \"usage.prompt_tokens_details.cached_tokens\", cachedTokenCount)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"gemini openai response: failed to set cached_tokens in streaming: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar responseStrings []string\n\tcandidates := gjson.GetBytes(rawJSON, \"candidates\")\n\n\t// Iterate over all candidates to support candidate_count > 1.\n\tif candidates.IsArray() {\n\t\tcandidates.ForEach(func(_, candidate gjson.Result) bool {\n\t\t\t// Clone the template for the current candidate.\n\t\t\ttemplate := baseTemplate\n\n\t\t\t// Set the specific index for this candidate.\n\t\t\tcandidateIndex := int(candidate.Get(\"index\").Int())\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.index\", candidateIndex)\n\n\t\t\tfinishReason := \"\"\n\t\t\tif stopReasonResult := gjson.GetBytes(rawJSON, \"stop_reason\"); stopReasonResult.Exists() {\n\t\t\t\tfinishReason = stopReasonResult.String()\n\t\t\t}\n\t\t\tif finishReason == \"\" {\n\t\t\t\tif finishReasonResult := gjson.GetBytes(rawJSON, \"candidates.0.finishReason\"); finishReasonResult.Exists() {\n\t\t\t\t\tfinishReason = finishReasonResult.String()\n\t\t\t\t}\n\t\t\t}\n\t\t\tfinishReason = strings.ToLower(finishReason)\n\n\t\t\tpartsResult := candidate.Get(\"content.parts\")\n\t\t\thasFunctionCall := false\n\n\t\t\tif partsResult.IsArray() {\n\t\t\t\tpartResults := partsResult.Array()\n\t\t\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\t\t\tpartResult := partResults[i]\n\t\t\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\t\t\t\t\tinlineDataResult := partResult.Get(\"inlineData\")\n\t\t\t\t\tif !inlineDataResult.Exists() {\n\t\t\t\t\t\tinlineDataResult = partResult.Get(\"inline_data\")\n\t\t\t\t\t}\n\t\t\t\t\tthoughtSignatureResult := partResult.Get(\"thoughtSignature\")\n\t\t\t\t\tif !thoughtSignatureResult.Exists() {\n\t\t\t\t\t\tthoughtSignatureResult = partResult.Get(\"thought_signature\")\n\t\t\t\t\t}\n\n\t\t\t\t\thasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != \"\"\n\t\t\t\t\thasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()\n\n\t\t\t\t\t// Skip pure thoughtSignature parts but keep any actual payload in the same part.\n\t\t\t\t\tif hasThoughtSignature && !hasContentPayload {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif partTextResult.Exists() {\n\t\t\t\t\t\ttext := partTextResult.String()\n\t\t\t\t\t\t// Handle text content, distinguishing between regular content and reasoning/thoughts.\n\t\t\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", text)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.content\", text)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t\t\t// Handle function call content.\n\t\t\t\t\t\thasFunctionCall = true\n\t\t\t\t\t\ttoolCallsResult := gjson.Get(template, \"choices.0.delta.tool_calls\")\n\n\t\t\t\t\t\t// Retrieve the function index for this specific candidate.\n\t\t\t\t\t\tfunctionCallIndex := p.FunctionIndex[candidateIndex]\n\t\t\t\t\t\tp.FunctionIndex[candidateIndex]++\n\n\t\t\t\t\t\tif toolCallsResult.Exists() && toolCallsResult.IsArray() {\n\t\t\t\t\t\t\tfunctionCallIndex = len(toolCallsResult.Array())\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunctionCallTemplate := `{\"id\": \"\",\"index\": 0,\"type\": \"function\",\"function\": {\"name\": \"\",\"arguments\": \"\"}}`\n\t\t\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\t\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"id\", fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))\n\t\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"index\", functionCallIndex)\n\t\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.name\", fcName)\n\t\t\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.arguments\", fcArgsResult.Raw)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallTemplate)\n\t\t\t\t\t} else if inlineDataResult.Exists() {\n\t\t\t\t\t\tdata := inlineDataResult.Get(\"data\").String()\n\t\t\t\t\t\tif data == \"\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmimeType := inlineDataResult.Get(\"mimeType\").String()\n\t\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\t\tmimeType = inlineDataResult.Get(\"mime_type\").String()\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\t\t\t\t\t\timagesResult := gjson.Get(template, \"choices.0.delta.images\")\n\t\t\t\t\t\tif !imagesResult.Exists() || !imagesResult.IsArray() {\n\t\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images\", `[]`)\n\t\t\t\t\t\t}\n\t\t\t\t\t\timageIndex := len(gjson.Get(template, \"choices.0.delta.images\").Array())\n\t\t\t\t\t\timagePayload := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"index\", imageIndex)\n\t\t\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"image_url.url\", imageURL)\n\t\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images.-1\", imagePayload)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasFunctionCall {\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", \"tool_calls\")\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", \"tool_calls\")\n\t\t\t} else if finishReason != \"\" {\n\t\t\t\t// Only pass through specific finish reasons\n\t\t\t\tif finishReason == \"max_tokens\" || finishReason == \"stop\" {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", finishReason)\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", finishReason)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresponseStrings = append(responseStrings, template)\n\t\t\treturn true // continue loop\n\t\t})\n\t} else {\n\t\t// If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.\n\t\tif gjson.GetBytes(rawJSON, \"usageMetadata\").Exists() && len(responseStrings) == 0 {\n\t\t\tresponseStrings = append(responseStrings, baseTemplate)\n\t\t}\n\t}\n\n\treturn responseStrings\n}\n\n// ConvertGeminiResponseToOpenAINonStream converts a non-streaming Gemini response to a non-streaming OpenAI response.\n// This function processes the complete Gemini response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\tvar unixTimestamp int64\n\t// Initialize template with an empty choices array to support multiple candidates.\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion\",\"created\":123456,\"model\":\"model\",\"choices\":[]}`\n\n\tif modelVersionResult := gjson.GetBytes(rawJSON, \"modelVersion\"); modelVersionResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelVersionResult.String())\n\t}\n\n\tif createTimeResult := gjson.GetBytes(rawJSON, \"createTime\"); createTimeResult.Exists() {\n\t\tt, err := time.Parse(time.RFC3339Nano, createTimeResult.String())\n\t\tif err == nil {\n\t\t\tunixTimestamp = t.Unix()\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"created\", unixTimestamp)\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"created\", unixTimestamp)\n\t}\n\n\tif responseIDResult := gjson.GetBytes(rawJSON, \"responseId\"); responseIDResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"id\", responseIDResult.String())\n\t}\n\n\tif usageResult := gjson.GetBytes(rawJSON, \"usageMetadata\"); usageResult.Exists() {\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", candidatesTokenCountResult.Int())\n\t\t}\n\t\tif totalTokenCountResult := usageResult.Get(\"totalTokenCount\"); totalTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", totalTokenCountResult.Int())\n\t\t}\n\t\tpromptTokenCount := usageResult.Get(\"promptTokenCount\").Int()\n\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\tcachedTokenCount := usageResult.Get(\"cachedContentTokenCount\").Int()\n\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", promptTokenCount)\n\t\tif thoughtsTokenCount > 0 {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens_details.reasoning_tokens\", thoughtsTokenCount)\n\t\t}\n\t\t// Include cached token count if present (indicates prompt caching is working)\n\t\tif cachedTokenCount > 0 {\n\t\t\tvar err error\n\t\t\ttemplate, err = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cachedTokenCount)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"gemini openai response: failed to set cached_tokens in non-streaming: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process the main content part of the response for all candidates.\n\tcandidates := gjson.GetBytes(rawJSON, \"candidates\")\n\tif candidates.IsArray() {\n\t\tcandidates.ForEach(func(_, candidate gjson.Result) bool {\n\t\t\t// Construct a single Choice object.\n\t\t\tchoiceTemplate := `{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}`\n\n\t\t\t// Set the index for this choice.\n\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"index\", candidate.Get(\"index\").Int())\n\n\t\t\t// Set finish reason.\n\t\t\tif finishReasonResult := candidate.Get(\"finishReason\"); finishReasonResult.Exists() {\n\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"finish_reason\", strings.ToLower(finishReasonResult.String()))\n\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"native_finish_reason\", strings.ToLower(finishReasonResult.String()))\n\t\t\t}\n\n\t\t\tpartsResult := candidate.Get(\"content.parts\")\n\t\t\thasFunctionCall := false\n\t\t\tif partsResult.IsArray() {\n\t\t\t\tpartsResults := partsResult.Array()\n\t\t\t\tfor i := 0; i < len(partsResults); i++ {\n\t\t\t\t\tpartResult := partsResults[i]\n\t\t\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\t\t\t\t\tinlineDataResult := partResult.Get(\"inlineData\")\n\t\t\t\t\tif !inlineDataResult.Exists() {\n\t\t\t\t\t\tinlineDataResult = partResult.Get(\"inline_data\")\n\t\t\t\t\t}\n\n\t\t\t\t\tif partTextResult.Exists() {\n\t\t\t\t\t\t// Append text content, distinguishing between regular content and reasoning.\n\t\t\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\t\t\toldVal := gjson.Get(choiceTemplate, \"message.reasoning_content\").String()\n\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"message.reasoning_content\", oldVal+partTextResult.String())\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\toldVal := gjson.Get(choiceTemplate, \"message.content\").String()\n\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"message.content\", oldVal+partTextResult.String())\n\t\t\t\t\t\t}\n\t\t\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"message.role\", \"assistant\")\n\t\t\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t\t\t// Append function call content to the tool_calls array.\n\t\t\t\t\t\thasFunctionCall = true\n\t\t\t\t\t\ttoolCallsResult := gjson.Get(choiceTemplate, \"message.tool_calls\")\n\t\t\t\t\t\tif !toolCallsResult.Exists() || !toolCallsResult.IsArray() {\n\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.SetRaw(choiceTemplate, \"message.tool_calls\", `[]`)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunctionCallItemTemplate := `{\"id\": \"\",\"type\": \"function\",\"function\": {\"name\": \"\",\"arguments\": \"\"}}`\n\t\t\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\t\t\t\t\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"id\", fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))\n\t\t\t\t\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.name\", fcName)\n\t\t\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\t\t\tfunctionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, \"function.arguments\", fcArgsResult.Raw)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"message.role\", \"assistant\")\n\t\t\t\t\t\tchoiceTemplate, _ = sjson.SetRaw(choiceTemplate, \"message.tool_calls.-1\", functionCallItemTemplate)\n\t\t\t\t\t} else if inlineDataResult.Exists() {\n\t\t\t\t\t\tdata := inlineDataResult.Get(\"data\").String()\n\t\t\t\t\t\tif data != \"\" {\n\t\t\t\t\t\t\tmimeType := inlineDataResult.Get(\"mimeType\").String()\n\t\t\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\t\t\tmimeType = inlineDataResult.Get(\"mime_type\").String()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\t\t\t\t\t\t\timagesResult := gjson.Get(choiceTemplate, \"message.images\")\n\t\t\t\t\t\t\tif !imagesResult.Exists() || !imagesResult.IsArray() {\n\t\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.SetRaw(choiceTemplate, \"message.images\", `[]`)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\timageIndex := len(gjson.Get(choiceTemplate, \"message.images\").Array())\n\t\t\t\t\t\t\timagePayload := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"index\", imageIndex)\n\t\t\t\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"image_url.url\", imageURL)\n\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"message.role\", \"assistant\")\n\t\t\t\t\t\t\tchoiceTemplate, _ = sjson.SetRaw(choiceTemplate, \"message.images.-1\", imagePayload)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasFunctionCall {\n\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"finish_reason\", \"tool_calls\")\n\t\t\t\tchoiceTemplate, _ = sjson.Set(choiceTemplate, \"native_finish_reason\", \"tool_calls\")\n\t\t\t}\n\n\t\t\t// Append the constructed choice to the main choices array.\n\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.-1\", choiceTemplate)\n\t\t\treturn true\n\t\t})\n\t}\n\n\treturn template\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tGemini,\n\t\tConvertOpenAIRequestToGemini,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertGeminiResponseToOpenAI,\n\t\t\tNonStream: ConvertGeminiResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/responses/gemini_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiResponsesThoughtSignature = \"skip_thought_signature_validator\"\n\nfunc ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\n\t// Note: modelName and stream parameters are part of the fixed method signature\n\t_ = modelName // Unused but required by interface\n\t_ = stream    // Unused but required by interface\n\n\t// Base Gemini API template (do not include thinkingConfig by default)\n\tout := `{\"contents\":[]}`\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Extract system instruction from OpenAI \"instructions\" field\n\tif instructions := root.Get(\"instructions\"); instructions.Exists() {\n\t\tsystemInstr := `{\"parts\":[{\"text\":\"\"}]}`\n\t\tsystemInstr, _ = sjson.Set(systemInstr, \"parts.0.text\", instructions.String())\n\t\tout, _ = sjson.SetRaw(out, \"systemInstruction\", systemInstr)\n\t}\n\n\t// Convert input messages to Gemini contents format\n\tif input := root.Get(\"input\"); input.Exists() && input.IsArray() {\n\t\titems := input.Array()\n\n\t\t// Normalize consecutive function calls and outputs so each call is immediately followed by its response\n\t\tnormalized := make([]gjson.Result, 0, len(items))\n\t\tfor i := 0; i < len(items); {\n\t\t\titem := items[i]\n\t\t\titemType := item.Get(\"type\").String()\n\t\t\titemRole := item.Get(\"role\").String()\n\t\t\tif itemType == \"\" && itemRole != \"\" {\n\t\t\t\titemType = \"message\"\n\t\t\t}\n\n\t\t\tif itemType == \"function_call\" {\n\t\t\t\tvar calls []gjson.Result\n\t\t\t\tvar outputs []gjson.Result\n\n\t\t\t\tfor i < len(items) {\n\t\t\t\t\tnext := items[i]\n\t\t\t\t\tnextType := next.Get(\"type\").String()\n\t\t\t\t\tnextRole := next.Get(\"role\").String()\n\t\t\t\t\tif nextType == \"\" && nextRole != \"\" {\n\t\t\t\t\t\tnextType = \"message\"\n\t\t\t\t\t}\n\t\t\t\t\tif nextType != \"function_call\" {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tcalls = append(calls, next)\n\t\t\t\t\ti++\n\t\t\t\t}\n\n\t\t\t\tfor i < len(items) {\n\t\t\t\t\tnext := items[i]\n\t\t\t\t\tnextType := next.Get(\"type\").String()\n\t\t\t\t\tnextRole := next.Get(\"role\").String()\n\t\t\t\t\tif nextType == \"\" && nextRole != \"\" {\n\t\t\t\t\t\tnextType = \"message\"\n\t\t\t\t\t}\n\t\t\t\t\tif nextType != \"function_call_output\" {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\toutputs = append(outputs, next)\n\t\t\t\t\ti++\n\t\t\t\t}\n\n\t\t\t\tif len(calls) > 0 {\n\t\t\t\t\toutputMap := make(map[string]gjson.Result, len(outputs))\n\t\t\t\t\tfor _, out := range outputs {\n\t\t\t\t\t\toutputMap[out.Get(\"call_id\").String()] = out\n\t\t\t\t\t}\n\t\t\t\t\tfor _, call := range calls {\n\t\t\t\t\t\tnormalized = append(normalized, call)\n\t\t\t\t\t\tcallID := call.Get(\"call_id\").String()\n\t\t\t\t\t\tif resp, ok := outputMap[callID]; ok {\n\t\t\t\t\t\t\tnormalized = append(normalized, resp)\n\t\t\t\t\t\t\tdelete(outputMap, callID)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, out := range outputs {\n\t\t\t\t\t\tif _, ok := outputMap[out.Get(\"call_id\").String()]; ok {\n\t\t\t\t\t\t\tnormalized = append(normalized, out)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif itemType == \"function_call_output\" {\n\t\t\t\tnormalized = append(normalized, item)\n\t\t\t\ti++\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tnormalized = append(normalized, item)\n\t\t\ti++\n\t\t}\n\n\t\tfor _, item := range normalized {\n\t\t\titemType := item.Get(\"type\").String()\n\t\t\titemRole := item.Get(\"role\").String()\n\t\t\tif itemType == \"\" && itemRole != \"\" {\n\t\t\t\titemType = \"message\"\n\t\t\t}\n\n\t\t\tswitch itemType {\n\t\t\tcase \"message\":\n\t\t\t\tif strings.EqualFold(itemRole, \"system\") {\n\t\t\t\t\tif contentArray := item.Get(\"content\"); contentArray.Exists() {\n\t\t\t\t\t\tsystemInstr := \"\"\n\t\t\t\t\t\tif systemInstructionResult := gjson.Get(out, \"systemInstruction\"); systemInstructionResult.Exists() {\n\t\t\t\t\t\t\tsystemInstr = systemInstructionResult.Raw\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsystemInstr = `{\"parts\":[]}`\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif contentArray.IsArray() {\n\t\t\t\t\t\t\tcontentArray.ForEach(func(_, contentItem gjson.Result) bool {\n\t\t\t\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\t\t\t\ttext := contentItem.Get(\"text\").String()\n\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", text)\n\t\t\t\t\t\t\t\tsystemInstr, _ = sjson.SetRaw(systemInstr, \"parts.-1\", part)\n\t\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t} else if contentArray.Type == gjson.String {\n\t\t\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentArray.String())\n\t\t\t\t\t\t\tsystemInstr, _ = sjson.SetRaw(systemInstr, \"parts.-1\", part)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif systemInstr != `{\"parts\":[]}` {\n\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"systemInstruction\", systemInstr)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Handle regular messages\n\t\t\t\t// Note: In Responses format, model outputs may appear as content items with type \"output_text\"\n\t\t\t\t// even when the message.role is \"user\". We split such items into distinct Gemini messages\n\t\t\t\t// with roles derived from the content type to match docs/convert-2.md.\n\t\t\t\tif contentArray := item.Get(\"content\"); contentArray.Exists() && contentArray.IsArray() {\n\t\t\t\t\tcurrentRole := \"\"\n\t\t\t\t\tvar currentParts []string\n\n\t\t\t\t\tflush := func() {\n\t\t\t\t\t\tif currentRole == \"\" || len(currentParts) == 0 {\n\t\t\t\t\t\t\tcurrentParts = nil\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tone := `{\"role\":\"\",\"parts\":[]}`\n\t\t\t\t\t\tone, _ = sjson.Set(one, \"role\", currentRole)\n\t\t\t\t\t\tfor _, part := range currentParts {\n\t\t\t\t\t\t\tone, _ = sjson.SetRaw(one, \"parts.-1\", part)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", one)\n\t\t\t\t\t\tcurrentParts = nil\n\t\t\t\t\t}\n\n\t\t\t\t\tcontentArray.ForEach(func(_, contentItem gjson.Result) bool {\n\t\t\t\t\t\tcontentType := contentItem.Get(\"type\").String()\n\t\t\t\t\t\tif contentType == \"\" {\n\t\t\t\t\t\t\tcontentType = \"input_text\"\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\teffRole := \"user\"\n\t\t\t\t\t\tif itemRole != \"\" {\n\t\t\t\t\t\t\tswitch strings.ToLower(itemRole) {\n\t\t\t\t\t\t\tcase \"assistant\", \"model\":\n\t\t\t\t\t\t\t\teffRole = \"model\"\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\teffRole = strings.ToLower(itemRole)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif contentType == \"output_text\" {\n\t\t\t\t\t\t\teffRole = \"model\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif effRole == \"assistant\" {\n\t\t\t\t\t\t\teffRole = \"model\"\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif currentRole != \"\" && effRole != currentRole {\n\t\t\t\t\t\t\tflush()\n\t\t\t\t\t\t\tcurrentRole = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif currentRole == \"\" {\n\t\t\t\t\t\t\tcurrentRole = effRole\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tvar partJSON string\n\t\t\t\t\t\tswitch contentType {\n\t\t\t\t\t\tcase \"input_text\", \"output_text\":\n\t\t\t\t\t\t\tif text := contentItem.Get(\"text\"); text.Exists() {\n\t\t\t\t\t\t\t\tpartJSON = `{\"text\":\"\"}`\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"text\", text.String())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"input_image\":\n\t\t\t\t\t\t\timageURL := contentItem.Get(\"image_url\").String()\n\t\t\t\t\t\t\tif imageURL == \"\" {\n\t\t\t\t\t\t\t\timageURL = contentItem.Get(\"url\").String()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif imageURL != \"\" {\n\t\t\t\t\t\t\t\tmimeType := \"application/octet-stream\"\n\t\t\t\t\t\t\t\tdata := \"\"\n\t\t\t\t\t\t\t\tif strings.HasPrefix(imageURL, \"data:\") {\n\t\t\t\t\t\t\t\t\ttrimmed := strings.TrimPrefix(imageURL, \"data:\")\n\t\t\t\t\t\t\t\t\tmediaAndData := strings.SplitN(trimmed, \";base64,\", 2)\n\t\t\t\t\t\t\t\t\tif len(mediaAndData) == 2 {\n\t\t\t\t\t\t\t\t\t\tif mediaAndData[0] != \"\" {\n\t\t\t\t\t\t\t\t\t\t\tmimeType = mediaAndData[0]\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdata = mediaAndData[1]\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tmediaAndData = strings.SplitN(trimmed, \",\", 2)\n\t\t\t\t\t\t\t\t\t\tif len(mediaAndData) == 2 {\n\t\t\t\t\t\t\t\t\t\t\tif mediaAndData[0] != \"\" {\n\t\t\t\t\t\t\t\t\t\t\t\tmimeType = mediaAndData[0]\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tdata = mediaAndData[1]\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif data != \"\" {\n\t\t\t\t\t\t\t\t\tpartJSON = `{\"inline_data\":{\"mime_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"inline_data.mime_type\", mimeType)\n\t\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"inline_data.data\", data)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"input_audio\":\n\t\t\t\t\t\t\taudioData := contentItem.Get(\"data\").String()\n\t\t\t\t\t\t\taudioFormat := contentItem.Get(\"format\").String()\n\t\t\t\t\t\t\tif audioData != \"\" {\n\t\t\t\t\t\t\t\taudioMimeMap := map[string]string{\n\t\t\t\t\t\t\t\t\t\"mp3\":       \"audio/mpeg\",\n\t\t\t\t\t\t\t\t\t\"wav\":       \"audio/wav\",\n\t\t\t\t\t\t\t\t\t\"ogg\":       \"audio/ogg\",\n\t\t\t\t\t\t\t\t\t\"flac\":      \"audio/flac\",\n\t\t\t\t\t\t\t\t\t\"aac\":       \"audio/aac\",\n\t\t\t\t\t\t\t\t\t\"webm\":      \"audio/webm\",\n\t\t\t\t\t\t\t\t\t\"pcm16\":     \"audio/pcm\",\n\t\t\t\t\t\t\t\t\t\"g711_ulaw\": \"audio/basic\",\n\t\t\t\t\t\t\t\t\t\"g711_alaw\": \"audio/basic\",\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tmimeType := \"audio/wav\"\n\t\t\t\t\t\t\t\tif audioFormat != \"\" {\n\t\t\t\t\t\t\t\t\tif mapped, ok := audioMimeMap[audioFormat]; ok {\n\t\t\t\t\t\t\t\t\t\tmimeType = mapped\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tmimeType = \"audio/\" + audioFormat\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tpartJSON = `{\"inline_data\":{\"mime_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"inline_data.mime_type\", mimeType)\n\t\t\t\t\t\t\t\tpartJSON, _ = sjson.Set(partJSON, \"inline_data.data\", audioData)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif partJSON != \"\" {\n\t\t\t\t\t\t\tcurrentParts = append(currentParts, partJSON)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\n\t\t\t\t\tflush()\n\t\t\t\t} else if contentArray.Type == gjson.String {\n\t\t\t\t\teffRole := \"user\"\n\t\t\t\t\tif itemRole != \"\" {\n\t\t\t\t\t\tswitch strings.ToLower(itemRole) {\n\t\t\t\t\t\tcase \"assistant\", \"model\":\n\t\t\t\t\t\t\teffRole = \"model\"\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\teffRole = strings.ToLower(itemRole)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tone := `{\"role\":\"\",\"parts\":[{\"text\":\"\"}]}`\n\t\t\t\t\tone, _ = sjson.Set(one, \"role\", effRole)\n\t\t\t\t\tone, _ = sjson.Set(one, \"parts.0.text\", contentArray.String())\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", one)\n\t\t\t\t}\n\t\t\tcase \"function_call\":\n\t\t\t\t// Handle function calls - convert to model message with functionCall\n\t\t\t\tname := item.Get(\"name\").String()\n\t\t\t\targuments := item.Get(\"arguments\").String()\n\n\t\t\t\tmodelContent := `{\"role\":\"model\",\"parts\":[]}`\n\t\t\t\tfunctionCall := `{\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"functionCall.name\", name)\n\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"thoughtSignature\", geminiResponsesThoughtSignature)\n\t\t\t\tfunctionCall, _ = sjson.Set(functionCall, \"functionCall.id\", item.Get(\"call_id\").String())\n\n\t\t\t\t// Parse arguments JSON string and set as args object\n\t\t\t\tif arguments != \"\" {\n\t\t\t\t\targsResult := gjson.Parse(arguments)\n\t\t\t\t\tfunctionCall, _ = sjson.SetRaw(functionCall, \"functionCall.args\", argsResult.Raw)\n\t\t\t\t}\n\n\t\t\t\tmodelContent, _ = sjson.SetRaw(modelContent, \"parts.-1\", functionCall)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", modelContent)\n\n\t\t\tcase \"function_call_output\":\n\t\t\t\t// Handle function call outputs - convert to function message with functionResponse\n\t\t\t\tcallID := item.Get(\"call_id\").String()\n\t\t\t\t// Use .Raw to preserve the JSON encoding (includes quotes for strings)\n\t\t\t\toutputRaw := item.Get(\"output\").Str\n\n\t\t\t\tfunctionContent := `{\"role\":\"function\",\"parts\":[]}`\n\t\t\t\tfunctionResponse := `{\"functionResponse\":{\"name\":\"\",\"response\":{}}}`\n\n\t\t\t\t// We need to extract the function name from the previous function_call\n\t\t\t\t// For now, we'll use a placeholder or extract from context if available\n\t\t\t\tfunctionName := \"unknown\" // This should ideally be matched with the corresponding function_call\n\n\t\t\t\t// Find the corresponding function call name by matching call_id\n\t\t\t\t// We need to look back through the input array to find the matching call\n\t\t\t\tif inputArray := root.Get(\"input\"); inputArray.Exists() && inputArray.IsArray() {\n\t\t\t\t\tinputArray.ForEach(func(_, prevItem gjson.Result) bool {\n\t\t\t\t\t\tif prevItem.Get(\"type\").String() == \"function_call\" && prevItem.Get(\"call_id\").String() == callID {\n\t\t\t\t\t\t\tfunctionName = prevItem.Get(\"name\").String()\n\t\t\t\t\t\t\treturn false // Stop iteration\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tfunctionResponse, _ = sjson.Set(functionResponse, \"functionResponse.name\", functionName)\n\t\t\t\tfunctionResponse, _ = sjson.Set(functionResponse, \"functionResponse.id\", callID)\n\n\t\t\t\t// Set the raw JSON output directly (preserves string encoding)\n\t\t\t\tif outputRaw != \"\" && outputRaw != \"null\" {\n\t\t\t\t\toutput := gjson.Parse(outputRaw)\n\t\t\t\t\tif output.Type == gjson.JSON && json.Valid([]byte(output.Raw)) {\n\t\t\t\t\t\tfunctionResponse, _ = sjson.SetRaw(functionResponse, \"functionResponse.response.result\", output.Raw)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfunctionResponse, _ = sjson.Set(functionResponse, \"functionResponse.response.result\", outputRaw)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfunctionContent, _ = sjson.SetRaw(functionContent, \"parts.-1\", functionResponse)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", functionContent)\n\n\t\t\tcase \"reasoning\":\n\t\t\t\tthoughtContent := `{\"role\":\"model\",\"parts\":[]}`\n\t\t\t\tthought := `{\"text\":\"\",\"thoughtSignature\":\"\",\"thought\":true}`\n\t\t\t\tthought, _ = sjson.Set(thought, \"text\", item.Get(\"summary.0.text\").String())\n\t\t\t\tthought, _ = sjson.Set(thought, \"thoughtSignature\", item.Get(\"encrypted_content\").String())\n\n\t\t\t\tthoughtContent, _ = sjson.SetRaw(thoughtContent, \"parts.-1\", thought)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", thoughtContent)\n\t\t\t}\n\t\t}\n\t} else if input.Exists() && input.Type == gjson.String {\n\t\t// Simple string input conversion to user message\n\t\tuserContent := `{\"role\":\"user\",\"parts\":[{\"text\":\"\"}]}`\n\t\tuserContent, _ = sjson.Set(userContent, \"parts.0.text\", input.String())\n\t\tout, _ = sjson.SetRaw(out, \"contents.-1\", userContent)\n\t}\n\n\t// Convert tools to Gemini functionDeclarations format\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\tgeminiTools := `[{\"functionDeclarations\":[]}]`\n\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tif tool.Get(\"type\").String() == \"function\" {\n\t\t\t\tfuncDecl := `{\"name\":\"\",\"description\":\"\",\"parametersJsonSchema\":{}}`\n\n\t\t\t\tif name := tool.Get(\"name\"); name.Exists() {\n\t\t\t\t\tfuncDecl, _ = sjson.Set(funcDecl, \"name\", name.String())\n\t\t\t\t}\n\t\t\t\tif desc := tool.Get(\"description\"); desc.Exists() {\n\t\t\t\t\tfuncDecl, _ = sjson.Set(funcDecl, \"description\", desc.String())\n\t\t\t\t}\n\t\t\t\tif params := tool.Get(\"parameters\"); params.Exists() {\n\t\t\t\t\tfuncDecl, _ = sjson.SetRaw(funcDecl, \"parametersJsonSchema\", params.Raw)\n\t\t\t\t}\n\n\t\t\t\tgeminiTools, _ = sjson.SetRaw(geminiTools, \"0.functionDeclarations.-1\", funcDecl)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\t// Only add tools if there are function declarations\n\t\tif funcDecls := gjson.Get(geminiTools, \"0.functionDeclarations\"); funcDecls.Exists() && len(funcDecls.Array()) > 0 {\n\t\t\tout, _ = sjson.SetRaw(out, \"tools\", geminiTools)\n\t\t}\n\t}\n\n\t// Handle generation config from OpenAI format\n\tif maxOutputTokens := root.Get(\"max_output_tokens\"); maxOutputTokens.Exists() {\n\t\tgenConfig := `{\"maxOutputTokens\":0}`\n\t\tgenConfig, _ = sjson.Set(genConfig, \"maxOutputTokens\", maxOutputTokens.Int())\n\t\tout, _ = sjson.SetRaw(out, \"generationConfig\", genConfig)\n\t}\n\n\t// Handle temperature if present\n\tif temperature := root.Get(\"temperature\"); temperature.Exists() {\n\t\tif !gjson.Get(out, \"generationConfig\").Exists() {\n\t\t\tout, _ = sjson.SetRaw(out, \"generationConfig\", `{}`)\n\t\t}\n\t\tout, _ = sjson.Set(out, \"generationConfig.temperature\", temperature.Float())\n\t}\n\n\t// Handle top_p if present\n\tif topP := root.Get(\"top_p\"); topP.Exists() {\n\t\tif !gjson.Get(out, \"generationConfig\").Exists() {\n\t\t\tout, _ = sjson.SetRaw(out, \"generationConfig\", `{}`)\n\t\t}\n\t\tout, _ = sjson.Set(out, \"generationConfig.topP\", topP.Float())\n\t}\n\n\t// Handle stop sequences\n\tif stopSequences := root.Get(\"stop_sequences\"); stopSequences.Exists() && stopSequences.IsArray() {\n\t\tif !gjson.Get(out, \"generationConfig\").Exists() {\n\t\t\tout, _ = sjson.SetRaw(out, \"generationConfig\", `{}`)\n\t\t}\n\t\tvar sequences []string\n\t\tstopSequences.ForEach(func(_, seq gjson.Result) bool {\n\t\t\tsequences = append(sequences, seq.String())\n\t\t\treturn true\n\t\t})\n\t\tout, _ = sjson.Set(out, \"generationConfig.stopSequences\", sequences)\n\t}\n\n\t// Apply thinking configuration: convert OpenAI Responses API reasoning.effort to Gemini thinkingConfig.\n\t// Inline translation-only mapping; capability checks happen later in ApplyThinking.\n\tre := root.Get(\"reasoning.effort\")\n\tif re.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(re.String()))\n\t\tif effort != \"\" {\n\t\t\tthinkingPath := \"generationConfig.thinkingConfig\"\n\t\t\tif effort == \"auto\" {\n\t\t\t\tout, _ = sjson.Set(out, thinkingPath+\".thinkingBudget\", -1)\n\t\t\t\tout, _ = sjson.Set(out, thinkingPath+\".includeThoughts\", true)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.Set(out, thinkingPath+\".thinkingLevel\", effort)\n\t\t\t\tout, _ = sjson.Set(out, thinkingPath+\".includeThoughts\", effort != \"none\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := []byte(out)\n\tresult = common.AttachDefaultSafetySettings(result, \"safetySettings\")\n\treturn result\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/responses/gemini_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\ntype geminiToResponsesState struct {\n\tSeq        int\n\tResponseID string\n\tCreatedAt  int64\n\tStarted    bool\n\n\t// message aggregation\n\tMsgOpened    bool\n\tMsgClosed    bool\n\tMsgIndex     int\n\tCurrentMsgID string\n\tTextBuf      strings.Builder\n\tItemTextBuf  strings.Builder\n\n\t// reasoning aggregation\n\tReasoningOpened bool\n\tReasoningIndex  int\n\tReasoningItemID string\n\tReasoningEnc    string\n\tReasoningBuf    strings.Builder\n\tReasoningClosed bool\n\n\t// function call aggregation (keyed by output_index)\n\tNextIndex   int\n\tFuncArgsBuf map[int]*strings.Builder\n\tFuncNames   map[int]string\n\tFuncCallIDs map[int]string\n\tFuncDone    map[int]bool\n}\n\n// responseIDCounter provides a process-wide unique counter for synthesized response identifiers.\nvar responseIDCounter uint64\n\n// funcCallIDCounter provides a process-wide unique counter for function call identifiers.\nvar funcCallIDCounter uint64\n\nfunc pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {\n\tif len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {\n\t\treturn originalRequestRawJSON\n\t}\n\tif len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {\n\t\treturn requestRawJSON\n\t}\n\treturn nil\n}\n\nfunc unwrapRequestRoot(root gjson.Result) gjson.Result {\n\treq := root.Get(\"request\")\n\tif !req.Exists() {\n\t\treturn root\n\t}\n\tif req.Get(\"model\").Exists() || req.Get(\"input\").Exists() || req.Get(\"instructions\").Exists() {\n\t\treturn req\n\t}\n\treturn root\n}\n\nfunc unwrapGeminiResponseRoot(root gjson.Result) gjson.Result {\n\tresp := root.Get(\"response\")\n\tif !resp.Exists() {\n\t\treturn root\n\t}\n\t// Vertex-style Gemini responses wrap the actual payload in a \"response\" object.\n\tif resp.Get(\"candidates\").Exists() || resp.Get(\"responseId\").Exists() || resp.Get(\"usageMetadata\").Exists() {\n\t\treturn resp\n\t}\n\treturn root\n}\n\nfunc emitEvent(event string, payload string) string {\n\treturn fmt.Sprintf(\"event: %s\\ndata: %s\", event, payload)\n}\n\n// ConvertGeminiResponseToOpenAIResponses converts Gemini SSE chunks into OpenAI Responses SSE events.\nfunc ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &geminiToResponsesState{\n\t\t\tFuncArgsBuf: make(map[int]*strings.Builder),\n\t\t\tFuncNames:   make(map[int]string),\n\t\t\tFuncCallIDs: make(map[int]string),\n\t\t\tFuncDone:    make(map[int]bool),\n\t\t}\n\t}\n\tst := (*param).(*geminiToResponsesState)\n\tif st.FuncArgsBuf == nil {\n\t\tst.FuncArgsBuf = make(map[int]*strings.Builder)\n\t}\n\tif st.FuncNames == nil {\n\t\tst.FuncNames = make(map[int]string)\n\t}\n\tif st.FuncCallIDs == nil {\n\t\tst.FuncCallIDs = make(map[int]string)\n\t}\n\tif st.FuncDone == nil {\n\t\tst.FuncDone = make(map[int]bool)\n\t}\n\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\trawJSON = bytes.TrimSpace(rawJSON)\n\tif len(rawJSON) == 0 || bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\troot := gjson.ParseBytes(rawJSON)\n\tif !root.Exists() {\n\t\treturn []string{}\n\t}\n\troot = unwrapGeminiResponseRoot(root)\n\n\tvar out []string\n\tnextSeq := func() int { st.Seq++; return st.Seq }\n\n\t// Helper to finalize reasoning summary events in correct order.\n\t// It emits response.reasoning_summary_text.done followed by\n\t// response.reasoning_summary_part.done exactly once.\n\tfinalizeReasoning := func() {\n\t\tif !st.ReasoningOpened || st.ReasoningClosed {\n\t\t\treturn\n\t\t}\n\t\tfull := st.ReasoningBuf.String()\n\t\ttextDone := `{\"type\":\"response.reasoning_summary_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"text\":\"\"}`\n\t\ttextDone, _ = sjson.Set(textDone, \"sequence_number\", nextSeq())\n\t\ttextDone, _ = sjson.Set(textDone, \"item_id\", st.ReasoningItemID)\n\t\ttextDone, _ = sjson.Set(textDone, \"output_index\", st.ReasoningIndex)\n\t\ttextDone, _ = sjson.Set(textDone, \"text\", full)\n\t\tout = append(out, emitEvent(\"response.reasoning_summary_text.done\", textDone))\n\n\t\tpartDone := `{\"type\":\"response.reasoning_summary_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.ReasoningItemID)\n\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", st.ReasoningIndex)\n\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", full)\n\t\tout = append(out, emitEvent(\"response.reasoning_summary_part.done\", partDone))\n\n\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"encrypted_content\":\"\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]}}`\n\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", st.ReasoningItemID)\n\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", st.ReasoningIndex)\n\t\titemDone, _ = sjson.Set(itemDone, \"item.encrypted_content\", st.ReasoningEnc)\n\t\titemDone, _ = sjson.Set(itemDone, \"item.summary.0.text\", full)\n\t\tout = append(out, emitEvent(\"response.output_item.done\", itemDone))\n\n\t\tst.ReasoningClosed = true\n\t}\n\n\t// Helper to finalize the assistant message in correct order.\n\t// It emits response.output_text.done, response.content_part.done,\n\t// and response.output_item.done exactly once.\n\tfinalizeMessage := func() {\n\t\tif !st.MsgOpened || st.MsgClosed {\n\t\t\treturn\n\t\t}\n\t\tfullText := st.ItemTextBuf.String()\n\t\tdone := `{\"type\":\"response.output_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"text\":\"\",\"logprobs\":[]}`\n\t\tdone, _ = sjson.Set(done, \"sequence_number\", nextSeq())\n\t\tdone, _ = sjson.Set(done, \"item_id\", st.CurrentMsgID)\n\t\tdone, _ = sjson.Set(done, \"output_index\", st.MsgIndex)\n\t\tdone, _ = sjson.Set(done, \"text\", fullText)\n\t\tout = append(out, emitEvent(\"response.output_text.done\", done))\n\t\tpartDone := `{\"type\":\"response.content_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.CurrentMsgID)\n\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", st.MsgIndex)\n\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", fullText)\n\t\tout = append(out, emitEvent(\"response.content_part.done\", partDone))\n\t\tfinal := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"\"}],\"role\":\"assistant\"}}`\n\t\tfinal, _ = sjson.Set(final, \"sequence_number\", nextSeq())\n\t\tfinal, _ = sjson.Set(final, \"output_index\", st.MsgIndex)\n\t\tfinal, _ = sjson.Set(final, \"item.id\", st.CurrentMsgID)\n\t\tfinal, _ = sjson.Set(final, \"item.content.0.text\", fullText)\n\t\tout = append(out, emitEvent(\"response.output_item.done\", final))\n\n\t\tst.MsgClosed = true\n\t}\n\n\t// Initialize per-response fields and emit created/in_progress once\n\tif !st.Started {\n\t\tst.ResponseID = root.Get(\"responseId\").String()\n\t\tif st.ResponseID == \"\" {\n\t\t\tst.ResponseID = fmt.Sprintf(\"resp_%x_%d\", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))\n\t\t}\n\t\tif !strings.HasPrefix(st.ResponseID, \"resp_\") {\n\t\t\tst.ResponseID = fmt.Sprintf(\"resp_%s\", st.ResponseID)\n\t\t}\n\t\tif v := root.Get(\"createTime\"); v.Exists() {\n\t\t\tif t, errParseCreateTime := time.Parse(time.RFC3339Nano, v.String()); errParseCreateTime == nil {\n\t\t\t\tst.CreatedAt = t.Unix()\n\t\t\t}\n\t\t}\n\t\tif st.CreatedAt == 0 {\n\t\t\tst.CreatedAt = time.Now().Unix()\n\t\t}\n\n\t\tcreated := `{\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"output\":[]}}`\n\t\tcreated, _ = sjson.Set(created, \"sequence_number\", nextSeq())\n\t\tcreated, _ = sjson.Set(created, \"response.id\", st.ResponseID)\n\t\tcreated, _ = sjson.Set(created, \"response.created_at\", st.CreatedAt)\n\t\tout = append(out, emitEvent(\"response.created\", created))\n\n\t\tinprog := `{\"type\":\"response.in_progress\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\"}}`\n\t\tinprog, _ = sjson.Set(inprog, \"sequence_number\", nextSeq())\n\t\tinprog, _ = sjson.Set(inprog, \"response.id\", st.ResponseID)\n\t\tinprog, _ = sjson.Set(inprog, \"response.created_at\", st.CreatedAt)\n\t\tout = append(out, emitEvent(\"response.in_progress\", inprog))\n\n\t\tst.Started = true\n\t\tst.NextIndex = 0\n\t}\n\n\t// Handle parts (text/thought/functionCall)\n\tif parts := root.Get(\"candidates.0.content.parts\"); parts.Exists() && parts.IsArray() {\n\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t// Reasoning text\n\t\t\tif part.Get(\"thought\").Bool() {\n\t\t\t\tif st.ReasoningClosed {\n\t\t\t\t\t// Ignore any late thought chunks after reasoning is finalized.\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tif sig := part.Get(\"thoughtSignature\"); sig.Exists() && sig.String() != \"\" && sig.String() != geminiResponsesThoughtSignature {\n\t\t\t\t\tst.ReasoningEnc = sig.String()\n\t\t\t\t} else if sig = part.Get(\"thought_signature\"); sig.Exists() && sig.String() != \"\" && sig.String() != geminiResponsesThoughtSignature {\n\t\t\t\t\tst.ReasoningEnc = sig.String()\n\t\t\t\t}\n\t\t\t\tif !st.ReasoningOpened {\n\t\t\t\t\tst.ReasoningOpened = true\n\t\t\t\t\tst.ReasoningIndex = st.NextIndex\n\t\t\t\t\tst.NextIndex++\n\t\t\t\t\tst.ReasoningItemID = fmt.Sprintf(\"rs_%s_%d\", st.ResponseID, st.ReasoningIndex)\n\t\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"encrypted_content\":\"\",\"summary\":[]}}`\n\t\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\t\titem, _ = sjson.Set(item, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\titem, _ = sjson.Set(item, \"item.id\", st.ReasoningItemID)\n\t\t\t\t\titem, _ = sjson.Set(item, \"item.encrypted_content\", st.ReasoningEnc)\n\t\t\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\t\t\t\t\tpartAdded := `{\"type\":\"response.reasoning_summary_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"sequence_number\", nextSeq())\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"item_id\", st.ReasoningItemID)\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_part.added\", partAdded))\n\t\t\t\t}\n\t\t\t\tif t := part.Get(\"text\"); t.Exists() && t.String() != \"\" {\n\t\t\t\t\tst.ReasoningBuf.WriteString(t.String())\n\t\t\t\t\tmsg := `{\"type\":\"response.reasoning_summary_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"delta\":\"\"}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.ReasoningItemID)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", t.String())\n\t\t\t\t\tout = append(out, emitEvent(\"response.reasoning_summary_text.delta\", msg))\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Assistant visible text\n\t\t\tif t := part.Get(\"text\"); t.Exists() && t.String() != \"\" {\n\t\t\t\t// Before emitting non-reasoning outputs, finalize reasoning if open.\n\t\t\t\tfinalizeReasoning()\n\t\t\t\tif !st.MsgOpened {\n\t\t\t\t\tst.MsgOpened = true\n\t\t\t\t\tst.MsgIndex = st.NextIndex\n\t\t\t\t\tst.NextIndex++\n\t\t\t\t\tst.CurrentMsgID = fmt.Sprintf(\"msg_%s_0\", st.ResponseID)\n\t\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}`\n\t\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\t\titem, _ = sjson.Set(item, \"output_index\", st.MsgIndex)\n\t\t\t\t\titem, _ = sjson.Set(item, \"item.id\", st.CurrentMsgID)\n\t\t\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\t\t\t\t\tpartAdded := `{\"type\":\"response.content_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"sequence_number\", nextSeq())\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"item_id\", st.CurrentMsgID)\n\t\t\t\t\tpartAdded, _ = sjson.Set(partAdded, \"output_index\", st.MsgIndex)\n\t\t\t\t\tout = append(out, emitEvent(\"response.content_part.added\", partAdded))\n\t\t\t\t\tst.ItemTextBuf.Reset()\n\t\t\t\t}\n\t\t\t\tst.TextBuf.WriteString(t.String())\n\t\t\t\tst.ItemTextBuf.WriteString(t.String())\n\t\t\t\tmsg := `{\"type\":\"response.output_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"delta\":\"\",\"logprobs\":[]}`\n\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.CurrentMsgID)\n\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", st.MsgIndex)\n\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", t.String())\n\t\t\t\tout = append(out, emitEvent(\"response.output_text.delta\", msg))\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Function call\n\t\t\tif fc := part.Get(\"functionCall\"); fc.Exists() {\n\t\t\t\t// Before emitting function-call outputs, finalize reasoning and the message (if open).\n\t\t\t\t// Responses streaming requires message done events before the next output_item.added.\n\t\t\t\tfinalizeReasoning()\n\t\t\t\tfinalizeMessage()\n\t\t\t\tname := fc.Get(\"name\").String()\n\t\t\t\tidx := st.NextIndex\n\t\t\t\tst.NextIndex++\n\t\t\t\t// Ensure buffers\n\t\t\t\tif st.FuncArgsBuf[idx] == nil {\n\t\t\t\t\tst.FuncArgsBuf[idx] = &strings.Builder{}\n\t\t\t\t}\n\t\t\t\tif st.FuncCallIDs[idx] == \"\" {\n\t\t\t\t\tst.FuncCallIDs[idx] = fmt.Sprintf(\"call_%d_%d\", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))\n\t\t\t\t}\n\t\t\t\tst.FuncNames[idx] = name\n\n\t\t\t\targsJSON := \"{}\"\n\t\t\t\tif args := fc.Get(\"args\"); args.Exists() {\n\t\t\t\t\targsJSON = args.Raw\n\t\t\t\t}\n\t\t\t\tif st.FuncArgsBuf[idx].Len() == 0 && argsJSON != \"\" {\n\t\t\t\t\tst.FuncArgsBuf[idx].WriteString(argsJSON)\n\t\t\t\t}\n\n\t\t\t\t// Emit item.added for function call\n\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\titem, _ = sjson.Set(item, \"output_index\", idx)\n\t\t\t\titem, _ = sjson.Set(item, \"item.id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\titem, _ = sjson.Set(item, \"item.call_id\", st.FuncCallIDs[idx])\n\t\t\t\titem, _ = sjson.Set(item, \"item.name\", name)\n\t\t\t\tout = append(out, emitEvent(\"response.output_item.added\", item))\n\n\t\t\t\t// Emit arguments delta (full args in one chunk).\n\t\t\t\t// When Gemini omits args, emit \"{}\" to keep Responses streaming event order consistent.\n\t\t\t\tif argsJSON != \"\" {\n\t\t\t\t\tad := `{\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"delta\":\"\"}`\n\t\t\t\t\tad, _ = sjson.Set(ad, \"sequence_number\", nextSeq())\n\t\t\t\t\tad, _ = sjson.Set(ad, \"item_id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\t\tad, _ = sjson.Set(ad, \"output_index\", idx)\n\t\t\t\t\tad, _ = sjson.Set(ad, \"delta\", argsJSON)\n\t\t\t\t\tout = append(out, emitEvent(\"response.function_call_arguments.delta\", ad))\n\t\t\t\t}\n\n\t\t\t\t// Gemini emits the full function call payload at once, so we can finalize it immediately.\n\t\t\t\tif !st.FuncDone[idx] {\n\t\t\t\t\tfcDone := `{\"type\":\"response.function_call_arguments.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"arguments\":\"\"}`\n\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"sequence_number\", nextSeq())\n\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"item_id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"output_index\", idx)\n\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"arguments\", argsJSON)\n\t\t\t\t\tout = append(out, emitEvent(\"response.function_call_arguments.done\", fcDone))\n\n\t\t\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", idx)\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.arguments\", argsJSON)\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.call_id\", st.FuncCallIDs[idx])\n\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.name\", st.FuncNames[idx])\n\t\t\t\t\tout = append(out, emitEvent(\"response.output_item.done\", itemDone))\n\n\t\t\t\t\tst.FuncDone[idx] = true\n\t\t\t\t}\n\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Finalization on finishReason\n\tif fr := root.Get(\"candidates.0.finishReason\"); fr.Exists() && fr.String() != \"\" {\n\t\t// Finalize reasoning first to keep ordering tight with last delta\n\t\tfinalizeReasoning()\n\t\tfinalizeMessage()\n\n\t\t// Close function calls\n\t\tif len(st.FuncArgsBuf) > 0 {\n\t\t\t// sort indices (small N); avoid extra imports\n\t\t\tidxs := make([]int, 0, len(st.FuncArgsBuf))\n\t\t\tfor idx := range st.FuncArgsBuf {\n\t\t\t\tidxs = append(idxs, idx)\n\t\t\t}\n\t\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, idx := range idxs {\n\t\t\t\tif st.FuncDone[idx] {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\targs := \"{}\"\n\t\t\t\tif b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {\n\t\t\t\t\targs = b.String()\n\t\t\t\t}\n\t\t\t\tfcDone := `{\"type\":\"response.function_call_arguments.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"arguments\":\"\"}`\n\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"sequence_number\", nextSeq())\n\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"item_id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"output_index\", idx)\n\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"arguments\", args)\n\t\t\t\tout = append(out, emitEvent(\"response.function_call_arguments.done\", fcDone))\n\n\t\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", idx)\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.arguments\", args)\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.call_id\", st.FuncCallIDs[idx])\n\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.name\", st.FuncNames[idx])\n\t\t\t\tout = append(out, emitEvent(\"response.output_item.done\", itemDone))\n\n\t\t\t\tst.FuncDone[idx] = true\n\t\t\t}\n\t\t}\n\n\t\t// Reasoning already finalized above if present\n\n\t\t// Build response.completed with aggregated outputs and request echo fields\n\t\tcompleted := `{\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}`\n\t\tcompleted, _ = sjson.Set(completed, \"sequence_number\", nextSeq())\n\t\tcompleted, _ = sjson.Set(completed, \"response.id\", st.ResponseID)\n\t\tcompleted, _ = sjson.Set(completed, \"response.created_at\", st.CreatedAt)\n\n\t\tif reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 {\n\t\t\treq := unwrapRequestRoot(gjson.ParseBytes(reqJSON))\n\t\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.instructions\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_output_tokens\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_tool_calls\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.model\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.parallel_tool_calls\", v.Bool())\n\t\t\t}\n\t\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.previous_response_id\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.prompt_cache_key\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.reasoning\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.safety_identifier\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.service_tier\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.store\", v.Bool())\n\t\t\t}\n\t\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.temperature\", v.Float())\n\t\t\t}\n\t\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.text\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tool_choice\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tools\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_logprobs\", v.Int())\n\t\t\t}\n\t\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_p\", v.Float())\n\t\t\t}\n\t\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.truncation\", v.String())\n\t\t\t}\n\t\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.user\", v.Value())\n\t\t\t}\n\t\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.metadata\", v.Value())\n\t\t\t}\n\t\t}\n\n\t\t// Compose outputs in output_index order.\n\t\toutputsWrapper := `{\"arr\":[]}`\n\t\tfor idx := 0; idx < st.NextIndex; idx++ {\n\t\t\tif st.ReasoningOpened && idx == st.ReasoningIndex {\n\t\t\t\titem := `{\"id\":\"\",\"type\":\"reasoning\",\"encrypted_content\":\"\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]}`\n\t\t\t\titem, _ = sjson.Set(item, \"id\", st.ReasoningItemID)\n\t\t\t\titem, _ = sjson.Set(item, \"encrypted_content\", st.ReasoningEnc)\n\t\t\t\titem, _ = sjson.Set(item, \"summary.0.text\", st.ReasoningBuf.String())\n\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif st.MsgOpened && idx == st.MsgIndex {\n\t\t\t\titem := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\t\t\titem, _ = sjson.Set(item, \"id\", st.CurrentMsgID)\n\t\t\t\titem, _ = sjson.Set(item, \"content.0.text\", st.TextBuf.String())\n\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif callID, ok := st.FuncCallIDs[idx]; ok && callID != \"\" {\n\t\t\t\targs := \"{}\"\n\t\t\t\tif b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {\n\t\t\t\t\targs = b.String()\n\t\t\t\t}\n\t\t\t\titem := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\titem, _ = sjson.Set(item, \"arguments\", args)\n\t\t\t\titem, _ = sjson.Set(item, \"call_id\", callID)\n\t\t\t\titem, _ = sjson.Set(item, \"name\", st.FuncNames[idx])\n\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t}\n\t\t}\n\t\tif gjson.Get(outputsWrapper, \"arr.#\").Int() > 0 {\n\t\t\tcompleted, _ = sjson.SetRaw(completed, \"response.output\", gjson.Get(outputsWrapper, \"arr\").Raw)\n\t\t}\n\n\t\t// usage mapping\n\t\tif um := root.Get(\"usageMetadata\"); um.Exists() {\n\t\t\t// input tokens = prompt only (thoughts go to output)\n\t\t\tinput := um.Get(\"promptTokenCount\").Int()\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens\", input)\n\t\t\t// cached token details: align with OpenAI \"cached_tokens\" semantics.\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens_details.cached_tokens\", um.Get(\"cachedContentTokenCount\").Int())\n\t\t\t// output tokens\n\t\t\tif v := um.Get(\"candidatesTokenCount\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens\", v.Int())\n\t\t\t} else {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens\", 0)\n\t\t\t}\n\t\t\tif v := um.Get(\"thoughtsTokenCount\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens_details.reasoning_tokens\", v.Int())\n\t\t\t} else {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens_details.reasoning_tokens\", 0)\n\t\t\t}\n\t\t\tif v := um.Get(\"totalTokenCount\"); v.Exists() {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.total_tokens\", v.Int())\n\t\t\t} else {\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.total_tokens\", 0)\n\t\t\t}\n\t\t}\n\n\t\tout = append(out, emitEvent(\"response.completed\", completed))\n\t}\n\n\treturn out\n}\n\n// ConvertGeminiResponseToOpenAIResponsesNonStream aggregates Gemini response JSON into a single OpenAI Responses JSON object.\nfunc ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\troot := gjson.ParseBytes(rawJSON)\n\troot = unwrapGeminiResponseRoot(root)\n\n\t// Base response scaffold\n\tresp := `{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"incomplete_details\":null}`\n\n\t// id: prefer provider responseId, otherwise synthesize\n\tid := root.Get(\"responseId\").String()\n\tif id == \"\" {\n\t\tid = fmt.Sprintf(\"resp_%x_%d\", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))\n\t}\n\t// Normalize to response-style id (prefix resp_ if missing)\n\tif !strings.HasPrefix(id, \"resp_\") {\n\t\tid = fmt.Sprintf(\"resp_%s\", id)\n\t}\n\tresp, _ = sjson.Set(resp, \"id\", id)\n\n\t// created_at: map from createTime if available\n\tcreatedAt := time.Now().Unix()\n\tif v := root.Get(\"createTime\"); v.Exists() {\n\t\tif t, errParseCreateTime := time.Parse(time.RFC3339Nano, v.String()); errParseCreateTime == nil {\n\t\t\tcreatedAt = t.Unix()\n\t\t}\n\t}\n\tresp, _ = sjson.Set(resp, \"created_at\", createdAt)\n\n\t// Echo request fields when present; fallback model from response modelVersion\n\tif reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 {\n\t\treq := unwrapRequestRoot(gjson.ParseBytes(reqJSON))\n\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"instructions\", v.String())\n\t\t}\n\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"max_output_tokens\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"max_tool_calls\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t\t} else if v = root.Get(\"modelVersion\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t\t}\n\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"parallel_tool_calls\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"previous_response_id\", v.String())\n\t\t}\n\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"prompt_cache_key\", v.String())\n\t\t}\n\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"reasoning\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"safety_identifier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"service_tier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"store\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"temperature\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"text\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"tool_choice\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"tools\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"top_logprobs\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"top_p\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"truncation\", v.String())\n\t\t}\n\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"user\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"metadata\", v.Value())\n\t\t}\n\t} else if v := root.Get(\"modelVersion\"); v.Exists() {\n\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t}\n\n\t// Build outputs from candidates[0].content.parts\n\tvar reasoningText strings.Builder\n\tvar reasoningEncrypted string\n\tvar messageText strings.Builder\n\tvar haveMessage bool\n\n\thaveOutput := false\n\tensureOutput := func() {\n\t\tif haveOutput {\n\t\t\treturn\n\t\t}\n\t\tresp, _ = sjson.SetRaw(resp, \"output\", \"[]\")\n\t\thaveOutput = true\n\t}\n\tappendOutput := func(itemJSON string) {\n\t\tensureOutput()\n\t\tresp, _ = sjson.SetRaw(resp, \"output.-1\", itemJSON)\n\t}\n\n\tif parts := root.Get(\"candidates.0.content.parts\"); parts.Exists() && parts.IsArray() {\n\t\tparts.ForEach(func(_, p gjson.Result) bool {\n\t\t\tif p.Get(\"thought\").Bool() {\n\t\t\t\tif t := p.Get(\"text\"); t.Exists() {\n\t\t\t\t\treasoningText.WriteString(t.String())\n\t\t\t\t}\n\t\t\t\tif sig := p.Get(\"thoughtSignature\"); sig.Exists() && sig.String() != \"\" {\n\t\t\t\t\treasoningEncrypted = sig.String()\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif t := p.Get(\"text\"); t.Exists() && t.String() != \"\" {\n\t\t\t\tmessageText.WriteString(t.String())\n\t\t\t\thaveMessage = true\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif fc := p.Get(\"functionCall\"); fc.Exists() {\n\t\t\t\tname := fc.Get(\"name\").String()\n\t\t\t\targs := fc.Get(\"args\")\n\t\t\t\tcallID := fmt.Sprintf(\"call_%x_%d\", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))\n\t\t\t\titemJSON := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\t\titemJSON, _ = sjson.Set(itemJSON, \"id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\titemJSON, _ = sjson.Set(itemJSON, \"call_id\", callID)\n\t\t\t\titemJSON, _ = sjson.Set(itemJSON, \"name\", name)\n\t\t\t\targsStr := \"\"\n\t\t\t\tif args.Exists() {\n\t\t\t\t\targsStr = args.Raw\n\t\t\t\t}\n\t\t\t\titemJSON, _ = sjson.Set(itemJSON, \"arguments\", argsStr)\n\t\t\t\tappendOutput(itemJSON)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Reasoning output item\n\tif reasoningText.Len() > 0 || reasoningEncrypted != \"\" {\n\t\trid := strings.TrimPrefix(id, \"resp_\")\n\t\titemJSON := `{\"id\":\"\",\"type\":\"reasoning\",\"encrypted_content\":\"\"}`\n\t\titemJSON, _ = sjson.Set(itemJSON, \"id\", fmt.Sprintf(\"rs_%s\", rid))\n\t\titemJSON, _ = sjson.Set(itemJSON, \"encrypted_content\", reasoningEncrypted)\n\t\tif reasoningText.Len() > 0 {\n\t\t\tsummaryJSON := `{\"type\":\"summary_text\",\"text\":\"\"}`\n\t\t\tsummaryJSON, _ = sjson.Set(summaryJSON, \"text\", reasoningText.String())\n\t\t\titemJSON, _ = sjson.SetRaw(itemJSON, \"summary\", \"[]\")\n\t\t\titemJSON, _ = sjson.SetRaw(itemJSON, \"summary.-1\", summaryJSON)\n\t\t}\n\t\tappendOutput(itemJSON)\n\t}\n\n\t// Assistant message output item\n\tif haveMessage {\n\t\titemJSON := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\titemJSON, _ = sjson.Set(itemJSON, \"id\", fmt.Sprintf(\"msg_%s_0\", strings.TrimPrefix(id, \"resp_\")))\n\t\titemJSON, _ = sjson.Set(itemJSON, \"content.0.text\", messageText.String())\n\t\tappendOutput(itemJSON)\n\t}\n\n\t// usage mapping\n\tif um := root.Get(\"usageMetadata\"); um.Exists() {\n\t\t// input tokens = prompt only (thoughts go to output)\n\t\tinput := um.Get(\"promptTokenCount\").Int()\n\t\tresp, _ = sjson.Set(resp, \"usage.input_tokens\", input)\n\t\t// cached token details: align with OpenAI \"cached_tokens\" semantics.\n\t\tresp, _ = sjson.Set(resp, \"usage.input_tokens_details.cached_tokens\", um.Get(\"cachedContentTokenCount\").Int())\n\t\t// output tokens\n\t\tif v := um.Get(\"candidatesTokenCount\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"usage.output_tokens\", v.Int())\n\t\t}\n\t\tif v := um.Get(\"thoughtsTokenCount\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"usage.output_tokens_details.reasoning_tokens\", v.Int())\n\t\t}\n\t\tif v := um.Get(\"totalTokenCount\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"usage.total_tokens\", v.Int())\n\t\t}\n\t}\n\n\treturn resp\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go",
    "content": "package responses\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc parseSSEEvent(t *testing.T, chunk string) (string, gjson.Result) {\n\tt.Helper()\n\n\tlines := strings.Split(chunk, \"\\n\")\n\tif len(lines) < 2 {\n\t\tt.Fatalf(\"unexpected SSE chunk: %q\", chunk)\n\t}\n\n\tevent := strings.TrimSpace(strings.TrimPrefix(lines[0], \"event:\"))\n\tdataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], \"data:\"))\n\tif !gjson.Valid(dataLine) {\n\t\tt.Fatalf(\"invalid SSE data JSON: %q\", dataLine)\n\t}\n\treturn event, gjson.Parse(dataLine)\n}\n\nfunc TestConvertGeminiResponseToOpenAIResponses_UnwrapAndAggregateText(t *testing.T) {\n\t// Vertex-style Gemini stream wraps the actual response payload under \"response\".\n\t// This test ensures we unwrap and that output_text.done contains the full text.\n\tin := []string{\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\"}]}}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"让\"}]}}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"我先\"}]}}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"了解\"}]}}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"mcp__serena__list_dir\",\"args\":{\"recursive\":false,\"relative_path\":\"internal\"},\"id\":\"toolu_1\"}}]}}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":5,\"totalTokenCount\":15,\"cachedContentTokenCount\":2},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t}\n\n\toriginalReq := []byte(`{\"instructions\":\"test instructions\",\"model\":\"gpt-5\",\"max_output_tokens\":123}`)\n\n\tvar param any\n\tvar out []string\n\tfor _, line := range in {\n\t\tout = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), \"test-model\", originalReq, nil, []byte(line), &param)...)\n\t}\n\n\tvar (\n\t\tgotTextDone     bool\n\t\tgotMessageDone  bool\n\t\tgotResponseDone bool\n\t\tgotFuncDone     bool\n\n\t\ttextDone     string\n\t\tmessageText  string\n\t\tresponseID   string\n\t\tinstructions string\n\t\tcachedTokens int64\n\n\t\tfuncName string\n\t\tfuncArgs string\n\n\t\tposTextDone    = -1\n\t\tposPartDone    = -1\n\t\tposMessageDone = -1\n\t\tposFuncAdded   = -1\n\t)\n\n\tfor i, chunk := range out {\n\t\tev, data := parseSSEEvent(t, chunk)\n\t\tswitch ev {\n\t\tcase \"response.output_text.done\":\n\t\t\tgotTextDone = true\n\t\t\tif posTextDone == -1 {\n\t\t\t\tposTextDone = i\n\t\t\t}\n\t\t\ttextDone = data.Get(\"text\").String()\n\t\tcase \"response.content_part.done\":\n\t\t\tif posPartDone == -1 {\n\t\t\t\tposPartDone = i\n\t\t\t}\n\t\tcase \"response.output_item.done\":\n\t\t\tswitch data.Get(\"item.type\").String() {\n\t\t\tcase \"message\":\n\t\t\t\tgotMessageDone = true\n\t\t\t\tif posMessageDone == -1 {\n\t\t\t\t\tposMessageDone = i\n\t\t\t\t}\n\t\t\t\tmessageText = data.Get(\"item.content.0.text\").String()\n\t\t\tcase \"function_call\":\n\t\t\t\tgotFuncDone = true\n\t\t\t\tfuncName = data.Get(\"item.name\").String()\n\t\t\t\tfuncArgs = data.Get(\"item.arguments\").String()\n\t\t\t}\n\t\tcase \"response.output_item.added\":\n\t\t\tif data.Get(\"item.type\").String() == \"function_call\" && posFuncAdded == -1 {\n\t\t\t\tposFuncAdded = i\n\t\t\t}\n\t\tcase \"response.completed\":\n\t\t\tgotResponseDone = true\n\t\t\tresponseID = data.Get(\"response.id\").String()\n\t\t\tinstructions = data.Get(\"response.instructions\").String()\n\t\t\tcachedTokens = data.Get(\"response.usage.input_tokens_details.cached_tokens\").Int()\n\t\t}\n\t}\n\n\tif !gotTextDone {\n\t\tt.Fatalf(\"missing response.output_text.done event\")\n\t}\n\tif posTextDone == -1 || posPartDone == -1 || posMessageDone == -1 || posFuncAdded == -1 {\n\t\tt.Fatalf(\"missing ordering events: textDone=%d partDone=%d messageDone=%d funcAdded=%d\", posTextDone, posPartDone, posMessageDone, posFuncAdded)\n\t}\n\tif !(posTextDone < posPartDone && posPartDone < posMessageDone && posMessageDone < posFuncAdded) {\n\t\tt.Fatalf(\"unexpected message/function ordering: textDone=%d partDone=%d messageDone=%d funcAdded=%d\", posTextDone, posPartDone, posMessageDone, posFuncAdded)\n\t}\n\tif !gotMessageDone {\n\t\tt.Fatalf(\"missing message response.output_item.done event\")\n\t}\n\tif !gotFuncDone {\n\t\tt.Fatalf(\"missing function_call response.output_item.done event\")\n\t}\n\tif !gotResponseDone {\n\t\tt.Fatalf(\"missing response.completed event\")\n\t}\n\n\tif textDone != \"让我先了解\" {\n\t\tt.Fatalf(\"unexpected output_text.done text: got %q\", textDone)\n\t}\n\tif messageText != \"让我先了解\" {\n\t\tt.Fatalf(\"unexpected message done text: got %q\", messageText)\n\t}\n\n\tif responseID != \"resp_req_vrtx_1\" {\n\t\tt.Fatalf(\"unexpected response id: got %q\", responseID)\n\t}\n\tif instructions != \"test instructions\" {\n\t\tt.Fatalf(\"unexpected instructions echo: got %q\", instructions)\n\t}\n\tif cachedTokens != 2 {\n\t\tt.Fatalf(\"unexpected cached token count: got %d\", cachedTokens)\n\t}\n\n\tif funcName != \"mcp__serena__list_dir\" {\n\t\tt.Fatalf(\"unexpected function name: got %q\", funcName)\n\t}\n\tif !gjson.Valid(funcArgs) {\n\t\tt.Fatalf(\"invalid function arguments JSON: %q\", funcArgs)\n\t}\n\tif gjson.Get(funcArgs, \"recursive\").Bool() != false {\n\t\tt.Fatalf(\"unexpected recursive arg: %v\", gjson.Get(funcArgs, \"recursive\").Value())\n\t}\n\tif gjson.Get(funcArgs, \"relative_path\").String() != \"internal\" {\n\t\tt.Fatalf(\"unexpected relative_path arg: %q\", gjson.Get(funcArgs, \"relative_path\").String())\n\t}\n}\n\nfunc TestConvertGeminiResponseToOpenAIResponses_ReasoningEncryptedContent(t *testing.T) {\n\tsig := \"RXE0RENrZ0lDeEFDR0FJcVFOZDdjUzlleGFuRktRdFcvSzNyZ2MvWDNCcDQ4RmxSbGxOWUlOVU5kR1l1UHMrMGdkMVp0Vkg3ekdKU0g4YVljc2JjN3lNK0FrdGpTNUdqamI4T3Z0VVNETzdQd3pmcFhUOGl3U3hXUEJvTVFRQ09mWTFyMEtTWGZxUUlJakFqdmFGWk83RW1XRlBKckJVOVpkYzdDKw==\"\n\tin := []string{\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"thoughtSignature\":\"` + sig + `\",\"text\":\"\"}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_sig\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"a\"}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_sig\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"hello\"}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_sig\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\"}]},\"finishReason\":\"STOP\"}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_sig\"},\"traceId\":\"t1\"}`,\n\t}\n\n\tvar param any\n\tvar out []string\n\tfor _, line := range in {\n\t\tout = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), \"test-model\", nil, nil, []byte(line), &param)...)\n\t}\n\n\tvar (\n\t\taddedEnc string\n\t\tdoneEnc  string\n\t)\n\tfor _, chunk := range out {\n\t\tev, data := parseSSEEvent(t, chunk)\n\t\tswitch ev {\n\t\tcase \"response.output_item.added\":\n\t\t\tif data.Get(\"item.type\").String() == \"reasoning\" {\n\t\t\t\taddedEnc = data.Get(\"item.encrypted_content\").String()\n\t\t\t}\n\t\tcase \"response.output_item.done\":\n\t\t\tif data.Get(\"item.type\").String() == \"reasoning\" {\n\t\t\t\tdoneEnc = data.Get(\"item.encrypted_content\").String()\n\t\t\t}\n\t\t}\n\t}\n\n\tif addedEnc != sig {\n\t\tt.Fatalf(\"unexpected encrypted_content in response.output_item.added: got %q\", addedEnc)\n\t}\n\tif doneEnc != sig {\n\t\tt.Fatalf(\"unexpected encrypted_content in response.output_item.done: got %q\", doneEnc)\n\t}\n}\n\nfunc TestConvertGeminiResponseToOpenAIResponses_FunctionCallEventOrder(t *testing.T) {\n\tin := []string{\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"tool0\"}}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"tool1\"}}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"tool2\",\"args\":{\"a\":1}}}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":5,\"totalTokenCount\":15,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_1\"},\"traceId\":\"t1\"}`,\n\t}\n\n\tvar param any\n\tvar out []string\n\tfor _, line := range in {\n\t\tout = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), \"test-model\", nil, nil, []byte(line), &param)...)\n\t}\n\n\tposAdded := []int{-1, -1, -1}\n\tposArgsDelta := []int{-1, -1, -1}\n\tposArgsDone := []int{-1, -1, -1}\n\tposItemDone := []int{-1, -1, -1}\n\tposCompleted := -1\n\tdeltaByIndex := map[int]string{}\n\n\tfor i, chunk := range out {\n\t\tev, data := parseSSEEvent(t, chunk)\n\t\tswitch ev {\n\t\tcase \"response.output_item.added\":\n\t\t\tif data.Get(\"item.type\").String() != \"function_call\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidx := int(data.Get(\"output_index\").Int())\n\t\t\tif idx >= 0 && idx < len(posAdded) {\n\t\t\t\tposAdded[idx] = i\n\t\t\t}\n\t\tcase \"response.function_call_arguments.delta\":\n\t\t\tidx := int(data.Get(\"output_index\").Int())\n\t\t\tif idx >= 0 && idx < len(posArgsDelta) {\n\t\t\t\tposArgsDelta[idx] = i\n\t\t\t\tdeltaByIndex[idx] = data.Get(\"delta\").String()\n\t\t\t}\n\t\tcase \"response.function_call_arguments.done\":\n\t\t\tidx := int(data.Get(\"output_index\").Int())\n\t\t\tif idx >= 0 && idx < len(posArgsDone) {\n\t\t\t\tposArgsDone[idx] = i\n\t\t\t}\n\t\tcase \"response.output_item.done\":\n\t\t\tif data.Get(\"item.type\").String() != \"function_call\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidx := int(data.Get(\"output_index\").Int())\n\t\t\tif idx >= 0 && idx < len(posItemDone) {\n\t\t\t\tposItemDone[idx] = i\n\t\t\t}\n\t\tcase \"response.completed\":\n\t\t\tposCompleted = i\n\n\t\t\toutput := data.Get(\"response.output\")\n\t\t\tif !output.Exists() || !output.IsArray() {\n\t\t\t\tt.Fatalf(\"missing response.output in response.completed\")\n\t\t\t}\n\t\t\tif len(output.Array()) != 3 {\n\t\t\t\tt.Fatalf(\"unexpected response.output length: got %d\", len(output.Array()))\n\t\t\t}\n\t\t\tif data.Get(\"response.output.0.name\").String() != \"tool0\" || data.Get(\"response.output.0.arguments\").String() != \"{}\" {\n\t\t\t\tt.Fatalf(\"unexpected output[0]: %s\", data.Get(\"response.output.0\").Raw)\n\t\t\t}\n\t\t\tif data.Get(\"response.output.1.name\").String() != \"tool1\" || data.Get(\"response.output.1.arguments\").String() != \"{}\" {\n\t\t\t\tt.Fatalf(\"unexpected output[1]: %s\", data.Get(\"response.output.1\").Raw)\n\t\t\t}\n\t\t\tif data.Get(\"response.output.2.name\").String() != \"tool2\" {\n\t\t\t\tt.Fatalf(\"unexpected output[2] name: %s\", data.Get(\"response.output.2\").Raw)\n\t\t\t}\n\t\t\tif !gjson.Valid(data.Get(\"response.output.2.arguments\").String()) {\n\t\t\t\tt.Fatalf(\"unexpected output[2] arguments: %q\", data.Get(\"response.output.2.arguments\").String())\n\t\t\t}\n\t\t}\n\t}\n\n\tif posCompleted == -1 {\n\t\tt.Fatalf(\"missing response.completed event\")\n\t}\n\tfor idx := 0; idx < 3; idx++ {\n\t\tif posAdded[idx] == -1 || posArgsDelta[idx] == -1 || posArgsDone[idx] == -1 || posItemDone[idx] == -1 {\n\t\t\tt.Fatalf(\"missing function call events for output_index %d: added=%d argsDelta=%d argsDone=%d itemDone=%d\", idx, posAdded[idx], posArgsDelta[idx], posArgsDone[idx], posItemDone[idx])\n\t\t}\n\t\tif !(posAdded[idx] < posArgsDelta[idx] && posArgsDelta[idx] < posArgsDone[idx] && posArgsDone[idx] < posItemDone[idx]) {\n\t\t\tt.Fatalf(\"unexpected ordering for output_index %d: added=%d argsDelta=%d argsDone=%d itemDone=%d\", idx, posAdded[idx], posArgsDelta[idx], posArgsDone[idx], posItemDone[idx])\n\t\t}\n\t\tif idx > 0 && !(posItemDone[idx-1] < posAdded[idx]) {\n\t\t\tt.Fatalf(\"function call events overlap between %d and %d: prevDone=%d nextAdded=%d\", idx-1, idx, posItemDone[idx-1], posAdded[idx])\n\t\t}\n\t}\n\n\tif deltaByIndex[0] != \"{}\" {\n\t\tt.Fatalf(\"unexpected delta for output_index 0: got %q\", deltaByIndex[0])\n\t}\n\tif deltaByIndex[1] != \"{}\" {\n\t\tt.Fatalf(\"unexpected delta for output_index 1: got %q\", deltaByIndex[1])\n\t}\n\tif deltaByIndex[2] == \"\" || !gjson.Valid(deltaByIndex[2]) || gjson.Get(deltaByIndex[2], \"a\").Int() != 1 {\n\t\tt.Fatalf(\"unexpected delta for output_index 2: got %q\", deltaByIndex[2])\n\t}\n\tif !(posItemDone[2] < posCompleted) {\n\t\tt.Fatalf(\"response.completed should be after last output_item.done: last=%d completed=%d\", posItemDone[2], posCompleted)\n\t}\n}\n\nfunc TestConvertGeminiResponseToOpenAIResponses_ResponseOutputOrdering(t *testing.T) {\n\tin := []string{\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"tool0\",\"args\":{\"x\":\"y\"}}}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_2\"},\"traceId\":\"t2\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"hi\"}]}}],\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_2\"},\"traceId\":\"t2\"}`,\n\t\t`data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2,\"cachedContentTokenCount\":0},\"modelVersion\":\"test-model\",\"responseId\":\"req_vrtx_2\"},\"traceId\":\"t2\"}`,\n\t}\n\n\tvar param any\n\tvar out []string\n\tfor _, line := range in {\n\t\tout = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), \"test-model\", nil, nil, []byte(line), &param)...)\n\t}\n\n\tposFuncDone := -1\n\tposMsgAdded := -1\n\tposCompleted := -1\n\n\tfor i, chunk := range out {\n\t\tev, data := parseSSEEvent(t, chunk)\n\t\tswitch ev {\n\t\tcase \"response.output_item.done\":\n\t\t\tif data.Get(\"item.type\").String() == \"function_call\" && data.Get(\"output_index\").Int() == 0 {\n\t\t\t\tposFuncDone = i\n\t\t\t}\n\t\tcase \"response.output_item.added\":\n\t\t\tif data.Get(\"item.type\").String() == \"message\" && data.Get(\"output_index\").Int() == 1 {\n\t\t\t\tposMsgAdded = i\n\t\t\t}\n\t\tcase \"response.completed\":\n\t\t\tposCompleted = i\n\t\t\tif data.Get(\"response.output.0.type\").String() != \"function_call\" {\n\t\t\t\tt.Fatalf(\"expected response.output[0] to be function_call: %s\", data.Get(\"response.output.0\").Raw)\n\t\t\t}\n\t\t\tif data.Get(\"response.output.1.type\").String() != \"message\" {\n\t\t\t\tt.Fatalf(\"expected response.output[1] to be message: %s\", data.Get(\"response.output.1\").Raw)\n\t\t\t}\n\t\t\tif data.Get(\"response.output.1.content.0.text\").String() != \"hi\" {\n\t\t\t\tt.Fatalf(\"unexpected message text in response.output[1]: %s\", data.Get(\"response.output.1\").Raw)\n\t\t\t}\n\t\t}\n\t}\n\n\tif posFuncDone == -1 || posMsgAdded == -1 || posCompleted == -1 {\n\t\tt.Fatalf(\"missing required events: funcDone=%d msgAdded=%d completed=%d\", posFuncDone, posMsgAdded, posCompleted)\n\t}\n\tif !(posFuncDone < posMsgAdded) {\n\t\tt.Fatalf(\"expected function_call to complete before message is added: funcDone=%d msgAdded=%d\", posFuncDone, posMsgAdded)\n\t}\n\tif !(posMsgAdded < posCompleted) {\n\t\tt.Fatalf(\"expected response.completed after message added: msgAdded=%d completed=%d\", posMsgAdded, posCompleted)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/gemini/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tGemini,\n\t\tConvertOpenAIResponsesRequestToGemini,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertGeminiResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertGeminiResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/claude/gemini-cli_claude_request.go",
    "content": "// Package claude provides request translation functionality for Claude Code API compatibility.\n// This package handles the conversion of Claude Code API requests into Gemini CLI-compatible\n// JSON format, transforming message contents, system instructions, and tool declarations\n// into the format expected by Gemini CLI API clients. It performs JSON data transformation\n// to ensure compatibility between Claude Code API format and Gemini CLI API's expected format.\npackage claude\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiCLIClaudeThoughtSignature = \"skip_thought_signature_validator\"\n\n// ConvertClaudeRequestToCLI parses and transforms a Claude Code API request into Gemini CLI API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini CLI API.\n// The function performs the following transformations:\n// 1. Extracts the model information from the request\n// 2. Restructures the JSON to match Gemini CLI API format\n// 3. Converts system instructions to the expected format\n// 4. Maps message contents with proper role transformations\n// 5. Handles tool declarations and tool choices\n// 6. Maps generation configuration parameters\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the Claude Code API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini CLI API format\nfunc ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\n\t// Build output Gemini CLI request JSON\n\tout := `{\"model\":\"\",\"request\":{\"contents\":[]}}`\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// system instruction\n\tif systemResult := gjson.GetBytes(rawJSON, \"system\"); systemResult.IsArray() {\n\t\tsystemInstruction := `{\"role\":\"user\",\"parts\":[]}`\n\t\thasSystemParts := false\n\t\tsystemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {\n\t\t\tif systemPromptResult.Get(\"type\").String() == \"text\" {\n\t\t\t\ttextResult := systemPromptResult.Get(\"text\")\n\t\t\t\tif textResult.Type == gjson.String {\n\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", textResult.String())\n\t\t\t\t\tsystemInstruction, _ = sjson.SetRaw(systemInstruction, \"parts.-1\", part)\n\t\t\t\t\thasSystemParts = true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif hasSystemParts {\n\t\t\tout, _ = sjson.SetRaw(out, \"request.systemInstruction\", systemInstruction)\n\t\t}\n\t} else if systemResult.Type == gjson.String {\n\t\tout, _ = sjson.Set(out, \"request.systemInstruction.parts.-1.text\", systemResult.String())\n\t}\n\n\t// contents\n\tif messagesResult := gjson.GetBytes(rawJSON, \"messages\"); messagesResult.IsArray() {\n\t\tmessagesResult.ForEach(func(_, messageResult gjson.Result) bool {\n\t\t\troleResult := messageResult.Get(\"role\")\n\t\t\tif roleResult.Type != gjson.String {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\trole := roleResult.String()\n\t\t\tif role == \"assistant\" {\n\t\t\t\trole = \"model\"\n\t\t\t}\n\n\t\t\tcontentJSON := `{\"role\":\"\",\"parts\":[]}`\n\t\t\tcontentJSON, _ = sjson.Set(contentJSON, \"role\", role)\n\n\t\t\tcontentsResult := messageResult.Get(\"content\")\n\t\t\tif contentsResult.IsArray() {\n\t\t\t\tcontentsResult.ForEach(func(_, contentResult gjson.Result) bool {\n\t\t\t\t\tswitch contentResult.Get(\"type\").String() {\n\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentResult.Get(\"text\").String())\n\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\n\t\t\t\t\tcase \"tool_use\":\n\t\t\t\t\t\tfunctionName := contentResult.Get(\"name\").String()\n\t\t\t\t\t\tfunctionArgs := contentResult.Get(\"input\").String()\n\t\t\t\t\t\targsResult := gjson.Parse(functionArgs)\n\t\t\t\t\t\tif argsResult.IsObject() && gjson.Valid(functionArgs) {\n\t\t\t\t\t\t\tpart := `{\"thoughtSignature\":\"\",\"functionCall\":{\"name\":\"\",\"args\":{}}}`\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"thoughtSignature\", geminiCLIClaudeThoughtSignature)\n\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionCall.name\", functionName)\n\t\t\t\t\t\t\tpart, _ = sjson.SetRaw(part, \"functionCall.args\", functionArgs)\n\t\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool_result\":\n\t\t\t\t\t\ttoolCallID := contentResult.Get(\"tool_use_id\").String()\n\t\t\t\t\t\tif toolCallID == \"\" {\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfuncName := toolCallID\n\t\t\t\t\t\ttoolCallIDs := strings.Split(toolCallID, \"-\")\n\t\t\t\t\t\tif len(toolCallIDs) > 1 {\n\t\t\t\t\t\t\tfuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], \"-\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresponseData := contentResult.Get(\"content\").Raw\n\t\t\t\t\t\tpart := `{\"functionResponse\":{\"name\":\"\",\"response\":{\"result\":\"\"}}}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionResponse.name\", funcName)\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"functionResponse.response.result\", responseData)\n\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\n\t\t\t\t\tcase \"image\":\n\t\t\t\t\t\tsource := contentResult.Get(\"source\")\n\t\t\t\t\t\tif source.Get(\"type\").String() == \"base64\" {\n\t\t\t\t\t\t\tmimeType := source.Get(\"media_type\").String()\n\t\t\t\t\t\t\tdata := source.Get(\"data\").String()\n\t\t\t\t\t\t\tif mimeType != \"\" && data != \"\" {\n\t\t\t\t\t\t\t\tpart := `{\"inlineData\":{\"mime_type\":\"\",\"data\":\"\"}}`\n\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"inlineData.mime_type\", mimeType)\n\t\t\t\t\t\t\t\tpart, _ = sjson.Set(part, \"inlineData.data\", data)\n\t\t\t\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t\tout, _ = sjson.SetRaw(out, \"request.contents.-1\", contentJSON)\n\t\t\t} else if contentsResult.Type == gjson.String {\n\t\t\t\tpart := `{\"text\":\"\"}`\n\t\t\t\tpart, _ = sjson.Set(part, \"text\", contentsResult.String())\n\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"parts.-1\", part)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"request.contents.-1\", contentJSON)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// tools\n\tif toolsResult := gjson.GetBytes(rawJSON, \"tools\"); toolsResult.IsArray() {\n\t\thasTools := false\n\t\ttoolsResult.ForEach(func(_, toolResult gjson.Result) bool {\n\t\t\tinputSchemaResult := toolResult.Get(\"input_schema\")\n\t\t\tif inputSchemaResult.Exists() && inputSchemaResult.IsObject() {\n\t\t\t\tinputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw)\n\t\t\t\ttool, _ := sjson.Delete(toolResult.Raw, \"input_schema\")\n\t\t\t\ttool, _ = sjson.SetRaw(tool, \"parametersJsonSchema\", inputSchema)\n\t\t\t\ttool, _ = sjson.Delete(tool, \"strict\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"input_examples\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"type\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"cache_control\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"defer_loading\")\n\t\t\t\ttool, _ = sjson.Delete(tool, \"eager_input_streaming\")\n\t\t\t\tif gjson.Valid(tool) && gjson.Parse(tool).IsObject() {\n\t\t\t\t\tif !hasTools {\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"request.tools\", `[{\"functionDeclarations\":[]}]`)\n\t\t\t\t\t\thasTools = true\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"request.tools.0.functionDeclarations.-1\", tool)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tif !hasTools {\n\t\t\tout, _ = sjson.Delete(out, \"request.tools\")\n\t\t}\n\t}\n\n\t// tool_choice\n\ttoolChoiceResult := gjson.GetBytes(rawJSON, \"tool_choice\")\n\tif toolChoiceResult.Exists() {\n\t\ttoolChoiceType := \"\"\n\t\ttoolChoiceName := \"\"\n\t\tif toolChoiceResult.IsObject() {\n\t\t\ttoolChoiceType = toolChoiceResult.Get(\"type\").String()\n\t\t\ttoolChoiceName = toolChoiceResult.Get(\"name\").String()\n\t\t} else if toolChoiceResult.Type == gjson.String {\n\t\t\ttoolChoiceType = toolChoiceResult.String()\n\t\t}\n\n\t\tswitch toolChoiceType {\n\t\tcase \"auto\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"AUTO\")\n\t\tcase \"none\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"NONE\")\n\t\tcase \"any\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\tcase \"tool\":\n\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.mode\", \"ANY\")\n\t\t\tif toolChoiceName != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\", []string{toolChoiceName})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Map Anthropic thinking -> Gemini CLI thinkingConfig when enabled\n\t// Translator only does format conversion, ApplyThinking handles model capability validation.\n\tif t := gjson.GetBytes(rawJSON, \"thinking\"); t.Exists() && t.IsObject() {\n\t\tswitch t.Get(\"type\").String() {\n\t\tcase \"enabled\":\n\t\t\tif b := t.Get(\"budget_tokens\"); b.Exists() && b.Type == gjson.Number {\n\t\t\t\tbudget := int(b.Int())\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingBudget\", budget)\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t\t}\n\t\tcase \"adaptive\", \"auto\":\n\t\t\t// For adaptive thinking:\n\t\t\t// - If output_config.effort is explicitly present, pass through as thinkingLevel.\n\t\t\t// - Otherwise, treat it as \"enabled with target-model maximum\" and emit high.\n\t\t\t// ApplyThinking handles clamping to target model's supported levels.\n\t\t\teffort := \"\"\n\t\t\tif v := gjson.GetBytes(rawJSON, \"output_config.effort\"); v.Exists() && v.Type == gjson.String {\n\t\t\t\teffort = strings.ToLower(strings.TrimSpace(v.String()))\n\t\t\t}\n\t\t\tif effort != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingLevel\", effort)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.thinkingLevel\", \"high\")\n\t\t\t}\n\t\t\tout, _ = sjson.Set(out, \"request.generationConfig.thinkingConfig.includeThoughts\", true)\n\t\t}\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"temperature\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.temperature\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_p\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.topP\", v.Num)\n\t}\n\tif v := gjson.GetBytes(rawJSON, \"top_k\"); v.Exists() && v.Type == gjson.Number {\n\t\tout, _ = sjson.Set(out, \"request.generationConfig.topK\", v.Num)\n\t}\n\n\toutBytes := []byte(out)\n\toutBytes = common.AttachDefaultSafetySettings(outBytes, \"request.safetySettings\")\n\n\treturn outBytes\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go",
    "content": "package claude\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertClaudeRequestToCLI_ToolChoice_SpecificTool(t *testing.T) {\n\tinputJSON := []byte(`{\n\t\t\"model\": \"gemini-3-flash-preview\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"hi\"}\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t\"tools\": [\n\t\t\t{\n\t\t\t\t\"name\": \"json\",\n\t\t\t\t\"description\": \"A JSON tool\",\n\t\t\t\t\"input_schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {}\n\t\t\t\t}\n\t\t\t}\n\t\t],\n\t\t\"tool_choice\": {\"type\": \"tool\", \"name\": \"json\"}\n\t}`)\n\n\toutput := ConvertClaudeRequestToCLI(\"gemini-3-flash-preview\", inputJSON, false)\n\n\tif got := gjson.GetBytes(output, \"request.toolConfig.functionCallingConfig.mode\").String(); got != \"ANY\" {\n\t\tt.Fatalf(\"Expected request.toolConfig.functionCallingConfig.mode 'ANY', got '%s'\", got)\n\t}\n\tallowed := gjson.GetBytes(output, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\").Array()\n\tif len(allowed) != 1 || allowed[0].String() != \"json\" {\n\t\tt.Fatalf(\"Expected allowedFunctionNames ['json'], got %s\", gjson.GetBytes(output, \"request.toolConfig.functionCallingConfig.allowedFunctionNames\").Raw)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/claude/gemini-cli_claude_response.go",
    "content": "// Package claude provides response translation functionality for Claude Code API compatibility.\n// This package handles the conversion of backend client responses into Claude Code-compatible\n// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages\n// different response types including text content, thinking processes, and function calls.\n// The translation ensures proper sequencing of SSE events and maintains state across\n// multiple response chunks to provide a seamless streaming experience.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Params holds parameters for response conversion and maintains state across streaming chunks.\n// This structure tracks the current state of the response translation process to ensure\n// proper sequencing of SSE events and transitions between different content types.\ntype Params struct {\n\tHasFirstResponse bool // Indicates if the initial message_start event has been sent\n\tResponseType     int  // Current response type: 0=none, 1=content, 2=thinking, 3=function\n\tResponseIndex    int  // Index counter for content blocks in the streaming response\n\tHasContent       bool // Tracks whether any content (text, thinking, or tool use) has been output\n}\n\n// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.\nvar toolUseIDCounter uint64\n\n// ConvertGeminiCLIResponseToClaude performs sophisticated streaming response format conversion.\n// This function implements a complex state machine that translates backend client responses\n// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types\n// and handles state transitions between content blocks, thinking processes, and function calls.\n//\n// Response type states: 0=none, 1=content, 2=thinking, 3=function\n// The function maintains state across multiple calls to ensure proper SSE event sequencing.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Claude Code-compatible JSON response\nfunc ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &Params{\n\t\t\tHasFirstResponse: false,\n\t\t\tResponseType:     0,\n\t\t\tResponseIndex:    0,\n\t\t}\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\t// Only send message_stop if we have actually output content\n\t\tif (*param).(*Params).HasContent {\n\t\t\treturn []string{\n\t\t\t\t\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\\n\",\n\t\t\t}\n\t\t}\n\t\treturn []string{}\n\t}\n\n\t// Track whether tools are being used in this response chunk\n\tusedTool := false\n\toutput := \"\"\n\n\t// Initialize the streaming session with a message_start event\n\t// This is only sent for the very first response chunk to establish the streaming session\n\tif !(*param).(*Params).HasFirstResponse {\n\t\toutput = \"event: message_start\\n\"\n\n\t\t// Create the initial message structure with default values according to Claude Code API specification\n\t\t// This follows the Claude Code API specification for streaming message initialization\n\t\tmessageStartTemplate := `{\"type\": \"message_start\", \"message\": {\"id\": \"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [], \"model\": \"claude-3-5-sonnet-20241022\", \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 0, \"output_tokens\": 0}}}`\n\n\t\t// Override default values with actual response metadata if available from the Gemini CLI response\n\t\tif modelVersionResult := gjson.GetBytes(rawJSON, \"response.modelVersion\"); modelVersionResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.model\", modelVersionResult.String())\n\t\t}\n\t\tif responseIDResult := gjson.GetBytes(rawJSON, \"response.responseId\"); responseIDResult.Exists() {\n\t\t\tmessageStartTemplate, _ = sjson.Set(messageStartTemplate, \"message.id\", responseIDResult.String())\n\t\t}\n\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", messageStartTemplate)\n\n\t\t(*param).(*Params).HasFirstResponse = true\n\t}\n\n\t// Process the response parts array from the backend client\n\t// Each part can contain text content, thinking content, or function calls\n\tpartsResult := gjson.GetBytes(rawJSON, \"response.candidates.0.content.parts\")\n\tif partsResult.IsArray() {\n\t\tpartResults := partsResult.Array()\n\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\tpartResult := partResults[i]\n\n\t\t\t// Extract the different types of content from each part\n\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\n\t\t\t// Handle text content (both regular content and thinking)\n\t\t\tif partTextResult.Exists() {\n\t\t\t\t// Process thinking content (internal reasoning)\n\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\t// Continue existing thinking block if already in thinking state\n\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition from another state to thinking\n\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Start a new thinking content block\n\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.thinking\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).ResponseType = 2 // Set state to thinking\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Process regular text content (user-visible output)\n\t\t\t\t\t// Continue existing text block if already in content state\n\t\t\t\t\tif (*param).(*Params).ResponseType == 1 {\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition from another state to text content\n\t\t\t\t\t\t// First, close any existing content block\n\t\t\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Start a new text content block\n\t\t\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\t\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t\tdata, _ := sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.text\", partTextResult.String())\n\t\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t\t\t(*param).(*Params).ResponseType = 1 // Set state to content\n\t\t\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t// Handle function/tool calls from the AI model\n\t\t\t\t// This processes tool usage requests and formats them for Claude Code API compatibility\n\t\t\t\tusedTool = true\n\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\n\t\t\t\t// Handle state transitions when switching to function calls\n\t\t\t\t// Close any existing function call block first\n\t\t\t\tif (*param).(*Params).ResponseType == 3 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t\t(*param).(*Params).ResponseType = 0\n\t\t\t\t}\n\n\t\t\t\t// Special handling for thinking state transition\n\t\t\t\tif (*param).(*Params).ResponseType == 2 {\n\t\t\t\t\t// output = output + \"event: content_block_delta\\n\"\n\t\t\t\t\t// output = output + fmt.Sprintf(`data: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"signature_delta\",\"signature\":null}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\t// output = output + \"\\n\\n\\n\"\n\t\t\t\t}\n\n\t\t\t\t// Close any other existing content block\n\t\t\t\tif (*param).(*Params).ResponseType != 0 {\n\t\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\t\toutput = output + \"\\n\\n\\n\"\n\t\t\t\t\t(*param).(*Params).ResponseIndex++\n\t\t\t\t}\n\n\t\t\t\t// Start a new tool use content block\n\t\t\t\t// This creates the structure for a function call in Claude Code format\n\t\t\t\toutput = output + \"event: content_block_start\\n\"\n\n\t\t\t\t// Create the tool use block with unique ID and function details\n\t\t\t\tdata := fmt.Sprintf(`{\"type\":\"content_block_start\",\"index\":%d,\"content_block\":{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}}`, (*param).(*Params).ResponseIndex)\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.id\", util.SanitizeClaudeToolID(fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1))))\n\t\t\t\tdata, _ = sjson.Set(data, \"content_block.name\", fcName)\n\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\n\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\toutput = output + \"event: content_block_delta\\n\"\n\t\t\t\t\tdata, _ = sjson.Set(fmt.Sprintf(`{\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`, (*param).(*Params).ResponseIndex), \"delta.partial_json\", fcArgsResult.Raw)\n\t\t\t\t\toutput = output + fmt.Sprintf(\"data: %s\\n\\n\\n\", data)\n\t\t\t\t}\n\t\t\t\t(*param).(*Params).ResponseType = 3\n\t\t\t\t(*param).(*Params).HasContent = true\n\t\t\t}\n\t\t}\n\t}\n\n\tusageResult := gjson.GetBytes(rawJSON, \"response.usageMetadata\")\n\t// Process usage metadata and finish reason when present in the response\n\tif usageResult.Exists() && bytes.Contains(rawJSON, []byte(`\"finishReason\"`)) {\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\t// Only send final events if we have actually output content\n\t\t\tif (*param).(*Params).HasContent {\n\t\t\t\t// Close the final content block\n\t\t\t\toutput = output + \"event: content_block_stop\\n\"\n\t\t\t\toutput = output + fmt.Sprintf(`data: {\"type\":\"content_block_stop\",\"index\":%d}`, (*param).(*Params).ResponseIndex)\n\t\t\t\toutput = output + \"\\n\\n\\n\"\n\n\t\t\t\t// Send the final message delta with usage information and stop reason\n\t\t\t\toutput = output + \"event: message_delta\\n\"\n\t\t\t\toutput = output + `data: `\n\n\t\t\t\t// Create the message delta template with appropriate stop reason\n\t\t\t\ttemplate := `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\t// Set tool_use stop reason if tools were used in this response\n\t\t\t\tif usedTool {\n\t\t\t\t\ttemplate = `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\t} else if finish := gjson.GetBytes(rawJSON, \"response.candidates.0.finishReason\"); finish.Exists() && finish.String() == \"MAX_TOKENS\" {\n\t\t\t\t\ttemplate = `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\t\t}\n\n\t\t\t\t// Include thinking tokens in output token count if present\n\t\t\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usage.output_tokens\", candidatesTokenCountResult.Int()+thoughtsTokenCount)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usage.input_tokens\", usageResult.Get(\"promptTokenCount\").Int())\n\n\t\t\t\toutput = output + template + \"\\n\\n\\n\"\n\t\t\t}\n\t\t}\n\t}\n\n\treturn []string{output}\n}\n\n// ConvertGeminiCLIResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the Gemini CLI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: A Claude-compatible JSON response.\nfunc ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t_ = originalRequestRawJSON\n\t_ = requestRawJSON\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\tout := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tout, _ = sjson.Set(out, \"id\", root.Get(\"response.responseId\").String())\n\tout, _ = sjson.Set(out, \"model\", root.Get(\"response.modelVersion\").String())\n\n\tinputTokens := root.Get(\"response.usageMetadata.promptTokenCount\").Int()\n\toutputTokens := root.Get(\"response.usageMetadata.candidatesTokenCount\").Int() + root.Get(\"response.usageMetadata.thoughtsTokenCount\").Int()\n\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\n\tparts := root.Get(\"response.candidates.0.content.parts\")\n\ttextBuilder := strings.Builder{}\n\tthinkingBuilder := strings.Builder{}\n\ttoolIDCounter := 0\n\thasToolCall := false\n\n\tflushText := func() {\n\t\tif textBuilder.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"text\", textBuilder.String())\n\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\ttextBuilder.Reset()\n\t}\n\n\tflushThinking := func() {\n\t\tif thinkingBuilder.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\tblock, _ = sjson.Set(block, \"thinking\", thinkingBuilder.String())\n\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\tthinkingBuilder.Reset()\n\t}\n\n\tif parts.IsArray() {\n\t\tfor _, part := range parts.Array() {\n\t\t\tif text := part.Get(\"text\"); text.Exists() && text.String() != \"\" {\n\t\t\t\tif part.Get(\"thought\").Bool() {\n\t\t\t\t\tflushText()\n\t\t\t\t\tthinkingBuilder.WriteString(text.String())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tflushThinking()\n\t\t\t\ttextBuilder.WriteString(text.String())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif functionCall := part.Get(\"functionCall\"); functionCall.Exists() {\n\t\t\t\tflushThinking()\n\t\t\t\tflushText()\n\t\t\t\thasToolCall = true\n\n\t\t\t\tname := functionCall.Get(\"name\").String()\n\t\t\t\ttoolIDCounter++\n\t\t\t\ttoolBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"id\", fmt.Sprintf(\"tool_%d\", toolIDCounter))\n\t\t\t\ttoolBlock, _ = sjson.Set(toolBlock, \"name\", name)\n\t\t\t\tinputRaw := \"{}\"\n\t\t\t\tif args := functionCall.Get(\"args\"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {\n\t\t\t\t\tinputRaw = args.Raw\n\t\t\t\t}\n\t\t\t\ttoolBlock, _ = sjson.SetRaw(toolBlock, \"input\", inputRaw)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolBlock)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\tflushThinking()\n\tflushText()\n\n\tstopReason := \"end_turn\"\n\tif hasToolCall {\n\t\tstopReason = \"tool_use\"\n\t} else {\n\t\tif finish := root.Get(\"response.candidates.0.finishReason\"); finish.Exists() {\n\t\t\tswitch finish.String() {\n\t\t\tcase \"MAX_TOKENS\":\n\t\t\t\tstopReason = \"max_tokens\"\n\t\t\tcase \"STOP\", \"FINISH_REASON_UNSPECIFIED\", \"UNKNOWN\":\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\tdefault:\n\t\t\t\tstopReason = \"end_turn\"\n\t\t\t}\n\t\t}\n\t}\n\tout, _ = sjson.Set(out, \"stop_reason\", stopReason)\n\n\tif inputTokens == int64(0) && outputTokens == int64(0) && !root.Get(\"response.usageMetadata\").Exists() {\n\t\tout, _ = sjson.Delete(out, \"usage\")\n\t}\n\n\treturn out\n}\n\nfunc ClaudeTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"input_tokens\":%d}`, count)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/claude/init.go",
    "content": "package claude\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tClaude,\n\t\tGeminiCLI,\n\t\tConvertClaudeRequestToCLI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertGeminiCLIResponseToClaude,\n\t\t\tNonStream:  ConvertGeminiCLIResponseToClaudeNonStream,\n\t\t\tTokenCount: ClaudeTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go",
    "content": "// Package gemini provides request translation functionality for Gemini CLI to Gemini API compatibility.\n// It handles parsing and transforming Gemini CLI API requests into Gemini API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini CLI API format and Gemini API's expected format.\npackage gemini\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiRequestToGeminiCLI parses and transforms a Gemini CLI API request into Gemini API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini API.\n// The function performs the following transformations:\n// 1. Extracts the model information from the request\n// 2. Restructures the JSON to match Gemini API format\n// 3. Converts system instructions to the expected format\n// 4. Fixes CLI tool response format and grouping\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini API format\nfunc ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\ttemplate := \"\"\n\ttemplate = `{\"project\":\"\",\"request\":{},\"model\":\"\"}`\n\ttemplate, _ = sjson.SetRaw(template, \"request\", string(rawJSON))\n\ttemplate, _ = sjson.Set(template, \"model\", gjson.Get(template, \"request.model\").String())\n\ttemplate, _ = sjson.Delete(template, \"request.model\")\n\n\ttemplate, errFixCLIToolResponse := fixCLIToolResponse(template)\n\tif errFixCLIToolResponse != nil {\n\t\treturn []byte{}\n\t}\n\n\tsystemInstructionResult := gjson.Get(template, \"request.system_instruction\")\n\tif systemInstructionResult.Exists() {\n\t\ttemplate, _ = sjson.SetRaw(template, \"request.systemInstruction\", systemInstructionResult.Raw)\n\t\ttemplate, _ = sjson.Delete(template, \"request.system_instruction\")\n\t}\n\trawJSON = []byte(template)\n\n\t// Normalize roles in request.contents: default to valid values if missing/invalid\n\tcontents := gjson.GetBytes(rawJSON, \"request.contents\")\n\tif contents.Exists() {\n\t\tprevRole := \"\"\n\t\tidx := 0\n\t\tcontents.ForEach(func(_ gjson.Result, value gjson.Result) bool {\n\t\t\trole := value.Get(\"role\").String()\n\t\t\tvalid := role == \"user\" || role == \"model\"\n\t\t\tif role == \"\" || !valid {\n\t\t\t\tvar newRole string\n\t\t\t\tif prevRole == \"\" {\n\t\t\t\t\tnewRole = \"user\"\n\t\t\t\t} else if prevRole == \"user\" {\n\t\t\t\t\tnewRole = \"model\"\n\t\t\t\t} else {\n\t\t\t\t\tnewRole = \"user\"\n\t\t\t\t}\n\t\t\t\tpath := fmt.Sprintf(\"request.contents.%d.role\", idx)\n\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, path, newRole)\n\t\t\t\trole = newRole\n\t\t\t}\n\t\t\tprevRole = role\n\t\t\tidx++\n\t\t\treturn true\n\t\t})\n\t}\n\n\ttoolsResult := gjson.GetBytes(rawJSON, \"request.tools\")\n\tif toolsResult.Exists() && toolsResult.IsArray() {\n\t\ttoolResults := toolsResult.Array()\n\t\tfor i := 0; i < len(toolResults); i++ {\n\t\t\tfunctionDeclarationsResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"request.tools.%d.function_declarations\", i))\n\t\t\tif functionDeclarationsResult.Exists() && functionDeclarationsResult.IsArray() {\n\t\t\t\tfunctionDeclarationsResults := functionDeclarationsResult.Array()\n\t\t\t\tfor j := 0; j < len(functionDeclarationsResults); j++ {\n\t\t\t\t\tparametersResult := gjson.GetBytes(rawJSON, fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parameters\", i, j))\n\t\t\t\t\tif parametersResult.Exists() {\n\t\t\t\t\t\tstrJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parameters\", i, j), fmt.Sprintf(\"request.tools.%d.function_declarations.%d.parametersJsonSchema\", i, j))\n\t\t\t\t\t\trawJSON = []byte(strJson)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tgjson.GetBytes(rawJSON, \"request.contents\").ForEach(func(key, content gjson.Result) bool {\n\t\tif content.Get(\"role\").String() == \"model\" {\n\t\t\tcontent.Get(\"parts\").ForEach(func(partKey, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"request.contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t} else if part.Get(\"thoughtSignature\").Exists() {\n\t\t\t\t\trawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf(\"request.contents.%d.parts.%d.thoughtSignature\", key.Int(), partKey.Int()), \"skip_thought_signature_validator\")\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t\treturn true\n\t})\n\n\t// Filter out contents with empty parts to avoid Gemini API error:\n\t// \"required oneof field 'data' must have one initialized field\"\n\tfilteredContents := \"[]\"\n\thasFiltered := false\n\tgjson.GetBytes(rawJSON, \"request.contents\").ForEach(func(_, content gjson.Result) bool {\n\t\tparts := content.Get(\"parts\")\n\t\tif !parts.IsArray() || len(parts.Array()) == 0 {\n\t\t\thasFiltered = true\n\t\t\treturn true\n\t\t}\n\t\tfilteredContents, _ = sjson.SetRaw(filteredContents, \"-1\", content.Raw)\n\t\treturn true\n\t})\n\tif hasFiltered {\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"request.contents\", []byte(filteredContents))\n\t}\n\n\treturn common.AttachDefaultSafetySettings(rawJSON, \"request.safetySettings\")\n}\n\n// FunctionCallGroup represents a group of function calls and their responses\ntype FunctionCallGroup struct {\n\tResponsesNeeded int\n\tCallNames       []string // ordered function call names for backfilling empty response names\n}\n\n// backfillFunctionResponseName ensures that a functionResponse JSON object has a non-empty name,\n// falling back to fallbackName if the original is empty.\nfunc backfillFunctionResponseName(raw string, fallbackName string) string {\n\tname := gjson.Get(raw, \"functionResponse.name\").String()\n\tif strings.TrimSpace(name) == \"\" && fallbackName != \"\" {\n\t\traw, _ = sjson.Set(raw, \"functionResponse.name\", fallbackName)\n\t}\n\treturn raw\n}\n\n// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.\n// This function transforms the CLI tool response format by intelligently grouping function calls\n// with their corresponding responses, ensuring proper conversation flow and API compatibility.\n// It converts from a linear format (1.json) to a grouped format (2.json) where function calls\n// and their responses are properly associated and structured.\n//\n// Parameters:\n//   - input: The input JSON string to be processed\n//\n// Returns:\n//   - string: The processed JSON string with grouped function calls and responses\n//   - error: An error if the processing fails\nfunc fixCLIToolResponse(input string) (string, error) {\n\t// Parse the input JSON to extract the conversation structure\n\tparsed := gjson.Parse(input)\n\n\t// Extract the contents array which contains the conversation messages\n\tcontents := parsed.Get(\"request.contents\")\n\tif !contents.Exists() {\n\t\t// log.Debugf(input)\n\t\treturn input, fmt.Errorf(\"contents not found in input\")\n\t}\n\n\t// Initialize data structures for processing and grouping\n\tcontentsWrapper := `{\"contents\":[]}`\n\tvar pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses\n\tvar collectedResponses []gjson.Result  // Standalone responses to be matched\n\n\t// Process each content object in the conversation\n\t// This iterates through messages and groups function calls with their responses\n\tcontents.ForEach(func(key, value gjson.Result) bool {\n\t\trole := value.Get(\"role\").String()\n\t\tparts := value.Get(\"parts\")\n\n\t\t// Check if this content has function responses\n\t\tvar responsePartsInThisContent []gjson.Result\n\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\tif part.Get(\"functionResponse\").Exists() {\n\t\t\t\tresponsePartsInThisContent = append(responsePartsInThisContent, part)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\t// If this content has function responses, collect them\n\t\tif len(responsePartsInThisContent) > 0 {\n\t\t\tcollectedResponses = append(collectedResponses, responsePartsInThisContent...)\n\n\t\t\t// Check if pending groups can be satisfied (FIFO: oldest group first)\n\t\t\tfor len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {\n\t\t\t\tgroup := pendingGroups[0]\n\t\t\t\tpendingGroups = pendingGroups[1:]\n\n\t\t\t\t// Take the needed responses for this group\n\t\t\t\tgroupResponses := collectedResponses[:group.ResponsesNeeded]\n\t\t\t\tcollectedResponses = collectedResponses[group.ResponsesNeeded:]\n\n\t\t\t\t// Create merged function response content\n\t\t\t\tfunctionResponseContent := `{\"parts\":[],\"role\":\"function\"}`\n\t\t\t\tfor ri, response := range groupResponses {\n\t\t\t\t\tif !response.IsObject() {\n\t\t\t\t\t\tlog.Warnf(\"failed to parse function response\")\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\traw := backfillFunctionResponseName(response.Raw, group.CallNames[ri])\n\t\t\t\t\tfunctionResponseContent, _ = sjson.SetRaw(functionResponseContent, \"parts.-1\", raw)\n\t\t\t\t}\n\n\t\t\t\tif gjson.Get(functionResponseContent, \"parts.#\").Int() > 0 {\n\t\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", functionResponseContent)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn true // Skip adding this content, responses are merged\n\t\t}\n\n\t\t// If this is a model with function calls, create a new group\n\t\tif role == \"model\" {\n\t\t\tvar callNames []string\n\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\tif part.Get(\"functionCall\").Exists() {\n\t\t\t\t\tcallNames = append(callNames, part.Get(\"functionCall.name\").String())\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\tif len(callNames) > 0 {\n\t\t\t\t// Add the model content\n\t\t\t\tif !value.IsObject() {\n\t\t\t\t\tlog.Warnf(\"failed to parse model content\")\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\n\t\t\t\t// Create a new group for tracking responses\n\t\t\t\tgroup := &FunctionCallGroup{\n\t\t\t\t\tResponsesNeeded: len(callNames),\n\t\t\t\t\tCallNames:       callNames,\n\t\t\t\t}\n\t\t\t\tpendingGroups = append(pendingGroups, group)\n\t\t\t} else {\n\t\t\t\t// Regular model content without function calls\n\t\t\t\tif !value.IsObject() {\n\t\t\t\t\tlog.Warnf(\"failed to parse content\")\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-model content (user, etc.)\n\t\t\tif !value.IsObject() {\n\t\t\t\tlog.Warnf(\"failed to parse content\")\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", value.Raw)\n\t\t}\n\n\t\treturn true\n\t})\n\n\t// Handle any remaining pending groups with remaining responses\n\tfor _, group := range pendingGroups {\n\t\tif len(collectedResponses) >= group.ResponsesNeeded {\n\t\t\tgroupResponses := collectedResponses[:group.ResponsesNeeded]\n\t\t\tcollectedResponses = collectedResponses[group.ResponsesNeeded:]\n\n\t\t\tfunctionResponseContent := `{\"parts\":[],\"role\":\"function\"}`\n\t\t\tfor ri, response := range groupResponses {\n\t\t\t\tif !response.IsObject() {\n\t\t\t\t\tlog.Warnf(\"failed to parse function response\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\traw := backfillFunctionResponseName(response.Raw, group.CallNames[ri])\n\t\t\t\tfunctionResponseContent, _ = sjson.SetRaw(functionResponseContent, \"parts.-1\", raw)\n\t\t\t}\n\n\t\t\tif gjson.Get(functionResponseContent, \"parts.#\").Int() > 0 {\n\t\t\t\tcontentsWrapper, _ = sjson.SetRaw(contentsWrapper, \"contents.-1\", functionResponseContent)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update the original JSON with the new contents\n\tresult := input\n\tresult, _ = sjson.SetRaw(result, \"request.contents\", gjson.Get(contentsWrapper, \"contents\").Raw)\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go",
    "content": "// Package gemini provides request translation functionality for Gemini to Gemini CLI API compatibility.\n// It handles parsing and transforming Gemini API requests into Gemini CLI API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini API format and Gemini CLI API's expected format.\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiCliResponseToGemini parses and transforms a Gemini CLI API request into Gemini API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the Gemini API.\n// The function performs the following transformations:\n// 1. Extracts the response data from the request\n// 2. Handles alternative response formats\n// 3. Processes array responses by extracting individual response objects\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model to use for the request (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - []string: The transformed request data in Gemini API format\nfunc ConvertGeminiCliResponseToGemini(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string {\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\tif alt, ok := ctx.Value(\"alt\").(string); ok {\n\t\tvar chunk []byte\n\t\tif alt == \"\" {\n\t\t\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\t\t\tif responseResult.Exists() {\n\t\t\t\tchunk = []byte(responseResult.Raw)\n\t\t\t}\n\t\t} else {\n\t\t\tchunkTemplate := \"[]\"\n\t\t\tresponseResult := gjson.ParseBytes(chunk)\n\t\t\tif responseResult.IsArray() {\n\t\t\t\tresponseResultItems := responseResult.Array()\n\t\t\t\tfor i := 0; i < len(responseResultItems); i++ {\n\t\t\t\t\tresponseResultItem := responseResultItems[i]\n\t\t\t\t\tif responseResultItem.Get(\"response\").Exists() {\n\t\t\t\t\t\tchunkTemplate, _ = sjson.SetRaw(chunkTemplate, \"-1\", responseResultItem.Get(\"response\").Raw)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tchunk = []byte(chunkTemplate)\n\t\t}\n\t\treturn []string{string(chunk)}\n\t}\n\treturn []string{}\n}\n\n// ConvertGeminiCliResponseToGeminiNonStream converts a non-streaming Gemini CLI request to a non-streaming Gemini response.\n// This function processes the complete Gemini CLI request and transforms it into a single Gemini-compatible\n// JSON response. It extracts the response data from the request and returns it in the expected format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON request data from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion (unused in current implementation)\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response containing the response data\nfunc ConvertGeminiCliResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\treturn responseResult.Raw\n\t}\n\treturn string(rawJSON)\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tGeminiCLI,\n\t\tConvertGeminiRequestToGeminiCLI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertGeminiCliResponseToGemini,\n\t\t\tNonStream:  ConvertGeminiCliResponseToGeminiNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go",
    "content": "// Package openai provides request translation functionality for OpenAI to Gemini CLI API compatibility.\n// It converts OpenAI Chat Completions requests into Gemini CLI compatible JSON using gjson/sjson only.\npackage chat_completions\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst geminiCLIFunctionThoughtSignature = \"skip_thought_signature_validator\"\n\n// ConvertOpenAIRequestToGeminiCLI converts an OpenAI Chat Completions request (raw JSON)\n// into a complete Gemini CLI request JSON. All JSON construction uses sjson and lookups use gjson.\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini CLI API format\nfunc ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base envelope (no default thinkingConfig)\n\tout := []byte(`{\"project\":\"\",\"request\":{\"contents\":[]},\"model\":\"gemini-2.5-pro\"}`)\n\n\t// Model\n\tout, _ = sjson.SetBytes(out, \"model\", modelName)\n\n\t// Let user-provided generationConfig pass through\n\tif genConfig := gjson.GetBytes(rawJSON, \"generationConfig\"); genConfig.Exists() {\n\t\tout, _ = sjson.SetRawBytes(out, \"request.generationConfig\", []byte(genConfig.Raw))\n\t}\n\n\t// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini CLI thinkingConfig.\n\t// Inline translation-only mapping; capability checks happen later in ApplyThinking.\n\tre := gjson.GetBytes(rawJSON, \"reasoning_effort\")\n\tif re.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(re.String()))\n\t\tif effort != \"\" {\n\t\t\tthinkingPath := \"request.generationConfig.thinkingConfig\"\n\t\t\tif effort == \"auto\" {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingBudget\", -1)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", true)\n\t\t\t} else {\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".thinkingLevel\", effort)\n\t\t\t\tout, _ = sjson.SetBytes(out, thinkingPath+\".includeThoughts\", effort != \"none\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// Temperature/top_p/top_k\n\tif tr := gjson.GetBytes(rawJSON, \"temperature\"); tr.Exists() && tr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.temperature\", tr.Num)\n\t}\n\tif tpr := gjson.GetBytes(rawJSON, \"top_p\"); tpr.Exists() && tpr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.topP\", tpr.Num)\n\t}\n\tif tkr := gjson.GetBytes(rawJSON, \"top_k\"); tkr.Exists() && tkr.Type == gjson.Number {\n\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.topK\", tkr.Num)\n\t}\n\n\t// Candidate count (OpenAI 'n' parameter)\n\tif n := gjson.GetBytes(rawJSON, \"n\"); n.Exists() && n.Type == gjson.Number {\n\t\tif val := n.Int(); val > 1 {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.candidateCount\", val)\n\t\t}\n\t}\n\n\t// Map OpenAI modalities -> Gemini CLI request.generationConfig.responseModalities\n\t// e.g. \"modalities\": [\"image\", \"text\"] -> [\"IMAGE\", \"TEXT\"]\n\tif mods := gjson.GetBytes(rawJSON, \"modalities\"); mods.Exists() && mods.IsArray() {\n\t\tvar responseMods []string\n\t\tfor _, m := range mods.Array() {\n\t\t\tswitch strings.ToLower(m.String()) {\n\t\t\tcase \"text\":\n\t\t\t\tresponseMods = append(responseMods, \"TEXT\")\n\t\t\tcase \"image\":\n\t\t\t\tresponseMods = append(responseMods, \"IMAGE\")\n\t\t\t}\n\t\t}\n\t\tif len(responseMods) > 0 {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.responseModalities\", responseMods)\n\t\t}\n\t}\n\n\t// OpenRouter-style image_config support\n\t// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.\n\tif imgCfg := gjson.GetBytes(rawJSON, \"image_config\"); imgCfg.Exists() && imgCfg.IsObject() {\n\t\tif ar := imgCfg.Get(\"aspect_ratio\"); ar.Exists() && ar.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.imageConfig.aspectRatio\", ar.Str)\n\t\t}\n\t\tif size := imgCfg.Get(\"image_size\"); size.Exists() && size.Type == gjson.String {\n\t\t\tout, _ = sjson.SetBytes(out, \"request.generationConfig.imageConfig.imageSize\", size.Str)\n\t\t}\n\t}\n\n\t// messages -> systemInstruction + contents\n\tmessages := gjson.GetBytes(rawJSON, \"messages\")\n\tif messages.IsArray() {\n\t\tarr := messages.Array()\n\t\t// First pass: assistant tool_calls id->name map\n\t\ttcID2Name := map[string]string{}\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\tif m.Get(\"role\").String() == \"assistant\" {\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\t\tid := tc.Get(\"id\").String()\n\t\t\t\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\t\tif id != \"\" && name != \"\" {\n\t\t\t\t\t\t\t\ttcID2Name[id] = name\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Second pass build systemInstruction/tool responses cache\n\t\ttoolResponses := map[string]string{} // tool_call_id -> response text\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tif role == \"tool\" {\n\t\t\t\ttoolCallID := m.Get(\"tool_call_id\").String()\n\t\t\t\tif toolCallID != \"\" {\n\t\t\t\t\tc := m.Get(\"content\")\n\t\t\t\t\ttoolResponses[toolCallID] = c.Raw\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsystemPartIndex := 0\n\t\tfor i := 0; i < len(arr); i++ {\n\t\t\tm := arr[i]\n\t\t\trole := m.Get(\"role\").String()\n\t\t\tcontent := m.Get(\"content\")\n\n\t\t\tif (role == \"system\" || role == \"developer\") && len(arr) > 1 {\n\t\t\t\t// system -> request.systemInstruction as a user message style\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), content.String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsObject() && content.Get(\"type\").String() == \"text\" {\n\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), content.Get(\"text\").String())\n\t\t\t\t\tsystemPartIndex++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\tcontents := content.Array()\n\t\t\t\t\tif len(contents) > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetBytes(out, \"request.systemInstruction.role\", \"user\")\n\t\t\t\t\t\tfor j := 0; j < len(contents); j++ {\n\t\t\t\t\t\t\tout, _ = sjson.SetBytes(out, fmt.Sprintf(\"request.systemInstruction.parts.%d.text\", systemPartIndex), contents[j].Get(\"text\").String())\n\t\t\t\t\t\t\tsystemPartIndex++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if role == \"user\" || ((role == \"system\" || role == \"developer\") && len(arr) == 1) {\n\t\t\t\t// Build single user content node to avoid splitting into multiple contents\n\t\t\t\tnode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.0.text\", content.String())\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\titems := content.Array()\n\t\t\t\t\tp := 0\n\t\t\t\t\tfor _, item := range items {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", item.Get(\"text\").String())\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 {\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"file\":\n\t\t\t\t\t\t\tfilename := item.Get(\"file.filename\").String()\n\t\t\t\t\t\t\tfileData := item.Get(\"file.file_data\").String()\n\t\t\t\t\t\t\text := \"\"\n\t\t\t\t\t\t\tif sp := strings.Split(filename, \".\"); len(sp) > 1 {\n\t\t\t\t\t\t\t\text = sp[len(sp)-1]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif mimeType, ok := misc.MimeTypes[ext]; ok {\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mimeType)\n\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", fileData)\n\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Unknown file name extension '%s' in user message, skip\", ext)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\t\t\t} else if role == \"assistant\" {\n\t\t\t\tp := 0\n\t\t\t\tnode := []byte(`{\"role\":\"model\",\"parts\":[]}`)\n\t\t\t\tif content.Type == gjson.String {\n\t\t\t\t\t// Assistant text -> single model content\n\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.-1.text\", content.String())\n\t\t\t\t\tp++\n\t\t\t\t} else if content.IsArray() {\n\t\t\t\t\t// Assistant multimodal content (e.g. text + image) -> single model content with parts\n\t\t\t\t\tfor _, item := range content.Array() {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".text\", item.Get(\"text\").String())\n\t\t\t\t\t\t\tp++\n\t\t\t\t\t\tcase \"image_url\":\n\t\t\t\t\t\t\t// If the assistant returned an inline data URL, preserve it for history fidelity.\n\t\t\t\t\t\t\timageURL := item.Get(\"image_url.url\").String()\n\t\t\t\t\t\t\tif len(imageURL) > 5 { // expect data:...\n\t\t\t\t\t\t\t\tpieces := strings.SplitN(imageURL[5:], \";\", 2)\n\t\t\t\t\t\t\t\tif len(pieces) == 2 && len(pieces[1]) > 7 {\n\t\t\t\t\t\t\t\t\tmime := pieces[0]\n\t\t\t\t\t\t\t\t\tdata := pieces[1][7:]\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.mime_type\", mime)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".inlineData.data\", data)\n\t\t\t\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\t\t\t\tp++\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Tool calls -> single model content with functionCall parts\n\t\t\t\ttcs := m.Get(\"tool_calls\")\n\t\t\t\tif tcs.IsArray() {\n\t\t\t\t\tfIDs := make([]string, 0)\n\t\t\t\t\tfor _, tc := range tcs.Array() {\n\t\t\t\t\t\tif tc.Get(\"type\").String() != \"function\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfid := tc.Get(\"id\").String()\n\t\t\t\t\t\tfname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\tfargs := tc.Get(\"function.arguments\").String()\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".functionCall.name\", fname)\n\t\t\t\t\t\tnode, _ = sjson.SetRawBytes(node, \"parts.\"+itoa(p)+\".functionCall.args\", []byte(fargs))\n\t\t\t\t\t\tnode, _ = sjson.SetBytes(node, \"parts.\"+itoa(p)+\".thoughtSignature\", geminiCLIFunctionThoughtSignature)\n\t\t\t\t\t\tp++\n\t\t\t\t\t\tif fid != \"\" {\n\t\t\t\t\t\t\tfIDs = append(fIDs, fid)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\n\t\t\t\t\t// Append a single tool content combining name + response per function\n\t\t\t\t\ttoolNode := []byte(`{\"role\":\"user\",\"parts\":[]}`)\n\t\t\t\t\tpp := 0\n\t\t\t\t\tfor _, fid := range fIDs {\n\t\t\t\t\t\tif name, ok := tcID2Name[fid]; ok {\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.name\", name)\n\t\t\t\t\t\t\tresp := toolResponses[fid]\n\t\t\t\t\t\t\tif resp == \"\" {\n\t\t\t\t\t\t\t\tresp = \"{}\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ttoolNode, _ = sjson.SetBytes(toolNode, \"parts.\"+itoa(pp)+\".functionResponse.response.result\", []byte(resp))\n\t\t\t\t\t\t\tpp++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif pp > 0 {\n\t\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", toolNode)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.SetRawBytes(out, \"request.contents.-1\", node)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// tools -> request.tools[].functionDeclarations + request.tools[].googleSearch/codeExecution/urlContext passthrough\n\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\tif tools.IsArray() && len(tools.Array()) > 0 {\n\t\tfunctionToolNode := []byte(`{}`)\n\t\thasFunction := false\n\t\tgoogleSearchNodes := make([][]byte, 0)\n\t\tcodeExecutionNodes := make([][]byte, 0)\n\t\turlContextNodes := make([][]byte, 0)\n\t\tfor _, t := range tools.Array() {\n\t\t\tif t.Get(\"type\").String() == \"function\" {\n\t\t\t\tfn := t.Get(\"function\")\n\t\t\t\tif fn.Exists() && fn.IsObject() {\n\t\t\t\t\tfnRaw := fn.Raw\n\t\t\t\t\tif fn.Get(\"parameters\").Exists() {\n\t\t\t\t\t\trenamed, errRename := util.RenameKey(fnRaw, \"parameters\", \"parametersJsonSchema\")\n\t\t\t\t\t\tif errRename != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to rename parameters for tool '%s': %v\", fn.Get(\"name\").String(), errRename)\n\t\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tfnRaw = renamed\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar errSet error\n\t\t\t\t\t\tfnRaw, errSet = sjson.Set(fnRaw, \"parametersJsonSchema.type\", \"object\")\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema type for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfnRaw, errSet = sjson.SetRaw(fnRaw, \"parametersJsonSchema.properties\", `{}`)\n\t\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\t\tlog.Warnf(\"Failed to set default schema properties for tool '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfnRaw, _ = sjson.Delete(fnRaw, \"strict\")\n\t\t\t\t\tif !hasFunction {\n\t\t\t\t\t\tfunctionToolNode, _ = sjson.SetRawBytes(functionToolNode, \"functionDeclarations\", []byte(\"[]\"))\n\t\t\t\t\t}\n\t\t\t\t\ttmp, errSet := sjson.SetRawBytes(functionToolNode, \"functionDeclarations.-1\", []byte(fnRaw))\n\t\t\t\t\tif errSet != nil {\n\t\t\t\t\t\tlog.Warnf(\"Failed to append tool declaration for '%s': %v\", fn.Get(\"name\").String(), errSet)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tfunctionToolNode = tmp\n\t\t\t\t\thasFunction = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif gs := t.Get(\"google_search\"); gs.Exists() {\n\t\t\t\tgoogleToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tgoogleToolNode, errSet = sjson.SetRawBytes(googleToolNode, \"googleSearch\", []byte(gs.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set googleSearch tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tgoogleSearchNodes = append(googleSearchNodes, googleToolNode)\n\t\t\t}\n\t\t\tif ce := t.Get(\"code_execution\"); ce.Exists() {\n\t\t\t\tcodeToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\tcodeToolNode, errSet = sjson.SetRawBytes(codeToolNode, \"codeExecution\", []byte(ce.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set codeExecution tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcodeExecutionNodes = append(codeExecutionNodes, codeToolNode)\n\t\t\t}\n\t\t\tif uc := t.Get(\"url_context\"); uc.Exists() {\n\t\t\t\turlToolNode := []byte(`{}`)\n\t\t\t\tvar errSet error\n\t\t\t\turlToolNode, errSet = sjson.SetRawBytes(urlToolNode, \"urlContext\", []byte(uc.Raw))\n\t\t\t\tif errSet != nil {\n\t\t\t\t\tlog.Warnf(\"Failed to set urlContext tool: %v\", errSet)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\turlContextNodes = append(urlContextNodes, urlToolNode)\n\t\t\t}\n\t\t}\n\t\tif hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {\n\t\t\ttoolsNode := []byte(\"[]\")\n\t\t\tif hasFunction {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", functionToolNode)\n\t\t\t}\n\t\t\tfor _, googleNode := range googleSearchNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", googleNode)\n\t\t\t}\n\t\t\tfor _, codeNode := range codeExecutionNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", codeNode)\n\t\t\t}\n\t\t\tfor _, urlNode := range urlContextNodes {\n\t\t\t\ttoolsNode, _ = sjson.SetRawBytes(toolsNode, \"-1\", urlNode)\n\t\t\t}\n\t\t\tout, _ = sjson.SetRawBytes(out, \"request.tools\", toolsNode)\n\t\t}\n\t}\n\n\treturn common.AttachDefaultSafetySettings(out, \"request.safetySettings\")\n}\n\n// itoa converts int to string without strconv import for few usages.\nfunc itoa(i int) string { return fmt.Sprintf(\"%d\", i) }\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go",
    "content": "// Package openai provides response translation functionality for Gemini CLI to OpenAI API compatibility.\n// This package handles the conversion of Gemini CLI API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// convertCliResponseToOpenAIChatParams holds parameters for response conversion.\ntype convertCliResponseToOpenAIChatParams struct {\n\tUnixTimestamp int64\n\tFunctionIndex int\n}\n\n// functionCallIDCounter provides a process-wide unique counter for function call identifiers.\nvar functionCallIDCounter uint64\n\n// ConvertCliResponseToOpenAI translates a single chunk of a streaming response from the\n// Gemini CLI API format to the OpenAI Chat Completions streaming format.\n// It processes various Gemini CLI event types and transforms them into OpenAI-compatible JSON responses.\n// The function handles text content, tool calls, reasoning content, and usage metadata, outputting\n// responses that match the OpenAI API format. It supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &convertCliResponseToOpenAIChatParams{\n\t\t\tUnixTimestamp: 0,\n\t\t\tFunctionIndex: 0,\n\t\t}\n\t}\n\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\t// Initialize the OpenAI SSE template.\n\ttemplate := `{\"id\":\"\",\"object\":\"chat.completion.chunk\",\"created\":12345,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":null,\"reasoning_content\":null,\"tool_calls\":null},\"finish_reason\":null,\"native_finish_reason\":null}]}`\n\n\t// Extract and set the model version.\n\tif modelVersionResult := gjson.GetBytes(rawJSON, \"response.modelVersion\"); modelVersionResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"model\", modelVersionResult.String())\n\t}\n\n\t// Extract and set the creation timestamp.\n\tif createTimeResult := gjson.GetBytes(rawJSON, \"response.createTime\"); createTimeResult.Exists() {\n\t\tt, err := time.Parse(time.RFC3339Nano, createTimeResult.String())\n\t\tif err == nil {\n\t\t\t(*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp = t.Unix()\n\t\t}\n\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)\n\t} else {\n\t\ttemplate, _ = sjson.Set(template, \"created\", (*param).(*convertCliResponseToOpenAIChatParams).UnixTimestamp)\n\t}\n\n\t// Extract and set the response ID.\n\tif responseIDResult := gjson.GetBytes(rawJSON, \"response.responseId\"); responseIDResult.Exists() {\n\t\ttemplate, _ = sjson.Set(template, \"id\", responseIDResult.String())\n\t}\n\n\tfinishReason := \"\"\n\tif stopReasonResult := gjson.GetBytes(rawJSON, \"response.stop_reason\"); stopReasonResult.Exists() {\n\t\tfinishReason = stopReasonResult.String()\n\t}\n\tif finishReason == \"\" {\n\t\tif finishReasonResult := gjson.GetBytes(rawJSON, \"response.candidates.0.finishReason\"); finishReasonResult.Exists() {\n\t\t\tfinishReason = finishReasonResult.String()\n\t\t}\n\t}\n\tfinishReason = strings.ToLower(finishReason)\n\n\t// Extract and set usage metadata (token counts).\n\tif usageResult := gjson.GetBytes(rawJSON, \"response.usageMetadata\"); usageResult.Exists() {\n\t\tcachedTokenCount := usageResult.Get(\"cachedContentTokenCount\").Int()\n\t\tif candidatesTokenCountResult := usageResult.Get(\"candidatesTokenCount\"); candidatesTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens\", candidatesTokenCountResult.Int())\n\t\t}\n\t\tif totalTokenCountResult := usageResult.Get(\"totalTokenCount\"); totalTokenCountResult.Exists() {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.total_tokens\", totalTokenCountResult.Int())\n\t\t}\n\t\tpromptTokenCount := usageResult.Get(\"promptTokenCount\").Int()\n\t\tthoughtsTokenCount := usageResult.Get(\"thoughtsTokenCount\").Int()\n\t\ttemplate, _ = sjson.Set(template, \"usage.prompt_tokens\", promptTokenCount)\n\t\tif thoughtsTokenCount > 0 {\n\t\t\ttemplate, _ = sjson.Set(template, \"usage.completion_tokens_details.reasoning_tokens\", thoughtsTokenCount)\n\t\t}\n\t\t// Include cached token count if present (indicates prompt caching is working)\n\t\tif cachedTokenCount > 0 {\n\t\t\tvar err error\n\t\t\ttemplate, err = sjson.Set(template, \"usage.prompt_tokens_details.cached_tokens\", cachedTokenCount)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"gemini-cli openai response: failed to set cached_tokens: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process the main content part of the response.\n\tpartsResult := gjson.GetBytes(rawJSON, \"response.candidates.0.content.parts\")\n\thasFunctionCall := false\n\tif partsResult.IsArray() {\n\t\tpartResults := partsResult.Array()\n\t\tfor i := 0; i < len(partResults); i++ {\n\t\t\tpartResult := partResults[i]\n\t\t\tpartTextResult := partResult.Get(\"text\")\n\t\t\tfunctionCallResult := partResult.Get(\"functionCall\")\n\t\t\tthoughtSignatureResult := partResult.Get(\"thoughtSignature\")\n\t\t\tif !thoughtSignatureResult.Exists() {\n\t\t\t\tthoughtSignatureResult = partResult.Get(\"thought_signature\")\n\t\t\t}\n\t\t\tinlineDataResult := partResult.Get(\"inlineData\")\n\t\t\tif !inlineDataResult.Exists() {\n\t\t\t\tinlineDataResult = partResult.Get(\"inline_data\")\n\t\t\t}\n\n\t\t\thasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != \"\"\n\t\t\thasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()\n\n\t\t\t// Ignore encrypted thoughtSignature but keep any actual content in the same part.\n\t\t\tif hasThoughtSignature && !hasContentPayload {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif partTextResult.Exists() {\n\t\t\t\ttextContent := partTextResult.String()\n\n\t\t\t\t// Handle text content, distinguishing between regular content and reasoning/thoughts.\n\t\t\t\tif partResult.Get(\"thought\").Bool() {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.reasoning_content\", textContent)\n\t\t\t\t} else {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.content\", textContent)\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t} else if functionCallResult.Exists() {\n\t\t\t\t// Handle function call content.\n\t\t\t\thasFunctionCall = true\n\t\t\t\ttoolCallsResult := gjson.Get(template, \"choices.0.delta.tool_calls\")\n\t\t\t\tfunctionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex\n\t\t\t\t(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++\n\t\t\t\tif toolCallsResult.Exists() && toolCallsResult.IsArray() {\n\t\t\t\t\tfunctionCallIndex = len(toolCallsResult.Array())\n\t\t\t\t} else {\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls\", `[]`)\n\t\t\t\t}\n\n\t\t\t\tfunctionCallTemplate := `{\"id\": \"\",\"index\": 0,\"type\": \"function\",\"function\": {\"name\": \"\",\"arguments\": \"\"}}`\n\t\t\t\tfcName := functionCallResult.Get(\"name\").String()\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"id\", fmt.Sprintf(\"%s-%d-%d\", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"index\", functionCallIndex)\n\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.name\", fcName)\n\t\t\t\tif fcArgsResult := functionCallResult.Get(\"args\"); fcArgsResult.Exists() {\n\t\t\t\t\tfunctionCallTemplate, _ = sjson.Set(functionCallTemplate, \"function.arguments\", fcArgsResult.Raw)\n\t\t\t\t}\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.tool_calls.-1\", functionCallTemplate)\n\t\t\t} else if inlineDataResult.Exists() {\n\t\t\t\tdata := inlineDataResult.Get(\"data\").String()\n\t\t\t\tif data == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmimeType := inlineDataResult.Get(\"mimeType\").String()\n\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\tmimeType = inlineDataResult.Get(\"mime_type\").String()\n\t\t\t\t}\n\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\t}\n\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\t\t\t\timagesResult := gjson.Get(template, \"choices.0.delta.images\")\n\t\t\t\tif !imagesResult.Exists() || !imagesResult.IsArray() {\n\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images\", `[]`)\n\t\t\t\t}\n\t\t\t\timageIndex := len(gjson.Get(template, \"choices.0.delta.images\").Array())\n\t\t\t\timagePayload := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"index\", imageIndex)\n\t\t\t\timagePayload, _ = sjson.Set(imagePayload, \"image_url.url\", imageURL)\n\t\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.delta.role\", \"assistant\")\n\t\t\t\ttemplate, _ = sjson.SetRaw(template, \"choices.0.delta.images.-1\", imagePayload)\n\t\t\t}\n\t\t}\n\t}\n\n\tif hasFunctionCall {\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", \"tool_calls\")\n\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", \"tool_calls\")\n\t} else if finishReason != \"\" && (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex == 0 {\n\t\t// Only pass through specific finish reasons\n\t\tif finishReason == \"max_tokens\" || finishReason == \"stop\" {\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.finish_reason\", finishReason)\n\t\t\ttemplate, _ = sjson.Set(template, \"choices.0.native_finish_reason\", finishReason)\n\t\t}\n\t}\n\n\treturn []string{template}\n}\n\n// ConvertCliResponseToOpenAINonStream converts a non-streaming Gemini CLI response to a non-streaming OpenAI response.\n// This function processes the complete Gemini CLI response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertCliResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\treturn ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, []byte(responseResult.Raw), param)\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tGeminiCLI,\n\t\tConvertOpenAIRequestToGeminiCLI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertCliResponseToOpenAI,\n\t\t\tNonStream: ConvertCliResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses\"\n)\n\nfunc ConvertOpenAIResponsesRequestToGeminiCLI(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\trawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)\n\treturn ConvertGeminiRequestToGeminiCLI(modelName, rawJSON, stream)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"context\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc ConvertGeminiCLIResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\trawJSON = []byte(responseResult.Raw)\n\t}\n\treturn ConvertGeminiResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\nfunc ConvertGeminiCLIResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tresponseResult := gjson.GetBytes(rawJSON, \"response\")\n\tif responseResult.Exists() {\n\t\trawJSON = []byte(responseResult.Raw)\n\t}\n\n\trequestResult := gjson.GetBytes(originalRequestRawJSON, \"request\")\n\tif responseResult.Exists() {\n\t\toriginalRequestRawJSON = []byte(requestResult.Raw)\n\t}\n\n\trequestResult = gjson.GetBytes(requestRawJSON, \"request\")\n\tif responseResult.Exists() {\n\t\trequestRawJSON = []byte(requestResult.Raw)\n\t}\n\n\treturn ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n"
  },
  {
    "path": "internal/translator/gemini-cli/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tGeminiCLI,\n\t\tConvertOpenAIResponsesRequestToGeminiCLI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertGeminiCLIResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertGeminiCLIResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/init.go",
    "content": "package translator\n\nimport (\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini-cli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/responses\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini-cli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/responses\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/responses\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini-cli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini-cli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/chat-completions\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/responses\"\n)\n"
  },
  {
    "path": "internal/translator/openai/claude/init.go",
    "content": "package claude\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tClaude,\n\t\tOpenAI,\n\t\tConvertClaudeRequestToOpenAI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertOpenAIResponseToClaude,\n\t\t\tNonStream:  ConvertOpenAIResponseToClaudeNonStream,\n\t\t\tTokenCount: ClaudeTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/openai/claude/openai_claude_request.go",
    "content": "// Package claude provides request translation functionality for Anthropic to OpenAI API.\n// It handles parsing and transforming Anthropic API requests into OpenAI Chat Completions API format,\n// extracting model information, system instructions, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Anthropic API format and OpenAI API's expected format.\npackage claude\n\nimport (\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertClaudeRequestToOpenAI parses and transforms an Anthropic API request into OpenAI Chat Completions API format.\n// It extracts the model name, system instruction, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the OpenAI API.\nfunc ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base OpenAI Chat Completions API template\n\tout := `{\"model\":\"\",\"messages\":[]}`\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Model mapping\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Max tokens\n\tif maxTokens := root.Get(\"max_tokens\"); maxTokens.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t}\n\n\t// Temperature\n\tif temp := root.Get(\"temperature\"); temp.Exists() {\n\t\tout, _ = sjson.Set(out, \"temperature\", temp.Float())\n\t} else if topP := root.Get(\"top_p\"); topP.Exists() { // Top P\n\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t}\n\n\t// Stop sequences -> stop\n\tif stopSequences := root.Get(\"stop_sequences\"); stopSequences.Exists() {\n\t\tif stopSequences.IsArray() {\n\t\t\tvar stops []string\n\t\t\tstopSequences.ForEach(func(_, value gjson.Result) bool {\n\t\t\t\tstops = append(stops, value.String())\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif len(stops) > 0 {\n\t\t\t\tif len(stops) == 1 {\n\t\t\t\t\tout, _ = sjson.Set(out, \"stop\", stops[0])\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.Set(out, \"stop\", stops)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Stream\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort\n\tif thinkingConfig := root.Get(\"thinking\"); thinkingConfig.Exists() && thinkingConfig.IsObject() {\n\t\tif thinkingType := thinkingConfig.Get(\"type\"); thinkingType.Exists() {\n\t\t\tswitch thinkingType.String() {\n\t\t\tcase \"enabled\":\n\t\t\t\tif budgetTokens := thinkingConfig.Get(\"budget_tokens\"); budgetTokens.Exists() {\n\t\t\t\t\tbudget := int(budgetTokens.Int())\n\t\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != \"\" {\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// No budget_tokens specified, default to \"auto\" for enabled thinking\n\t\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(-1); ok && effort != \"\" {\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"adaptive\", \"auto\":\n\t\t\t\t// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).\n\t\t\t\t// Pass through directly; ApplyThinking handles clamping to target model's levels.\n\t\t\t\teffort := \"\"\n\t\t\t\tif v := root.Get(\"output_config.effort\"); v.Exists() && v.Type == gjson.String {\n\t\t\t\t\teffort = strings.ToLower(strings.TrimSpace(v.String()))\n\t\t\t\t}\n\t\t\t\tif effort != \"\" {\n\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t} else {\n\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", string(thinking.LevelXHigh))\n\t\t\t\t}\n\t\t\tcase \"disabled\":\n\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != \"\" {\n\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process messages and system\n\tvar messagesJSON = \"[]\"\n\n\t// Handle system message first\n\tsystemMsgJSON := `{\"role\":\"system\",\"content\":[]}`\n\thasSystemContent := false\n\tif system := root.Get(\"system\"); system.Exists() {\n\t\tif system.Type == gjson.String {\n\t\t\tif system.String() != \"\" {\n\t\t\t\toldSystem := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\toldSystem, _ = sjson.Set(oldSystem, \"text\", system.String())\n\t\t\t\tsystemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, \"content.-1\", oldSystem)\n\t\t\t\thasSystemContent = true\n\t\t\t}\n\t\t} else if system.Type == gjson.JSON {\n\t\t\tif system.IsArray() {\n\t\t\t\tsystemResults := system.Array()\n\t\t\t\tfor i := 0; i < len(systemResults); i++ {\n\t\t\t\t\tif contentItem, ok := convertClaudeContentPart(systemResults[i]); ok {\n\t\t\t\t\t\tsystemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, \"content.-1\", contentItem)\n\t\t\t\t\t\thasSystemContent = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t// Only add system message if it has content\n\tif hasSystemContent {\n\t\tmessagesJSON, _ = sjson.SetRaw(messagesJSON, \"-1\", systemMsgJSON)\n\t}\n\n\t// Process Anthropic messages\n\tif messages := root.Get(\"messages\"); messages.Exists() && messages.IsArray() {\n\t\tmessages.ForEach(func(_, message gjson.Result) bool {\n\t\t\trole := message.Get(\"role\").String()\n\t\t\tcontentResult := message.Get(\"content\")\n\n\t\t\t// Handle content\n\t\t\tif contentResult.Exists() && contentResult.IsArray() {\n\t\t\t\tvar contentItems []string\n\t\t\t\tvar reasoningParts []string // Accumulate thinking text for reasoning_content\n\t\t\t\tvar toolCalls []interface{}\n\t\t\t\tvar toolResults []string // Collect tool_result messages to emit after the main message\n\n\t\t\t\tcontentResult.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\tpartType := part.Get(\"type\").String()\n\n\t\t\t\t\tswitch partType {\n\t\t\t\t\tcase \"thinking\":\n\t\t\t\t\t\t// Only map thinking to reasoning_content for assistant messages (security: prevent injection)\n\t\t\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\t\t\tthinkingText := thinking.GetThinkingText(part)\n\t\t\t\t\t\t\t// Skip empty or whitespace-only thinking\n\t\t\t\t\t\t\tif strings.TrimSpace(thinkingText) != \"\" {\n\t\t\t\t\t\t\t\treasoningParts = append(reasoningParts, thinkingText)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Ignore thinking in user/system roles (AC4)\n\n\t\t\t\t\tcase \"redacted_thinking\":\n\t\t\t\t\t\t// Explicitly ignore redacted_thinking - never map to reasoning_content (AC2)\n\n\t\t\t\t\tcase \"text\", \"image\":\n\t\t\t\t\t\tif contentItem, ok := convertClaudeContentPart(part); ok {\n\t\t\t\t\t\t\tcontentItems = append(contentItems, contentItem)\n\t\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool_use\":\n\t\t\t\t\t\t// Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).\n\t\t\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\t\t\ttoolCallJSON := `{\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\t\t\t\t\t\t\ttoolCallJSON, _ = sjson.Set(toolCallJSON, \"id\", part.Get(\"id\").String())\n\t\t\t\t\t\t\ttoolCallJSON, _ = sjson.Set(toolCallJSON, \"function.name\", part.Get(\"name\").String())\n\n\t\t\t\t\t\t\t// Convert input to arguments JSON string\n\t\t\t\t\t\t\tif input := part.Get(\"input\"); input.Exists() {\n\t\t\t\t\t\t\t\ttoolCallJSON, _ = sjson.Set(toolCallJSON, \"function.arguments\", input.Raw)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttoolCallJSON, _ = sjson.Set(toolCallJSON, \"function.arguments\", \"{}\")\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\ttoolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())\n\t\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool_result\":\n\t\t\t\t\t\t// Collect tool_result to emit after the main message (ensures tool results follow tool_calls)\n\t\t\t\t\t\ttoolResultJSON := `{\"role\":\"tool\",\"tool_call_id\":\"\",\"content\":\"\"}`\n\t\t\t\t\t\ttoolResultJSON, _ = sjson.Set(toolResultJSON, \"tool_call_id\", part.Get(\"tool_use_id\").String())\n\t\t\t\t\t\ttoolResultContent, toolResultContentRaw := convertClaudeToolResultContent(part.Get(\"content\"))\n\t\t\t\t\t\tif toolResultContentRaw {\n\t\t\t\t\t\t\ttoolResultJSON, _ = sjson.SetRaw(toolResultJSON, \"content\", toolResultContent)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttoolResultJSON, _ = sjson.Set(toolResultJSON, \"content\", toolResultContent)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttoolResults = append(toolResults, toolResultJSON)\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\t\t// Build reasoning content string\n\t\t\t\treasoningContent := \"\"\n\t\t\t\tif len(reasoningParts) > 0 {\n\t\t\t\t\treasoningContent = strings.Join(reasoningParts, \"\\n\\n\")\n\t\t\t\t}\n\n\t\t\t\thasContent := len(contentItems) > 0\n\t\t\t\thasReasoning := reasoningContent != \"\"\n\t\t\t\thasToolCalls := len(toolCalls) > 0\n\t\t\t\thasToolResults := len(toolResults) > 0\n\n\t\t\t\t// OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.\n\t\t\t\t// Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),\n\t\t\t\t// then emit the current message's content.\n\t\t\t\tfor _, toolResultJSON := range toolResults {\n\t\t\t\t\tmessagesJSON, _ = sjson.Set(messagesJSON, \"-1\", gjson.Parse(toolResultJSON).Value())\n\t\t\t\t}\n\n\t\t\t\t// For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content\n\t\t\t\t// This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency\n\t\t\t\tif role == \"assistant\" {\n\t\t\t\t\tif hasContent || hasReasoning || hasToolCalls {\n\t\t\t\t\t\tmsgJSON := `{\"role\":\"assistant\"}`\n\n\t\t\t\t\t\t// Add content (as array if we have items, empty string if reasoning-only)\n\t\t\t\t\t\tif hasContent {\n\t\t\t\t\t\t\tcontentArrayJSON := \"[]\"\n\t\t\t\t\t\t\tfor _, contentItem := range contentItems {\n\t\t\t\t\t\t\t\tcontentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, \"-1\", contentItem)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tmsgJSON, _ = sjson.SetRaw(msgJSON, \"content\", contentArrayJSON)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Ensure content field exists for OpenAI compatibility\n\t\t\t\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"content\", \"\")\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Add reasoning_content if present\n\t\t\t\t\t\tif hasReasoning {\n\t\t\t\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"reasoning_content\", reasoningContent)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Add tool_calls if present (in same message as content)\n\t\t\t\t\t\tif hasToolCalls {\n\t\t\t\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"tool_calls\", toolCalls)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tmessagesJSON, _ = sjson.Set(messagesJSON, \"-1\", gjson.Parse(msgJSON).Value())\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// For non-assistant roles: emit content message if we have content\n\t\t\t\t\t// If the message only contains tool_results (no text/image), we still processed them above\n\t\t\t\t\tif hasContent {\n\t\t\t\t\t\tmsgJSON := `{\"role\":\"\"}`\n\t\t\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"role\", role)\n\n\t\t\t\t\t\tcontentArrayJSON := \"[]\"\n\t\t\t\t\t\tfor _, contentItem := range contentItems {\n\t\t\t\t\t\t\tcontentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, \"-1\", contentItem)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsgJSON, _ = sjson.SetRaw(msgJSON, \"content\", contentArrayJSON)\n\n\t\t\t\t\t\tmessagesJSON, _ = sjson.Set(messagesJSON, \"-1\", gjson.Parse(msgJSON).Value())\n\t\t\t\t\t} else if hasToolResults && !hasContent {\n\t\t\t\t\t\t// tool_results already emitted above, no additional user message needed\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t} else if contentResult.Exists() && contentResult.Type == gjson.String {\n\t\t\t\t// Simple string content\n\t\t\t\tmsgJSON := `{\"role\":\"\",\"content\":\"\"}`\n\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"role\", role)\n\t\t\t\tmsgJSON, _ = sjson.Set(msgJSON, \"content\", contentResult.String())\n\t\t\t\tmessagesJSON, _ = sjson.Set(messagesJSON, \"-1\", gjson.Parse(msgJSON).Value())\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Set messages\n\tif gjson.Parse(messagesJSON).IsArray() && len(gjson.Parse(messagesJSON).Array()) > 0 {\n\t\tout, _ = sjson.SetRaw(out, \"messages\", messagesJSON)\n\t}\n\n\t// Process tools - convert Anthropic tools to OpenAI functions\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\tvar toolsJSON = \"[]\"\n\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\topenAIToolJSON := `{\"type\":\"function\",\"function\":{\"name\":\"\",\"description\":\"\"}}`\n\t\t\topenAIToolJSON, _ = sjson.Set(openAIToolJSON, \"function.name\", tool.Get(\"name\").String())\n\t\t\topenAIToolJSON, _ = sjson.Set(openAIToolJSON, \"function.description\", tool.Get(\"description\").String())\n\n\t\t\t// Convert Anthropic input_schema to OpenAI function parameters\n\t\t\tif inputSchema := tool.Get(\"input_schema\"); inputSchema.Exists() {\n\t\t\t\topenAIToolJSON, _ = sjson.Set(openAIToolJSON, \"function.parameters\", inputSchema.Value())\n\t\t\t}\n\n\t\t\ttoolsJSON, _ = sjson.Set(toolsJSON, \"-1\", gjson.Parse(openAIToolJSON).Value())\n\t\t\treturn true\n\t\t})\n\n\t\tif gjson.Parse(toolsJSON).IsArray() && len(gjson.Parse(toolsJSON).Array()) > 0 {\n\t\t\tout, _ = sjson.SetRaw(out, \"tools\", toolsJSON)\n\t\t}\n\t}\n\n\t// Tool choice mapping - convert Anthropic tool_choice to OpenAI format\n\tif toolChoice := root.Get(\"tool_choice\"); toolChoice.Exists() {\n\t\tswitch toolChoice.Get(\"type\").String() {\n\t\tcase \"auto\":\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"auto\")\n\t\tcase \"any\":\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"required\")\n\t\tcase \"tool\":\n\t\t\t// Specific tool choice\n\t\t\ttoolName := toolChoice.Get(\"name\").String()\n\t\t\ttoolChoiceJSON := `{\"type\":\"function\",\"function\":{\"name\":\"\"}}`\n\t\t\ttoolChoiceJSON, _ = sjson.Set(toolChoiceJSON, \"function.name\", toolName)\n\t\t\tout, _ = sjson.SetRaw(out, \"tool_choice\", toolChoiceJSON)\n\t\tdefault:\n\t\t\t// Default to auto if not specified\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"auto\")\n\t\t}\n\t}\n\n\t// Handle user parameter (for tracking)\n\tif user := root.Get(\"user\"); user.Exists() {\n\t\tout, _ = sjson.Set(out, \"user\", user.String())\n\t}\n\n\treturn []byte(out)\n}\n\nfunc convertClaudeContentPart(part gjson.Result) (string, bool) {\n\tpartType := part.Get(\"type\").String()\n\n\tswitch partType {\n\tcase \"text\":\n\t\ttext := part.Get(\"text\").String()\n\t\tif strings.TrimSpace(text) == \"\" {\n\t\t\treturn \"\", false\n\t\t}\n\t\ttextContent := `{\"type\":\"text\",\"text\":\"\"}`\n\t\ttextContent, _ = sjson.Set(textContent, \"text\", text)\n\t\treturn textContent, true\n\n\tcase \"image\":\n\t\tvar imageURL string\n\n\t\tif source := part.Get(\"source\"); source.Exists() {\n\t\t\tsourceType := source.Get(\"type\").String()\n\t\t\tswitch sourceType {\n\t\t\tcase \"base64\":\n\t\t\t\tmediaType := source.Get(\"media_type\").String()\n\t\t\t\tif mediaType == \"\" {\n\t\t\t\t\tmediaType = \"application/octet-stream\"\n\t\t\t\t}\n\t\t\t\tdata := source.Get(\"data\").String()\n\t\t\t\tif data != \"\" {\n\t\t\t\t\timageURL = \"data:\" + mediaType + \";base64,\" + data\n\t\t\t\t}\n\t\t\tcase \"url\":\n\t\t\t\timageURL = source.Get(\"url\").String()\n\t\t\t}\n\t\t}\n\n\t\tif imageURL == \"\" {\n\t\t\timageURL = part.Get(\"url\").String()\n\t\t}\n\n\t\tif imageURL == \"\" {\n\t\t\treturn \"\", false\n\t\t}\n\n\t\timageContent := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\timageContent, _ = sjson.Set(imageContent, \"image_url.url\", imageURL)\n\n\t\treturn imageContent, true\n\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\nfunc convertClaudeToolResultContent(content gjson.Result) (string, bool) {\n\tif !content.Exists() {\n\t\treturn \"\", false\n\t}\n\n\tif content.Type == gjson.String {\n\t\treturn content.String(), false\n\t}\n\n\tif content.IsArray() {\n\t\tvar parts []string\n\t\tcontentJSON := \"[]\"\n\t\thasImagePart := false\n\t\tcontent.ForEach(func(_, item gjson.Result) bool {\n\t\t\tswitch {\n\t\t\tcase item.Type == gjson.String:\n\t\t\t\ttext := item.String()\n\t\t\t\tparts = append(parts, text)\n\t\t\t\ttextContent := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\ttextContent, _ = sjson.Set(textContent, \"text\", text)\n\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"-1\", textContent)\n\t\t\tcase item.IsObject() && item.Get(\"type\").String() == \"text\":\n\t\t\t\ttext := item.Get(\"text\").String()\n\t\t\t\tparts = append(parts, text)\n\t\t\t\ttextContent := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\ttextContent, _ = sjson.Set(textContent, \"text\", text)\n\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"-1\", textContent)\n\t\t\tcase item.IsObject() && item.Get(\"type\").String() == \"image\":\n\t\t\t\tcontentItem, ok := convertClaudeContentPart(item)\n\t\t\t\tif ok {\n\t\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"-1\", contentItem)\n\t\t\t\t\thasImagePart = true\n\t\t\t\t} else {\n\t\t\t\t\tparts = append(parts, item.Raw)\n\t\t\t\t}\n\t\t\tcase item.IsObject() && item.Get(\"text\").Exists() && item.Get(\"text\").Type == gjson.String:\n\t\t\t\tparts = append(parts, item.Get(\"text\").String())\n\t\t\tdefault:\n\t\t\t\tparts = append(parts, item.Raw)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\n\t\tif hasImagePart {\n\t\t\treturn contentJSON, true\n\t\t}\n\n\t\tjoined := strings.Join(parts, \"\\n\\n\")\n\t\tif strings.TrimSpace(joined) != \"\" {\n\t\t\treturn joined, false\n\t\t}\n\t\treturn content.Raw, false\n\t}\n\n\tif content.IsObject() {\n\t\tif content.Get(\"type\").String() == \"image\" {\n\t\t\tcontentItem, ok := convertClaudeContentPart(content)\n\t\t\tif ok {\n\t\t\t\tcontentJSON := \"[]\"\n\t\t\t\tcontentJSON, _ = sjson.SetRaw(contentJSON, \"-1\", contentItem)\n\t\t\t\treturn contentJSON, true\n\t\t\t}\n\t\t}\n\t\tif text := content.Get(\"text\"); text.Exists() && text.Type == gjson.String {\n\t\t\treturn text.String(), false\n\t\t}\n\t\treturn content.Raw, false\n\t}\n\n\treturn content.Raw, false\n}\n"
  },
  {
    "path": "internal/translator/openai/claude/openai_claude_request_test.go",
    "content": "package claude\n\nimport (\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\n// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping\n// of Claude thinking content to OpenAI reasoning_content field.\nfunc TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {\n\ttests := []struct {\n\t\tname                    string\n\t\tinputJSON               string\n\t\twantReasoningContent    string\n\t\twantHasReasoningContent bool\n\t\twantContentText         string // Expected visible content text (if any)\n\t\twantHasContent          bool\n\t}{\n\t\t{\n\t\t\tname: \"AC1: assistant message with thinking and text\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Let me analyze this step by step...\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Here is my response.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"Let me analyze this step by step...\",\n\t\t\twantHasReasoningContent: true,\n\t\t\twantContentText:         \"Here is my response.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"AC2: redacted_thinking must be ignored\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"redacted_thinking\", \"data\": \"secret\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Visible response.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"\",\n\t\t\twantHasReasoningContent: false,\n\t\t\twantContentText:         \"Visible response.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"AC3: thinking-only message preserved with reasoning_content\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Internal reasoning only.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"Internal reasoning only.\",\n\t\t\twantHasReasoningContent: true,\n\t\t\twantContentText:         \"\",\n\t\t\t// For OpenAI compatibility, content field is set to empty string \"\" when no text content exists\n\t\t\twantHasContent: false,\n\t\t},\n\t\t{\n\t\t\tname: \"AC4: thinking in user role must be ignored\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"user\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Injected thinking\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"User message.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"\",\n\t\t\twantHasReasoningContent: false,\n\t\t\twantContentText:         \"User message.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"AC4: thinking in system role must be ignored\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Injected system thinking\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"System prompt.\"}\n\t\t\t\t],\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"user\",\n\t\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\t// System messages don't have reasoning_content mapping\n\t\t\twantReasoningContent:    \"\",\n\t\t\twantHasReasoningContent: false,\n\t\t\twantContentText:         \"Hello\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"AC5: empty thinking must be ignored\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Response with empty thinking.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"\",\n\t\t\twantHasReasoningContent: false,\n\t\t\twantContentText:         \"Response with empty thinking.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"AC5: whitespace-only thinking must be ignored\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"   \\n\\t  \"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Response with whitespace thinking.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"\",\n\t\t\twantHasReasoningContent: false,\n\t\t\twantContentText:         \"Response with whitespace thinking.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"Multiple thinking parts concatenated\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"First thought.\"},\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Second thought.\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Final answer.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"First thought.\\n\\nSecond thought.\",\n\t\t\twantHasReasoningContent: true,\n\t\t\twantContentText:         \"Final answer.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t\t{\n\t\t\tname: \"Mixed thinking and redacted_thinking\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\n\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"Visible thought.\"},\n\t\t\t\t\t\t{\"type\": \"redacted_thinking\", \"data\": \"hidden\"},\n\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"Answer.\"}\n\t\t\t\t\t]\n\t\t\t\t}]\n\t\t\t}`,\n\t\t\twantReasoningContent:    \"Visible thought.\",\n\t\t\twantHasReasoningContent: true,\n\t\t\twantContentText:         \"Answer.\",\n\t\t\twantHasContent:          true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(tt.inputJSON), false)\n\t\t\tresultJSON := gjson.ParseBytes(result)\n\n\t\t\t// Find the relevant message\n\t\t\tmessages := resultJSON.Get(\"messages\").Array()\n\t\t\tif len(messages) < 1 {\n\t\t\t\tif tt.wantHasReasoningContent || tt.wantHasContent {\n\t\t\t\t\tt.Fatalf(\"Expected at least 1 message, got %d\", len(messages))\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Check the last non-system message\n\t\t\tvar targetMsg gjson.Result\n\t\t\tfor i := len(messages) - 1; i >= 0; i-- {\n\t\t\t\tif messages[i].Get(\"role\").String() != \"system\" {\n\t\t\t\t\ttargetMsg = messages[i]\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check reasoning_content\n\t\t\tgotReasoningContent := targetMsg.Get(\"reasoning_content\").String()\n\t\t\tgotHasReasoningContent := targetMsg.Get(\"reasoning_content\").Exists()\n\n\t\t\tif gotHasReasoningContent != tt.wantHasReasoningContent {\n\t\t\t\tt.Errorf(\"reasoning_content existence = %v, want %v\", gotHasReasoningContent, tt.wantHasReasoningContent)\n\t\t\t}\n\n\t\t\tif gotReasoningContent != tt.wantReasoningContent {\n\t\t\t\tt.Errorf(\"reasoning_content = %q, want %q\", gotReasoningContent, tt.wantReasoningContent)\n\t\t\t}\n\n\t\t\t// Check content\n\t\t\tcontent := targetMsg.Get(\"content\")\n\t\t\t// content has meaningful content if it's a non-empty array, or a non-empty string\n\t\t\tvar gotHasContent bool\n\t\t\tswitch {\n\t\t\tcase content.IsArray():\n\t\t\t\tgotHasContent = len(content.Array()) > 0\n\t\t\tcase content.Type == gjson.String:\n\t\t\t\tgotHasContent = content.String() != \"\"\n\t\t\tdefault:\n\t\t\t\tgotHasContent = false\n\t\t\t}\n\n\t\t\tif gotHasContent != tt.wantHasContent {\n\t\t\t\tt.Errorf(\"content existence = %v, want %v\", gotHasContent, tt.wantHasContent)\n\t\t\t}\n\n\t\t\tif tt.wantHasContent && tt.wantContentText != \"\" {\n\t\t\t\t// Find text content\n\t\t\t\tvar foundText string\n\t\t\t\tcontent.ForEach(func(_, v gjson.Result) bool {\n\t\t\t\t\tif v.Get(\"type\").String() == \"text\" {\n\t\t\t\t\t\tfoundText = v.Get(\"text\").String()\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t\tif foundText != tt.wantContentText {\n\t\t\t\t\tt.Errorf(\"content text = %q, want %q\", foundText, tt.wantContentText)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3:\n// that a message with only thinking content is preserved (not dropped).\nfunc TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"What is 2+2?\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [{\"type\": \"thinking\", \"thinking\": \"Let me calculate: 2+2=4\"}]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [{\"type\": \"text\", \"text\": \"Thanks\"}]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t// Should have: user + assistant (thinking-only) + user = 3 messages\n\tif len(messages) != 3 {\n\t\tt.Fatalf(\"Expected 3 messages, got %d. Messages: %v\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\t// Check the assistant message (index 1) has reasoning_content\n\tassistantMsg := messages[1]\n\tif assistantMsg.Get(\"role\").String() != \"assistant\" {\n\t\tt.Errorf(\"Expected message[1] to be assistant, got %s\", assistantMsg.Get(\"role\").String())\n\t}\n\n\tif !assistantMsg.Get(\"reasoning_content\").Exists() {\n\t\tt.Error(\"Expected assistant message to have reasoning_content\")\n\t}\n\n\tif assistantMsg.Get(\"reasoning_content\").String() != \"Let me calculate: 2+2=4\" {\n\t\tt.Errorf(\"Unexpected reasoning_content: %s\", assistantMsg.Get(\"reasoning_content\").String())\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tinputJSON   string\n\t\twantHasSys  bool\n\t\twantSysText string\n\t}{\n\t\t{\n\t\t\tname: \"No system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasSys: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Empty string system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": \"\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasSys: false,\n\t\t},\n\t\t{\n\t\t\tname: \"String system field\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": \"Be helpful\",\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasSys:  true,\n\t\t\twantSysText: \"Be helpful\",\n\t\t},\n\t\t{\n\t\t\tname: \"Array system field with text\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": [{\"type\": \"text\", \"text\": \"Array system\"}],\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasSys:  true,\n\t\t\twantSysText: \"Array system\",\n\t\t},\n\t\t{\n\t\t\tname: \"Array system field with multiple text blocks\",\n\t\t\tinputJSON: `{\n\t\t\t\t\"model\": \"claude-3-opus\",\n\t\t\t\t\"system\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Block 1\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Block 2\"}\n\t\t\t\t],\n\t\t\t\t\"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n\t\t\t}`,\n\t\t\twantHasSys:  true,\n\t\t\twantSysText: \"Block 2\", // We will update the test logic to check all blocks or specifically the second one\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(tt.inputJSON), false)\n\t\t\tresultJSON := gjson.ParseBytes(result)\n\t\t\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t\t\thasSys := false\n\t\t\tvar sysMsg gjson.Result\n\t\t\tif len(messages) > 0 && messages[0].Get(\"role\").String() == \"system\" {\n\t\t\t\thasSys = true\n\t\t\t\tsysMsg = messages[0]\n\t\t\t}\n\n\t\t\tif hasSys != tt.wantHasSys {\n\t\t\t\tt.Errorf(\"got hasSystem = %v, want %v\", hasSys, tt.wantHasSys)\n\t\t\t}\n\n\t\t\tif tt.wantHasSys {\n\t\t\t\t// Check content - it could be string or array in OpenAI\n\t\t\t\tcontent := sysMsg.Get(\"content\")\n\t\t\t\tvar gotText string\n\t\t\t\tif content.IsArray() {\n\t\t\t\t\tarr := content.Array()\n\t\t\t\t\tif len(arr) > 0 {\n\t\t\t\t\t\t// Get the last element's text for validation\n\t\t\t\t\t\tgotText = arr[len(arr)-1].Get(\"text\").String()\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tgotText = content.String()\n\t\t\t\t}\n\n\t\t\t\tif tt.wantSysText != \"\" && gotText != tt.wantSysText {\n\t\t\t\t\tt.Errorf(\"got system text = %q, want %q\", gotText, tt.wantSysText)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"before\"},\n\t\t\t\t\t{\"type\": \"tool_result\", \"tool_use_id\": \"call_1\", \"content\": [{\"type\":\"text\",\"text\":\"tool ok\"}]},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"after\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t// OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).\n\t// Correct order: assistant(tool_calls) + tool(result) + user(before+after)\n\tif len(messages) != 3 {\n\t\tt.Fatalf(\"Expected 3 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\tif messages[0].Get(\"role\").String() != \"assistant\" || !messages[0].Get(\"tool_calls\").Exists() {\n\t\tt.Fatalf(\"Expected messages[0] to be assistant tool_calls, got %s: %s\", messages[0].Get(\"role\").String(), messages[0].Raw)\n\t}\n\n\t// tool message MUST immediately follow assistant(tool_calls) per OpenAI spec\n\tif messages[1].Get(\"role\").String() != \"tool\" {\n\t\tt.Fatalf(\"Expected messages[1] to be tool (must follow tool_calls), got %s\", messages[1].Get(\"role\").String())\n\t}\n\tif got := messages[1].Get(\"tool_call_id\").String(); got != \"call_1\" {\n\t\tt.Fatalf(\"Expected tool_call_id %q, got %q\", \"call_1\", got)\n\t}\n\tif got := messages[1].Get(\"content\").String(); got != \"tool ok\" {\n\t\tt.Fatalf(\"Expected tool content %q, got %q\", \"tool ok\", got)\n\t}\n\n\t// User message comes after tool message\n\tif messages[2].Get(\"role\").String() != \"user\" {\n\t\tt.Fatalf(\"Expected messages[2] to be user, got %s\", messages[2].Get(\"role\").String())\n\t}\n\t// User message should contain both \"before\" and \"after\" text\n\tif got := messages[2].Get(\"content.0.text\").String(); got != \"before\" {\n\t\tt.Fatalf(\"Expected user text[0] %q, got %q\", \"before\", got)\n\t}\n\tif got := messages[2].Get(\"content.1.text\").String(); got != \"after\" {\n\t\tt.Fatalf(\"Expected user text[1] %q, got %q\", \"after\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_result\", \"tool_use_id\": \"call_1\", \"content\": {\"foo\": \"bar\"}}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t// assistant(tool_calls) + tool(result)\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"Expected 2 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\tif messages[1].Get(\"role\").String() != \"tool\" {\n\t\tt.Fatalf(\"Expected messages[1] to be tool, got %s\", messages[1].Get(\"role\").String())\n\t}\n\n\ttoolContent := messages[1].Get(\"content\").String()\n\tparsed := gjson.Parse(toolContent)\n\tif parsed.Get(\"foo\").String() != \"bar\" {\n\t\tt.Fatalf(\"Expected tool content JSON foo=bar, got %q\", toolContent)\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_ToolResultTextAndImageContent(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"call_1\",\n\t\t\t\t\t\t\"content\": [\n\t\t\t\t\t\t\t{\"type\": \"text\", \"text\": \"tool ok\"},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"base64\",\n\t\t\t\t\t\t\t\t\t\"media_type\": \"image/png\",\n\t\t\t\t\t\t\t\t\t\"data\": \"iVBORw0KGgoAAAANSUhEUg==\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"Expected 2 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\ttoolContent := messages[1].Get(\"content\")\n\tif !toolContent.IsArray() {\n\t\tt.Fatalf(\"Expected tool content array, got %s\", toolContent.Raw)\n\t}\n\tif got := toolContent.Get(\"0.type\").String(); got != \"text\" {\n\t\tt.Fatalf(\"Expected first tool content type %q, got %q\", \"text\", got)\n\t}\n\tif got := toolContent.Get(\"0.text\").String(); got != \"tool ok\" {\n\t\tt.Fatalf(\"Expected first tool content text %q, got %q\", \"tool ok\", got)\n\t}\n\tif got := toolContent.Get(\"1.type\").String(); got != \"image_url\" {\n\t\tt.Fatalf(\"Expected second tool content type %q, got %q\", \"image_url\", got)\n\t}\n\tif got := toolContent.Get(\"1.image_url.url\").String(); got != \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==\" {\n\t\tt.Fatalf(\"Unexpected image_url: %q\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_ToolResultURLImageOnly(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}}\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"call_1\",\n\t\t\t\t\t\t\"content\": {\n\t\t\t\t\t\t\t\"type\": \"image\",\n\t\t\t\t\t\t\t\"source\": {\n\t\t\t\t\t\t\t\t\"type\": \"url\",\n\t\t\t\t\t\t\t\t\"url\": \"https://example.com/tool.png\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"Expected 2 messages, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\ttoolContent := messages[1].Get(\"content\")\n\tif !toolContent.IsArray() {\n\t\tt.Fatalf(\"Expected tool content array, got %s\", toolContent.Raw)\n\t}\n\tif got := toolContent.Get(\"0.type\").String(); got != \"image_url\" {\n\t\tt.Fatalf(\"Expected tool content type %q, got %q\", \"image_url\", got)\n\t}\n\tif got := toolContent.Get(\"0.image_url.url\").String(); got != \"https://example.com/tool.png\" {\n\t\tt.Fatalf(\"Unexpected image_url: %q\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"pre\"},\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"post\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t// New behavior: content + tool_calls unified in single assistant message\n\t// Expect: assistant(content[pre,post] + tool_calls)\n\tif len(messages) != 1 {\n\t\tt.Fatalf(\"Expected 1 message, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\tassistantMsg := messages[0]\n\tif assistantMsg.Get(\"role\").String() != \"assistant\" {\n\t\tt.Fatalf(\"Expected messages[0] to be assistant, got %s\", assistantMsg.Get(\"role\").String())\n\t}\n\n\t// Should have both content and tool_calls in same message\n\tif !assistantMsg.Get(\"tool_calls\").Exists() {\n\t\tt.Fatalf(\"Expected assistant message to have tool_calls\")\n\t}\n\tif got := assistantMsg.Get(\"tool_calls.0.id\").String(); got != \"call_1\" {\n\t\tt.Fatalf(\"Expected tool_call id %q, got %q\", \"call_1\", got)\n\t}\n\tif got := assistantMsg.Get(\"tool_calls.0.function.name\").String(); got != \"do_work\" {\n\t\tt.Fatalf(\"Expected tool_call name %q, got %q\", \"do_work\", got)\n\t}\n\n\t// Content should have both pre and post text\n\tif got := assistantMsg.Get(\"content.0.text\").String(); got != \"pre\" {\n\t\tt.Fatalf(\"Expected content[0] text %q, got %q\", \"pre\", got)\n\t}\n\tif got := assistantMsg.Get(\"content.1.text\").String(); got != \"post\" {\n\t\tt.Fatalf(\"Expected content[1] text %q, got %q\", \"post\", got)\n\t}\n}\n\nfunc TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) {\n\tinputJSON := `{\n\t\t\"model\": \"claude-3-opus\",\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"t1\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"pre\"},\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"do_work\", \"input\": {\"a\": 1}},\n\t\t\t\t\t{\"type\": \"thinking\", \"thinking\": \"t2\"},\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"post\"}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tresult := ConvertClaudeRequestToOpenAI(\"test-model\", []byte(inputJSON), false)\n\tresultJSON := gjson.ParseBytes(result)\n\tmessages := resultJSON.Get(\"messages\").Array()\n\n\t// New behavior: all content, thinking, and tool_calls unified in single assistant message\n\t// Expect: assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2])\n\tif len(messages) != 1 {\n\t\tt.Fatalf(\"Expected 1 message, got %d. Messages: %s\", len(messages), resultJSON.Get(\"messages\").Raw)\n\t}\n\n\tassistantMsg := messages[0]\n\tif assistantMsg.Get(\"role\").String() != \"assistant\" {\n\t\tt.Fatalf(\"Expected messages[0] to be assistant, got %s\", assistantMsg.Get(\"role\").String())\n\t}\n\n\t// Should have content with both pre and post\n\tif got := assistantMsg.Get(\"content.0.text\").String(); got != \"pre\" {\n\t\tt.Fatalf(\"Expected content[0] text %q, got %q\", \"pre\", got)\n\t}\n\tif got := assistantMsg.Get(\"content.1.text\").String(); got != \"post\" {\n\t\tt.Fatalf(\"Expected content[1] text %q, got %q\", \"post\", got)\n\t}\n\n\t// Should have tool_calls\n\tif !assistantMsg.Get(\"tool_calls\").Exists() {\n\t\tt.Fatalf(\"Expected assistant message to have tool_calls\")\n\t}\n\n\t// Should have combined reasoning_content from both thinking blocks\n\tif got := assistantMsg.Get(\"reasoning_content\").String(); got != \"t1\\n\\nt2\" {\n\t\tt.Fatalf(\"Expected reasoning_content %q, got %q\", \"t1\\n\\nt2\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/translator/openai/claude/openai_claude_response.go",
    "content": "// Package claude provides response translation functionality for OpenAI to Anthropic API.\n// This package handles the conversion of OpenAI Chat Completions API responses into Anthropic API-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Anthropic API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, and usage metadata appropriately.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar (\n\tdataTag = []byte(\"data:\")\n)\n\n// ConvertOpenAIResponseToAnthropicParams holds parameters for response conversion\ntype ConvertOpenAIResponseToAnthropicParams struct {\n\tMessageID   string\n\tModel       string\n\tCreatedAt   int64\n\tToolNameMap map[string]string\n\tSawToolCall bool\n\t// Content accumulator for streaming\n\tContentAccumulator strings.Builder\n\t// Tool calls accumulator for streaming\n\tToolCallsAccumulator map[int]*ToolCallAccumulator\n\t// Track if text content block has been started\n\tTextContentBlockStarted bool\n\t// Track if thinking content block has been started\n\tThinkingContentBlockStarted bool\n\t// Track finish reason for later use\n\tFinishReason string\n\t// Track if content blocks have been stopped\n\tContentBlocksStopped bool\n\t// Track if message_delta has been sent\n\tMessageDeltaSent bool\n\t// Track if message_start has been sent\n\tMessageStarted bool\n\t// Track if message_stop has been sent\n\tMessageStopSent bool\n\t// Tool call content block index mapping\n\tToolCallBlockIndexes map[int]int\n\t// Index assigned to text content block\n\tTextContentBlockIndex int\n\t// Index assigned to thinking content block\n\tThinkingContentBlockIndex int\n\t// Next available content block index\n\tNextContentBlockIndex int\n}\n\n// ToolCallAccumulator holds the state for accumulating tool call data\ntype ToolCallAccumulator struct {\n\tID        string\n\tName      string\n\tArguments strings.Builder\n}\n\n// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.\n// This function processes OpenAI streaming chunks and transforms them into Anthropic-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Anthropic API format.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - []string: A slice of strings, each containing an Anthropic-compatible JSON response.\nfunc ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertOpenAIResponseToAnthropicParams{\n\t\t\tMessageID:                   \"\",\n\t\t\tModel:                       \"\",\n\t\t\tCreatedAt:                   0,\n\t\t\tToolNameMap:                 nil,\n\t\t\tSawToolCall:                 false,\n\t\t\tContentAccumulator:          strings.Builder{},\n\t\t\tToolCallsAccumulator:        nil,\n\t\t\tTextContentBlockStarted:     false,\n\t\t\tThinkingContentBlockStarted: false,\n\t\t\tFinishReason:                \"\",\n\t\t\tContentBlocksStopped:        false,\n\t\t\tMessageDeltaSent:            false,\n\t\t\tToolCallBlockIndexes:        make(map[int]int),\n\t\t\tTextContentBlockIndex:       -1,\n\t\t\tThinkingContentBlockIndex:   -1,\n\t\t\tNextContentBlockIndex:       0,\n\t\t}\n\t}\n\n\tif !bytes.HasPrefix(rawJSON, dataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\tif (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap == nil {\n\t\t(*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)\n\t}\n\n\t// Check if this is the [DONE] marker\n\trawStr := strings.TrimSpace(string(rawJSON))\n\tif rawStr == \"[DONE]\" {\n\t\treturn convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))\n\t}\n\n\tstreamResult := gjson.GetBytes(originalRequestRawJSON, \"stream\")\n\tif !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {\n\t\treturn convertOpenAINonStreamingToAnthropic(rawJSON)\n\t} else {\n\t\treturn convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))\n\t}\n}\n\nfunc effectiveOpenAIFinishReason(param *ConvertOpenAIResponseToAnthropicParams) string {\n\tif param == nil {\n\t\treturn \"\"\n\t}\n\tif param.SawToolCall {\n\t\treturn \"tool_calls\"\n\t}\n\treturn param.FinishReason\n}\n\n// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events\nfunc convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAIResponseToAnthropicParams) []string {\n\troot := gjson.ParseBytes(rawJSON)\n\tvar results []string\n\n\t// Initialize parameters if needed\n\tif param.MessageID == \"\" {\n\t\tparam.MessageID = root.Get(\"id\").String()\n\t}\n\tif param.Model == \"\" {\n\t\tparam.Model = root.Get(\"model\").String()\n\t}\n\tif param.CreatedAt == 0 {\n\t\tparam.CreatedAt = root.Get(\"created\").Int()\n\t}\n\n\t// Emit message_start on the very first chunk, regardless of whether it has a role field.\n\t// Some providers (like Copilot) may send tool_calls in the first chunk without a role field.\n\tif delta := root.Get(\"choices.0.delta\"); delta.Exists() {\n\t\tif !param.MessageStarted {\n\t\t\t// Send message_start event\n\t\t\tmessageStartJSON := `{\"type\":\"message_start\",\"message\":{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}`\n\t\t\tmessageStartJSON, _ = sjson.Set(messageStartJSON, \"message.id\", param.MessageID)\n\t\t\tmessageStartJSON, _ = sjson.Set(messageStartJSON, \"message.model\", param.Model)\n\t\t\tresults = append(results, \"event: message_start\\ndata: \"+messageStartJSON+\"\\n\\n\")\n\t\t\tparam.MessageStarted = true\n\n\t\t\t// Don't send content_block_start for text here - wait for actual content\n\t\t}\n\n\t\t// Handle reasoning content delta\n\t\tif reasoning := delta.Get(\"reasoning_content\"); reasoning.Exists() {\n\t\t\tfor _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {\n\t\t\t\tif reasoningText == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tstopTextContentBlock(param, &results)\n\t\t\t\tif !param.ThinkingContentBlockStarted {\n\t\t\t\t\tif param.ThinkingContentBlockIndex == -1 {\n\t\t\t\t\t\tparam.ThinkingContentBlockIndex = param.NextContentBlockIndex\n\t\t\t\t\t\tparam.NextContentBlockIndex++\n\t\t\t\t\t}\n\t\t\t\t\tcontentBlockStartJSON := `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}`\n\t\t\t\t\tcontentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, \"index\", param.ThinkingContentBlockIndex)\n\t\t\t\t\tresults = append(results, \"event: content_block_start\\ndata: \"+contentBlockStartJSON+\"\\n\\n\")\n\t\t\t\t\tparam.ThinkingContentBlockStarted = true\n\t\t\t\t}\n\n\t\t\t\tthinkingDeltaJSON := `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}`\n\t\t\t\tthinkingDeltaJSON, _ = sjson.Set(thinkingDeltaJSON, \"index\", param.ThinkingContentBlockIndex)\n\t\t\t\tthinkingDeltaJSON, _ = sjson.Set(thinkingDeltaJSON, \"delta.thinking\", reasoningText)\n\t\t\t\tresults = append(results, \"event: content_block_delta\\ndata: \"+thinkingDeltaJSON+\"\\n\\n\")\n\t\t\t}\n\t\t}\n\n\t\t// Handle content delta\n\t\tif content := delta.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\t// Send content_block_start for text if not already sent\n\t\t\tif !param.TextContentBlockStarted {\n\t\t\t\tstopThinkingContentBlock(param, &results)\n\t\t\t\tif param.TextContentBlockIndex == -1 {\n\t\t\t\t\tparam.TextContentBlockIndex = param.NextContentBlockIndex\n\t\t\t\t\tparam.NextContentBlockIndex++\n\t\t\t\t}\n\t\t\t\tcontentBlockStartJSON := `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}`\n\t\t\t\tcontentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, \"index\", param.TextContentBlockIndex)\n\t\t\t\tresults = append(results, \"event: content_block_start\\ndata: \"+contentBlockStartJSON+\"\\n\\n\")\n\t\t\t\tparam.TextContentBlockStarted = true\n\t\t\t}\n\n\t\t\tcontentDeltaJSON := `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}`\n\t\t\tcontentDeltaJSON, _ = sjson.Set(contentDeltaJSON, \"index\", param.TextContentBlockIndex)\n\t\t\tcontentDeltaJSON, _ = sjson.Set(contentDeltaJSON, \"delta.text\", content.String())\n\t\t\tresults = append(results, \"event: content_block_delta\\ndata: \"+contentDeltaJSON+\"\\n\\n\")\n\n\t\t\t// Accumulate content\n\t\t\tparam.ContentAccumulator.WriteString(content.String())\n\t\t}\n\n\t\t// Handle tool calls\n\t\tif toolCalls := delta.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\tif param.ToolCallsAccumulator == nil {\n\t\t\t\tparam.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)\n\t\t\t}\n\n\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\tparam.SawToolCall = true\n\t\t\t\tindex := int(toolCall.Get(\"index\").Int())\n\t\t\t\tblockIndex := param.toolContentBlockIndex(index)\n\n\t\t\t\t// Initialize accumulator if needed\n\t\t\t\tif _, exists := param.ToolCallsAccumulator[index]; !exists {\n\t\t\t\t\tparam.ToolCallsAccumulator[index] = &ToolCallAccumulator{}\n\t\t\t\t}\n\n\t\t\t\taccumulator := param.ToolCallsAccumulator[index]\n\n\t\t\t\t// Handle tool call ID\n\t\t\t\tif id := toolCall.Get(\"id\"); id.Exists() {\n\t\t\t\t\taccumulator.ID = id.String()\n\t\t\t\t}\n\n\t\t\t\t// Handle function name\n\t\t\t\tif function := toolCall.Get(\"function\"); function.Exists() {\n\t\t\t\t\tif name := function.Get(\"name\"); name.Exists() {\n\t\t\t\t\t\taccumulator.Name = util.MapToolName(param.ToolNameMap, name.String())\n\n\t\t\t\t\t\tstopThinkingContentBlock(param, &results)\n\n\t\t\t\t\t\tstopTextContentBlock(param, &results)\n\n\t\t\t\t\t\t// Send content_block_start for tool_use\n\t\t\t\t\t\tcontentBlockStartJSON := `{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}}`\n\t\t\t\t\t\tcontentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, \"index\", blockIndex)\n\t\t\t\t\t\tcontentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, \"content_block.id\", util.SanitizeClaudeToolID(accumulator.ID))\n\t\t\t\t\t\tcontentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, \"content_block.name\", accumulator.Name)\n\t\t\t\t\t\tresults = append(results, \"event: content_block_start\\ndata: \"+contentBlockStartJSON+\"\\n\\n\")\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle function arguments\n\t\t\t\t\tif args := function.Get(\"arguments\"); args.Exists() {\n\t\t\t\t\t\targsText := args.String()\n\t\t\t\t\t\tif argsText != \"\" {\n\t\t\t\t\t\t\taccumulator.Arguments.WriteString(argsText)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t}\n\n\t// Handle finish_reason (but don't send message_delta/message_stop yet)\n\tif finishReason := root.Get(\"choices.0.finish_reason\"); finishReason.Exists() && finishReason.String() != \"\" {\n\t\treason := finishReason.String()\n\t\tif param.SawToolCall {\n\t\t\tparam.FinishReason = \"tool_calls\"\n\t\t} else {\n\t\t\tparam.FinishReason = reason\n\t\t}\n\n\t\t// Send content_block_stop for thinking content if needed\n\t\tif param.ThinkingContentBlockStarted {\n\t\t\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\t\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", param.ThinkingContentBlockIndex)\n\t\t\tresults = append(results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\t\t\tparam.ThinkingContentBlockStarted = false\n\t\t\tparam.ThinkingContentBlockIndex = -1\n\t\t}\n\n\t\t// Send content_block_stop for text if text content block was started\n\t\tstopTextContentBlock(param, &results)\n\n\t\t// Send content_block_stop for any tool calls\n\t\tif !param.ContentBlocksStopped {\n\t\t\tfor index := range param.ToolCallsAccumulator {\n\t\t\t\taccumulator := param.ToolCallsAccumulator[index]\n\t\t\t\tblockIndex := param.toolContentBlockIndex(index)\n\n\t\t\t\t// Send complete input_json_delta with all accumulated arguments\n\t\t\t\tif accumulator.Arguments.Len() > 0 {\n\t\t\t\t\tinputDeltaJSON := `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`\n\t\t\t\t\tinputDeltaJSON, _ = sjson.Set(inputDeltaJSON, \"index\", blockIndex)\n\t\t\t\t\tinputDeltaJSON, _ = sjson.Set(inputDeltaJSON, \"delta.partial_json\", util.FixJSON(accumulator.Arguments.String()))\n\t\t\t\t\tresults = append(results, \"event: content_block_delta\\ndata: \"+inputDeltaJSON+\"\\n\\n\")\n\t\t\t\t}\n\n\t\t\t\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\t\t\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", blockIndex)\n\t\t\t\tresults = append(results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\t\t\t\tdelete(param.ToolCallBlockIndexes, index)\n\t\t\t}\n\t\t\tparam.ContentBlocksStopped = true\n\t\t}\n\n\t\t// Don't send message_delta here - wait for usage info or [DONE]\n\t}\n\n\t// Handle usage information separately (this comes in a later chunk)\n\t// Only process if usage has actual values (not null)\n\tif param.FinishReason != \"\" {\n\t\tusage := root.Get(\"usage\")\n\t\tvar inputTokens, outputTokens, cachedTokens int64\n\t\tif usage.Exists() && usage.Type != gjson.Null {\n\t\t\tinputTokens, outputTokens, cachedTokens = extractOpenAIUsage(usage)\n\t\t\t// Send message_delta with usage\n\t\t\tmessageDeltaJSON := `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\t\tmessageDeltaJSON, _ = sjson.Set(messageDeltaJSON, \"delta.stop_reason\", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))\n\t\t\tmessageDeltaJSON, _ = sjson.Set(messageDeltaJSON, \"usage.input_tokens\", inputTokens)\n\t\t\tmessageDeltaJSON, _ = sjson.Set(messageDeltaJSON, \"usage.output_tokens\", outputTokens)\n\t\t\tif cachedTokens > 0 {\n\t\t\t\tmessageDeltaJSON, _ = sjson.Set(messageDeltaJSON, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\t\t}\n\t\t\tresults = append(results, \"event: message_delta\\ndata: \"+messageDeltaJSON+\"\\n\\n\")\n\t\t\tparam.MessageDeltaSent = true\n\n\t\t\temitMessageStopIfNeeded(param, &results)\n\t\t}\n\t}\n\n\treturn results\n}\n\n// convertOpenAIDoneToAnthropic handles the [DONE] marker and sends final events\nfunc convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) []string {\n\tvar results []string\n\n\t// Ensure all content blocks are stopped before final events\n\tif param.ThinkingContentBlockStarted {\n\t\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", param.ThinkingContentBlockIndex)\n\t\tresults = append(results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\t\tparam.ThinkingContentBlockStarted = false\n\t\tparam.ThinkingContentBlockIndex = -1\n\t}\n\n\tstopTextContentBlock(param, &results)\n\n\tif !param.ContentBlocksStopped {\n\t\tfor index := range param.ToolCallsAccumulator {\n\t\t\taccumulator := param.ToolCallsAccumulator[index]\n\t\t\tblockIndex := param.toolContentBlockIndex(index)\n\n\t\t\tif accumulator.Arguments.Len() > 0 {\n\t\t\t\tinputDeltaJSON := `{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}`\n\t\t\t\tinputDeltaJSON, _ = sjson.Set(inputDeltaJSON, \"index\", blockIndex)\n\t\t\t\tinputDeltaJSON, _ = sjson.Set(inputDeltaJSON, \"delta.partial_json\", util.FixJSON(accumulator.Arguments.String()))\n\t\t\t\tresults = append(results, \"event: content_block_delta\\ndata: \"+inputDeltaJSON+\"\\n\\n\")\n\t\t\t}\n\n\t\t\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\t\t\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", blockIndex)\n\t\t\tresults = append(results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\t\t\tdelete(param.ToolCallBlockIndexes, index)\n\t\t}\n\t\tparam.ContentBlocksStopped = true\n\t}\n\n\t// If we haven't sent message_delta yet (no usage info was received), send it now\n\tif param.FinishReason != \"\" && !param.MessageDeltaSent {\n\t\tmessageDeltaJSON := `{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\t\tmessageDeltaJSON, _ = sjson.Set(messageDeltaJSON, \"delta.stop_reason\", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))\n\t\tresults = append(results, \"event: message_delta\\ndata: \"+messageDeltaJSON+\"\\n\\n\")\n\t\tparam.MessageDeltaSent = true\n\t}\n\n\temitMessageStopIfNeeded(param, &results)\n\n\treturn results\n}\n\n// convertOpenAINonStreamingToAnthropic converts OpenAI non-streaming response to Anthropic format\nfunc convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string {\n\troot := gjson.ParseBytes(rawJSON)\n\n\tout := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tout, _ = sjson.Set(out, \"id\", root.Get(\"id\").String())\n\tout, _ = sjson.Set(out, \"model\", root.Get(\"model\").String())\n\n\t// Process message content and tool calls\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {\n\t\tchoice := choices.Array()[0] // Take first choice\n\n\t\treasoningNode := choice.Get(\"message.reasoning_content\")\n\t\tfor _, reasoningText := range collectOpenAIReasoningTexts(reasoningNode) {\n\t\t\tif reasoningText == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\t\tblock, _ = sjson.Set(block, \"thinking\", reasoningText)\n\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t}\n\n\t\t// Handle text content\n\t\tif content := choice.Get(\"message.content\"); content.Exists() && content.String() != \"\" {\n\t\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\tblock, _ = sjson.Set(block, \"text\", content.String())\n\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t}\n\n\t\t// Handle tool calls\n\t\tif toolCalls := choice.Get(\"message.tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\ttoolUseBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\ttoolUseBlock, _ = sjson.Set(toolUseBlock, \"id\", util.SanitizeClaudeToolID(toolCall.Get(\"id\").String()))\n\t\t\t\ttoolUseBlock, _ = sjson.Set(toolUseBlock, \"name\", toolCall.Get(\"function.name\").String())\n\n\t\t\t\targsStr := util.FixJSON(toolCall.Get(\"function.arguments\").String())\n\t\t\t\tif argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", argsJSON.Raw)\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", \"{}\")\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", \"{}\")\n\t\t\t\t}\n\n\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolUseBlock)\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\n\t\t// Set stop reason\n\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"stop_reason\", mapOpenAIFinishReasonToAnthropic(finishReason.String()))\n\t\t}\n\t}\n\n\t// Set usage information\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tinputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage)\n\t\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\t\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\t\tif cachedTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\t}\n\t}\n\n\treturn []string{out}\n}\n\n// mapOpenAIFinishReasonToAnthropic maps OpenAI finish reasons to Anthropic equivalents\nfunc mapOpenAIFinishReasonToAnthropic(openAIReason string) string {\n\tswitch openAIReason {\n\tcase \"stop\":\n\t\treturn \"end_turn\"\n\tcase \"length\":\n\t\treturn \"max_tokens\"\n\tcase \"tool_calls\":\n\t\treturn \"tool_use\"\n\tcase \"content_filter\":\n\t\treturn \"end_turn\" // Anthropic doesn't have direct equivalent\n\tcase \"function_call\": // Legacy OpenAI\n\t\treturn \"tool_use\"\n\tdefault:\n\t\treturn \"end_turn\"\n\t}\n}\n\nfunc (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToolIndex int) int {\n\tif idx, ok := p.ToolCallBlockIndexes[openAIToolIndex]; ok {\n\t\treturn idx\n\t}\n\tidx := p.NextContentBlockIndex\n\tp.NextContentBlockIndex++\n\tp.ToolCallBlockIndexes[openAIToolIndex] = idx\n\treturn idx\n}\n\nfunc collectOpenAIReasoningTexts(node gjson.Result) []string {\n\tvar texts []string\n\tif !node.Exists() {\n\t\treturn texts\n\t}\n\n\tif node.IsArray() {\n\t\tnode.ForEach(func(_, value gjson.Result) bool {\n\t\t\ttexts = append(texts, collectOpenAIReasoningTexts(value)...)\n\t\t\treturn true\n\t\t})\n\t\treturn texts\n\t}\n\n\tswitch node.Type {\n\tcase gjson.String:\n\t\tif text := node.String(); text != \"\" {\n\t\t\ttexts = append(texts, text)\n\t\t}\n\tcase gjson.JSON:\n\t\tif text := node.Get(\"text\"); text.Exists() {\n\t\t\tif textStr := text.String(); textStr != \"\" {\n\t\t\t\ttexts = append(texts, textStr)\n\t\t\t}\n\t\t} else if raw := node.Raw; raw != \"\" && !strings.HasPrefix(raw, \"{\") && !strings.HasPrefix(raw, \"[\") {\n\t\t\ttexts = append(texts, raw)\n\t\t}\n\t}\n\n\treturn texts\n}\n\nfunc stopThinkingContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {\n\tif !param.ThinkingContentBlockStarted {\n\t\treturn\n\t}\n\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", param.ThinkingContentBlockIndex)\n\t*results = append(*results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\tparam.ThinkingContentBlockStarted = false\n\tparam.ThinkingContentBlockIndex = -1\n}\n\nfunc emitMessageStopIfNeeded(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {\n\tif param.MessageStopSent {\n\t\treturn\n\t}\n\t*results = append(*results, \"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\")\n\tparam.MessageStopSent = true\n}\n\nfunc stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {\n\tif !param.TextContentBlockStarted {\n\t\treturn\n\t}\n\tcontentBlockStopJSON := `{\"type\":\"content_block_stop\",\"index\":0}`\n\tcontentBlockStopJSON, _ = sjson.Set(contentBlockStopJSON, \"index\", param.TextContentBlockIndex)\n\t*results = append(*results, \"event: content_block_stop\\ndata: \"+contentBlockStopJSON+\"\\n\\n\")\n\tparam.TextContentBlockStarted = false\n\tparam.TextContentBlockIndex = -1\n}\n\n// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: An Anthropic-compatible JSON response.\nfunc ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\t_ = requestRawJSON\n\n\troot := gjson.ParseBytes(rawJSON)\n\ttoolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)\n\tout := `{\"id\":\"\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}`\n\tout, _ = sjson.Set(out, \"id\", root.Get(\"id\").String())\n\tout, _ = sjson.Set(out, \"model\", root.Get(\"model\").String())\n\n\thasToolCall := false\n\tstopReasonSet := false\n\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {\n\t\tchoice := choices.Array()[0]\n\n\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"stop_reason\", mapOpenAIFinishReasonToAnthropic(finishReason.String()))\n\t\t\tstopReasonSet = true\n\t\t}\n\n\t\tif message := choice.Get(\"message\"); message.Exists() {\n\t\t\tif contentResult := message.Get(\"content\"); contentResult.Exists() {\n\t\t\t\tif contentResult.IsArray() {\n\t\t\t\t\tvar textBuilder strings.Builder\n\t\t\t\t\tvar thinkingBuilder strings.Builder\n\n\t\t\t\t\tflushText := func() {\n\t\t\t\t\t\tif textBuilder.Len() == 0 {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\tblock, _ = sjson.Set(block, \"text\", textBuilder.String())\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t\t\ttextBuilder.Reset()\n\t\t\t\t\t}\n\n\t\t\t\t\tflushThinking := func() {\n\t\t\t\t\t\tif thinkingBuilder.Len() == 0 {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\t\t\t\t\tblock, _ = sjson.Set(block, \"thinking\", thinkingBuilder.String())\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t\t\tthinkingBuilder.Reset()\n\t\t\t\t\t}\n\n\t\t\t\t\tfor _, item := range contentResult.Array() {\n\t\t\t\t\t\tswitch item.Get(\"type\").String() {\n\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\tflushThinking()\n\t\t\t\t\t\t\ttextBuilder.WriteString(item.Get(\"text\").String())\n\t\t\t\t\t\tcase \"tool_calls\":\n\t\t\t\t\t\t\tflushThinking()\n\t\t\t\t\t\t\tflushText()\n\t\t\t\t\t\t\ttoolCalls := item.Get(\"tool_calls\")\n\t\t\t\t\t\t\tif toolCalls.IsArray() {\n\t\t\t\t\t\t\t\ttoolCalls.ForEach(func(_, tc gjson.Result) bool {\n\t\t\t\t\t\t\t\t\thasToolCall = true\n\t\t\t\t\t\t\t\t\ttoolUse := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"id\", util.SanitizeClaudeToolID(tc.Get(\"id\").String()))\n\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.Set(toolUse, \"name\", util.MapToolName(toolNameMap, tc.Get(\"function.name\").String()))\n\n\t\t\t\t\t\t\t\t\targsStr := util.FixJSON(tc.Get(\"function.arguments\").String())\n\t\t\t\t\t\t\t\t\tif argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\t\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\t\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", argsJSON.Raw)\n\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", \"{}\")\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\ttoolUse, _ = sjson.SetRaw(toolUse, \"input\", \"{}\")\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolUse)\n\t\t\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"reasoning\":\n\t\t\t\t\t\t\tflushText()\n\t\t\t\t\t\t\tif thinking := item.Get(\"text\"); thinking.Exists() {\n\t\t\t\t\t\t\t\tthinkingBuilder.WriteString(thinking.String())\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\tflushThinking()\n\t\t\t\t\t\t\tflushText()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tflushThinking()\n\t\t\t\t\tflushText()\n\t\t\t\t} else if contentResult.Type == gjson.String {\n\t\t\t\t\ttextContent := contentResult.String()\n\t\t\t\t\tif textContent != \"\" {\n\t\t\t\t\t\tblock := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\tblock, _ = sjson.Set(block, \"text\", textContent)\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif reasoning := message.Get(\"reasoning_content\"); reasoning.Exists() {\n\t\t\t\tfor _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {\n\t\t\t\t\tif reasoningText == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tblock := `{\"type\":\"thinking\",\"thinking\":\"\"}`\n\t\t\t\t\tblock, _ = sjson.Set(block, \"thinking\", reasoningText)\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", block)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif toolCalls := message.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\t\thasToolCall = true\n\t\t\t\t\ttoolUseBlock := `{\"type\":\"tool_use\",\"id\":\"\",\"name\":\"\",\"input\":{}}`\n\t\t\t\t\ttoolUseBlock, _ = sjson.Set(toolUseBlock, \"id\", util.SanitizeClaudeToolID(toolCall.Get(\"id\").String()))\n\t\t\t\t\ttoolUseBlock, _ = sjson.Set(toolUseBlock, \"name\", util.MapToolName(toolNameMap, toolCall.Get(\"function.name\").String()))\n\n\t\t\t\t\targsStr := util.FixJSON(toolCall.Get(\"function.arguments\").String())\n\t\t\t\t\tif argsStr != \"\" && gjson.Valid(argsStr) {\n\t\t\t\t\t\targsJSON := gjson.Parse(argsStr)\n\t\t\t\t\t\tif argsJSON.IsObject() {\n\t\t\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", argsJSON.Raw)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", \"{}\")\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttoolUseBlock, _ = sjson.SetRaw(toolUseBlock, \"input\", \"{}\")\n\t\t\t\t\t}\n\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"content.-1\", toolUseBlock)\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tif respUsage := root.Get(\"usage\"); respUsage.Exists() {\n\t\tinputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage)\n\t\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\t\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\t\tif cachedTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\t}\n\t}\n\n\tif !stopReasonSet {\n\t\tif hasToolCall {\n\t\t\tout, _ = sjson.Set(out, \"stop_reason\", \"tool_use\")\n\t\t} else {\n\t\t\tout, _ = sjson.Set(out, \"stop_reason\", \"end_turn\")\n\t\t}\n\t}\n\n\treturn out\n}\n\nfunc ClaudeTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"input_tokens\":%d}`, count)\n}\n\nfunc extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {\n\tif !usage.Exists() || usage.Type == gjson.Null {\n\t\treturn 0, 0, 0\n\t}\n\n\tinputTokens := usage.Get(\"prompt_tokens\").Int()\n\toutputTokens := usage.Get(\"completion_tokens\").Int()\n\tcachedTokens := usage.Get(\"prompt_tokens_details.cached_tokens\").Int()\n\n\tif cachedTokens > 0 {\n\t\tif inputTokens >= cachedTokens {\n\t\t\tinputTokens -= cachedTokens\n\t\t} else {\n\t\t\tinputTokens = 0\n\t\t}\n\t}\n\n\treturn inputTokens, outputTokens, cachedTokens\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini/init.go",
    "content": "package gemini\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGemini,\n\t\tOpenAI,\n\t\tConvertGeminiRequestToOpenAI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertOpenAIResponseToGemini,\n\t\t\tNonStream:  ConvertOpenAIResponseToGeminiNonStream,\n\t\t\tTokenCount: GeminiTokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini/openai_gemini_request.go",
    "content": "// Package gemini provides request translation functionality for Gemini to OpenAI API.\n// It handles parsing and transforming Gemini API requests into OpenAI Chat Completions API format,\n// extracting model information, generation config, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini API format and OpenAI API's expected format.\npackage gemini\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiRequestToOpenAI parses and transforms a Gemini API request into OpenAI Chat Completions API format.\n// It extracts the model name, generation config, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the OpenAI API.\nfunc ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base OpenAI Chat Completions API template\n\tout := `{\"model\":\"\",\"messages\":[]}`\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Helper for generating tool call IDs in the form: call_<alphanum>\n\tgenToolCallID := func() string {\n\t\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\tvar b strings.Builder\n\t\t// 24 chars random suffix\n\t\tfor i := 0; i < 24; i++ {\n\t\t\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\t\tb.WriteByte(letters[n.Int64()])\n\t\t}\n\t\treturn \"call_\" + b.String()\n\t}\n\n\t// Model mapping\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Generation config mapping\n\tif genConfig := root.Get(\"generationConfig\"); genConfig.Exists() {\n\t\t// Temperature\n\t\tif temp := genConfig.Get(\"temperature\"); temp.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"temperature\", temp.Float())\n\t\t}\n\n\t\t// Max tokens\n\t\tif maxTokens := genConfig.Get(\"maxOutputTokens\"); maxTokens.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t\t}\n\n\t\t// Top P\n\t\tif topP := genConfig.Get(\"topP\"); topP.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t\t}\n\n\t\t// Top K (OpenAI doesn't have direct equivalent, but we can map it)\n\t\tif topK := genConfig.Get(\"topK\"); topK.Exists() {\n\t\t\t// Store as custom parameter for potential use\n\t\t\tout, _ = sjson.Set(out, \"top_k\", topK.Int())\n\t\t}\n\n\t\t// Stop sequences\n\t\tif stopSequences := genConfig.Get(\"stopSequences\"); stopSequences.Exists() && stopSequences.IsArray() {\n\t\t\tvar stops []string\n\t\t\tstopSequences.ForEach(func(_, value gjson.Result) bool {\n\t\t\t\tstops = append(stops, value.String())\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tif len(stops) > 0 {\n\t\t\t\tout, _ = sjson.Set(out, \"stop\", stops)\n\t\t\t}\n\t\t}\n\n\t\t// Candidate count (OpenAI 'n' parameter)\n\t\tif candidateCount := genConfig.Get(\"candidateCount\"); candidateCount.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"n\", candidateCount.Int())\n\t\t}\n\n\t\t// Map Gemini thinkingConfig to OpenAI reasoning_effort.\n\t\t// Always perform conversion to support allowCompat models that may not be in registry.\n\t\t// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).\n\t\tif thinkingConfig := genConfig.Get(\"thinkingConfig\"); thinkingConfig.Exists() && thinkingConfig.IsObject() {\n\t\t\tthinkingLevel := thinkingConfig.Get(\"thinkingLevel\")\n\t\t\tif !thinkingLevel.Exists() {\n\t\t\t\tthinkingLevel = thinkingConfig.Get(\"thinking_level\")\n\t\t\t}\n\t\t\tif thinkingLevel.Exists() {\n\t\t\t\teffort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))\n\t\t\t\tif effort != \"\" {\n\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthinkingBudget := thinkingConfig.Get(\"thinkingBudget\")\n\t\t\t\tif !thinkingBudget.Exists() {\n\t\t\t\t\tthinkingBudget = thinkingConfig.Get(\"thinking_budget\")\n\t\t\t\t}\n\t\t\t\tif thinkingBudget.Exists() {\n\t\t\t\t\tif effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {\n\t\t\t\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Stream parameter\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Process contents (Gemini messages) -> OpenAI messages\n\tvar toolCallIDs []string // Track tool call IDs for matching with tool results\n\n\t// System instruction -> OpenAI system message\n\t// Gemini may provide `systemInstruction` or `system_instruction`; support both keys.\n\tsystemInstruction := root.Get(\"systemInstruction\")\n\tif !systemInstruction.Exists() {\n\t\tsystemInstruction = root.Get(\"system_instruction\")\n\t}\n\tif systemInstruction.Exists() {\n\t\tparts := systemInstruction.Get(\"parts\")\n\t\tmsg := `{\"role\":\"system\",\"content\":[]}`\n\t\thasContent := false\n\n\t\tif parts.Exists() && parts.IsArray() {\n\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t// Handle text parts\n\t\t\t\tif text := part.Get(\"text\"); text.Exists() {\n\t\t\t\t\tcontentPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"text\", text.String())\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", contentPart)\n\t\t\t\t\thasContent = true\n\t\t\t\t}\n\n\t\t\t\t// Handle inline data (e.g., images)\n\t\t\t\tif inlineData := part.Get(\"inlineData\"); inlineData.Exists() {\n\t\t\t\t\tmimeType := inlineData.Get(\"mimeType\").String()\n\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\tmimeType = \"application/octet-stream\"\n\t\t\t\t\t}\n\t\t\t\t\tdata := inlineData.Get(\"data\").String()\n\t\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\n\t\t\t\t\tcontentPart := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"image_url.url\", imageURL)\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content.-1\", contentPart)\n\t\t\t\t\thasContent = true\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\n\t\tif hasContent {\n\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t}\n\t}\n\n\tif contents := root.Get(\"contents\"); contents.Exists() && contents.IsArray() {\n\t\tcontents.ForEach(func(_, content gjson.Result) bool {\n\t\t\trole := content.Get(\"role\").String()\n\t\t\tparts := content.Get(\"parts\")\n\n\t\t\t// Convert role: model -> assistant\n\t\t\tif role == \"model\" {\n\t\t\t\trole = \"assistant\"\n\t\t\t}\n\n\t\t\tmsg := `{\"role\":\"\",\"content\":\"\"}`\n\t\t\tmsg, _ = sjson.Set(msg, \"role\", role)\n\n\t\t\tvar textBuilder strings.Builder\n\t\t\tcontentWrapper := `{\"arr\":[]}`\n\t\t\tcontentPartsCount := 0\n\t\t\tonlyTextContent := true\n\t\t\ttoolCallsWrapper := `{\"arr\":[]}`\n\t\t\ttoolCallsCount := 0\n\n\t\t\tif parts.Exists() && parts.IsArray() {\n\t\t\t\tparts.ForEach(func(_, part gjson.Result) bool {\n\t\t\t\t\t// Handle text parts\n\t\t\t\t\tif text := part.Get(\"text\"); text.Exists() {\n\t\t\t\t\t\tformattedText := text.String()\n\t\t\t\t\t\ttextBuilder.WriteString(formattedText)\n\t\t\t\t\t\tcontentPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"text\", formattedText)\n\t\t\t\t\t\tcontentWrapper, _ = sjson.SetRaw(contentWrapper, \"arr.-1\", contentPart)\n\t\t\t\t\t\tcontentPartsCount++\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle inline data (e.g., images)\n\t\t\t\t\tif inlineData := part.Get(\"inlineData\"); inlineData.Exists() {\n\t\t\t\t\t\tonlyTextContent = false\n\n\t\t\t\t\t\tmimeType := inlineData.Get(\"mimeType\").String()\n\t\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\t\tmimeType = \"application/octet-stream\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdata := inlineData.Get(\"data\").String()\n\t\t\t\t\t\timageURL := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, data)\n\n\t\t\t\t\t\tcontentPart := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"image_url.url\", imageURL)\n\t\t\t\t\t\tcontentWrapper, _ = sjson.SetRaw(contentWrapper, \"arr.-1\", contentPart)\n\t\t\t\t\t\tcontentPartsCount++\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle function calls (Gemini) -> tool calls (OpenAI)\n\t\t\t\t\tif functionCall := part.Get(\"functionCall\"); functionCall.Exists() {\n\t\t\t\t\t\ttoolCallID := genToolCallID()\n\t\t\t\t\t\ttoolCallIDs = append(toolCallIDs, toolCallID)\n\n\t\t\t\t\t\ttoolCall := `{\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\t\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"id\", toolCallID)\n\t\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"function.name\", functionCall.Get(\"name\").String())\n\n\t\t\t\t\t\t// Convert args to arguments JSON string\n\t\t\t\t\t\tif args := functionCall.Get(\"args\"); args.Exists() {\n\t\t\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"function.arguments\", args.Raw)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"function.arguments\", \"{}\")\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttoolCallsWrapper, _ = sjson.SetRaw(toolCallsWrapper, \"arr.-1\", toolCall)\n\t\t\t\t\t\ttoolCallsCount++\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle function responses (Gemini) -> tool role messages (OpenAI)\n\t\t\t\t\tif functionResponse := part.Get(\"functionResponse\"); functionResponse.Exists() {\n\t\t\t\t\t\t// Create tool message for function response\n\t\t\t\t\t\ttoolMsg := `{\"role\":\"tool\",\"tool_call_id\":\"\",\"content\":\"\"}`\n\n\t\t\t\t\t\t// Convert response.content to JSON string\n\t\t\t\t\t\tif response := functionResponse.Get(\"response\"); response.Exists() {\n\t\t\t\t\t\t\tif contentField := response.Get(\"content\"); contentField.Exists() {\n\t\t\t\t\t\t\t\ttoolMsg, _ = sjson.Set(toolMsg, \"content\", contentField.Raw)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttoolMsg, _ = sjson.Set(toolMsg, \"content\", response.Raw)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Try to match with previous tool call ID\n\t\t\t\t\t\t_ = functionResponse.Get(\"name\").String() // functionName not used for now\n\t\t\t\t\t\tif len(toolCallIDs) > 0 {\n\t\t\t\t\t\t\t// Use the last tool call ID (simple matching by function name)\n\t\t\t\t\t\t\t// In a real implementation, you might want more sophisticated matching\n\t\t\t\t\t\t\ttoolMsg, _ = sjson.Set(toolMsg, \"tool_call_id\", toolCallIDs[len(toolCallIDs)-1])\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Generate a tool call ID if none available\n\t\t\t\t\t\t\ttoolMsg, _ = sjson.Set(toolMsg, \"tool_call_id\", genToolCallID())\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", toolMsg)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Set content\n\t\t\tif contentPartsCount > 0 {\n\t\t\t\tif onlyTextContent {\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"content\", textBuilder.String())\n\t\t\t\t} else {\n\t\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"content\", gjson.Get(contentWrapper, \"arr\").Raw)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Set tool calls if any\n\t\t\tif toolCallsCount > 0 {\n\t\t\t\tmsg, _ = sjson.SetRaw(msg, \"tool_calls\", gjson.Get(toolCallsWrapper, \"arr\").Raw)\n\t\t\t}\n\n\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Tools mapping: Gemini tools -> OpenAI tools\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\tif functionDeclarations := tool.Get(\"functionDeclarations\"); functionDeclarations.Exists() && functionDeclarations.IsArray() {\n\t\t\t\tfunctionDeclarations.ForEach(func(_, funcDecl gjson.Result) bool {\n\t\t\t\t\topenAITool := `{\"type\":\"function\",\"function\":{\"name\":\"\",\"description\":\"\"}}`\n\t\t\t\t\topenAITool, _ = sjson.Set(openAITool, \"function.name\", funcDecl.Get(\"name\").String())\n\t\t\t\t\topenAITool, _ = sjson.Set(openAITool, \"function.description\", funcDecl.Get(\"description\").String())\n\n\t\t\t\t\t// Convert parameters schema\n\t\t\t\t\tif parameters := funcDecl.Get(\"parameters\"); parameters.Exists() {\n\t\t\t\t\t\topenAITool, _ = sjson.SetRaw(openAITool, \"function.parameters\", parameters.Raw)\n\t\t\t\t\t} else if parameters := funcDecl.Get(\"parametersJsonSchema\"); parameters.Exists() {\n\t\t\t\t\t\topenAITool, _ = sjson.SetRaw(openAITool, \"function.parameters\", parameters.Raw)\n\t\t\t\t\t}\n\n\t\t\t\t\tout, _ = sjson.SetRaw(out, \"tools.-1\", openAITool)\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Tool choice mapping (Gemini doesn't have direct equivalent, but we can handle it)\n\tif toolConfig := root.Get(\"toolConfig\"); toolConfig.Exists() {\n\t\tif functionCallingConfig := toolConfig.Get(\"functionCallingConfig\"); functionCallingConfig.Exists() {\n\t\t\tmode := functionCallingConfig.Get(\"mode\").String()\n\t\t\tswitch mode {\n\t\t\tcase \"NONE\":\n\t\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"none\")\n\t\t\tcase \"AUTO\":\n\t\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"auto\")\n\t\t\tcase \"ANY\":\n\t\t\t\tout, _ = sjson.Set(out, \"tool_choice\", \"required\")\n\t\t\t}\n\t\t}\n\t}\n\n\treturn []byte(out)\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini/openai_gemini_response.go",
    "content": "// Package gemini provides response translation functionality for OpenAI to Gemini API.\n// This package handles the conversion of OpenAI Chat Completions API responses into Gemini API-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, and usage metadata appropriately.\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertOpenAIResponseToGeminiParams holds parameters for response conversion\ntype ConvertOpenAIResponseToGeminiParams struct {\n\t// Tool calls accumulator for streaming\n\tToolCallsAccumulator map[int]*ToolCallAccumulator\n\t// Content accumulator for streaming\n\tContentAccumulator strings.Builder\n\t// Track if this is the first chunk\n\tIsFirstChunk bool\n}\n\n// ToolCallAccumulator holds the state for accumulating tool call data\ntype ToolCallAccumulator struct {\n\tID        string\n\tName      string\n\tArguments strings.Builder\n}\n\n// ConvertOpenAIResponseToGemini converts OpenAI Chat Completions streaming response format to Gemini API format.\n// This function processes OpenAI streaming chunks and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response.\nfunc ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &ConvertOpenAIResponseToGeminiParams{\n\t\t\tToolCallsAccumulator: nil,\n\t\t\tContentAccumulator:   strings.Builder{},\n\t\t\tIsFirstChunk:         false,\n\t\t}\n\t}\n\n\t// Handle [DONE] marker\n\tif strings.TrimSpace(string(rawJSON)) == \"[DONE]\" {\n\t\treturn []string{}\n\t}\n\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Initialize accumulators if needed\n\tif (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator == nil {\n\t\t(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)\n\t}\n\n\t// Process choices\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() {\n\t\t// Handle empty choices array (usage-only chunk)\n\t\tif len(choices.Array()) == 0 {\n\t\t\t// This is a usage-only chunk, handle usage and return\n\t\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t\ttemplate := `{\"candidates\":[],\"usageMetadata\":{}}`\n\n\t\t\t\t// Set model if available\n\t\t\t\tif model := root.Get(\"model\"); model.Exists() {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"model\", model.String())\n\t\t\t\t}\n\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.promptTokenCount\", usage.Get(\"prompt_tokens\").Int())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.candidatesTokenCount\", usage.Get(\"completion_tokens\").Int())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.totalTokenCount\", usage.Get(\"total_tokens\").Int())\n\t\t\t\tif reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.thoughtsTokenCount\", reasoningTokens)\n\t\t\t\t}\n\t\t\t\treturn []string{template}\n\t\t\t}\n\t\t\treturn []string{}\n\t\t}\n\n\t\tvar results []string\n\n\t\tchoices.ForEach(func(choiceIndex, choice gjson.Result) bool {\n\t\t\t// Base Gemini response template without finishReason; set when known\n\t\t\ttemplate := `{\"candidates\":[{\"content\":{\"parts\":[],\"role\":\"model\"},\"index\":0}]}`\n\n\t\t\t// Set model if available\n\t\t\tif model := root.Get(\"model\"); model.Exists() {\n\t\t\t\ttemplate, _ = sjson.Set(template, \"model\", model.String())\n\t\t\t}\n\n\t\t\t_ = int(choice.Get(\"index\").Int()) // choiceIdx not used in streaming\n\t\t\tdelta := choice.Get(\"delta\")\n\t\t\tbaseTemplate := template\n\n\t\t\t// Handle role (only in first chunk)\n\t\t\tif role := delta.Get(\"role\"); role.Exists() && (*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk {\n\t\t\t\t// OpenAI assistant -> Gemini model\n\t\t\t\tif role.String() == \"assistant\" {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.content.role\", \"model\")\n\t\t\t\t}\n\t\t\t\t(*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk = false\n\t\t\t\tresults = append(results, template)\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\tvar chunkOutputs []string\n\n\t\t\t// Handle reasoning/thinking delta\n\t\t\tif reasoning := delta.Get(\"reasoning_content\"); reasoning.Exists() {\n\t\t\t\tfor _, reasoningText := range extractReasoningTexts(reasoning) {\n\t\t\t\t\tif reasoningText == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\treasoningTemplate := baseTemplate\n\t\t\t\t\treasoningTemplate, _ = sjson.Set(reasoningTemplate, \"candidates.0.content.parts.0.thought\", true)\n\t\t\t\t\treasoningTemplate, _ = sjson.Set(reasoningTemplate, \"candidates.0.content.parts.0.text\", reasoningText)\n\t\t\t\t\tchunkOutputs = append(chunkOutputs, reasoningTemplate)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Handle content delta\n\t\t\tif content := delta.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\t\tcontentText := content.String()\n\t\t\t\t(*param).(*ConvertOpenAIResponseToGeminiParams).ContentAccumulator.WriteString(contentText)\n\n\t\t\t\t// Create text part for this delta\n\t\t\t\tcontentTemplate := baseTemplate\n\t\t\t\tcontentTemplate, _ = sjson.Set(contentTemplate, \"candidates.0.content.parts.0.text\", contentText)\n\t\t\t\tchunkOutputs = append(chunkOutputs, contentTemplate)\n\t\t\t}\n\n\t\t\tif len(chunkOutputs) > 0 {\n\t\t\t\tresults = append(results, chunkOutputs...)\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Handle tool calls delta\n\t\t\tif toolCalls := delta.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\t\ttoolIndex := int(toolCall.Get(\"index\").Int())\n\t\t\t\t\ttoolID := toolCall.Get(\"id\").String()\n\t\t\t\t\ttoolType := toolCall.Get(\"type\").String()\n\t\t\t\t\tfunction := toolCall.Get(\"function\")\n\n\t\t\t\t\t// Skip non-function tool calls explicitly marked as other types.\n\t\t\t\t\tif toolType != \"\" && toolType != \"function\" {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// OpenAI streaming deltas may omit the type field while still carrying function data.\n\t\t\t\t\tif !function.Exists() {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\tfunctionName := function.Get(\"name\").String()\n\t\t\t\t\tfunctionArgs := function.Get(\"arguments\").String()\n\n\t\t\t\t\t// Initialize accumulator if needed so later deltas without type can append arguments.\n\t\t\t\t\tif _, exists := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]; !exists {\n\t\t\t\t\t\t(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex] = &ToolCallAccumulator{\n\t\t\t\t\t\t\tID:   toolID,\n\t\t\t\t\t\t\tName: functionName,\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tacc := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]\n\n\t\t\t\t\t// Update ID if provided\n\t\t\t\t\tif toolID != \"\" {\n\t\t\t\t\t\tacc.ID = toolID\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update name if provided\n\t\t\t\t\tif functionName != \"\" {\n\t\t\t\t\t\tacc.Name = functionName\n\t\t\t\t\t}\n\n\t\t\t\t\t// Accumulate arguments\n\t\t\t\t\tif functionArgs != \"\" {\n\t\t\t\t\t\tacc.Arguments.WriteString(functionArgs)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\t\t// Don't output anything for tool call deltas - wait for completion\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Handle finish reason\n\t\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() {\n\t\t\t\tgeminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"candidates.0.finishReason\", geminiFinishReason)\n\n\t\t\t\t// If we have accumulated tool calls, output them now\n\t\t\t\tif len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 {\n\t\t\t\t\tpartIndex := 0\n\t\t\t\t\tfor _, accumulator := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator {\n\t\t\t\t\t\tnamePath := fmt.Sprintf(\"candidates.0.content.parts.%d.functionCall.name\", partIndex)\n\t\t\t\t\t\targsPath := fmt.Sprintf(\"candidates.0.content.parts.%d.functionCall.args\", partIndex)\n\t\t\t\t\t\ttemplate, _ = sjson.Set(template, namePath, accumulator.Name)\n\t\t\t\t\t\ttemplate, _ = sjson.SetRaw(template, argsPath, parseArgsToObjectRaw(accumulator.Arguments.String()))\n\t\t\t\t\t\tpartIndex++\n\t\t\t\t\t}\n\n\t\t\t\t\t// Clear accumulators\n\t\t\t\t\t(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)\n\t\t\t\t}\n\n\t\t\t\tresults = append(results, template)\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Handle usage information\n\t\t\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.promptTokenCount\", usage.Get(\"prompt_tokens\").Int())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.candidatesTokenCount\", usage.Get(\"completion_tokens\").Int())\n\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.totalTokenCount\", usage.Get(\"total_tokens\").Int())\n\t\t\t\tif reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {\n\t\t\t\t\ttemplate, _ = sjson.Set(template, \"usageMetadata.thoughtsTokenCount\", reasoningTokens)\n\t\t\t\t}\n\t\t\t\tresults = append(results, template)\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t\treturn results\n\t}\n\treturn []string{}\n}\n\n// mapOpenAIFinishReasonToGemini maps OpenAI finish reasons to Gemini finish reasons\nfunc mapOpenAIFinishReasonToGemini(openAIReason string) string {\n\tswitch openAIReason {\n\tcase \"stop\":\n\t\treturn \"STOP\"\n\tcase \"length\":\n\t\treturn \"MAX_TOKENS\"\n\tcase \"tool_calls\":\n\t\treturn \"STOP\" // Gemini doesn't have a specific tool_calls finish reason\n\tcase \"content_filter\":\n\t\treturn \"SAFETY\"\n\tdefault:\n\t\treturn \"STOP\"\n\t}\n}\n\n// parseArgsToObjectRaw safely parses a JSON string of function arguments into an object JSON string.\n// It returns \"{}\" if the input is empty or cannot be parsed as a JSON object.\nfunc parseArgsToObjectRaw(argsStr string) string {\n\ttrimmed := strings.TrimSpace(argsStr)\n\tif trimmed == \"\" || trimmed == \"{}\" {\n\t\treturn \"{}\"\n\t}\n\n\t// First try strict JSON\n\tif gjson.Valid(trimmed) {\n\t\tstrict := gjson.Parse(trimmed)\n\t\tif strict.IsObject() {\n\t\t\treturn strict.Raw\n\t\t}\n\t}\n\n\t// Tolerant parse: handle streams where values are barewords (e.g., 北京, celsius)\n\ttolerant := tolerantParseJSONObjectRaw(trimmed)\n\tif tolerant != \"{}\" {\n\t\treturn tolerant\n\t}\n\n\t// Fallback: return empty object when parsing fails\n\treturn \"{}\"\n}\n\nfunc escapeSjsonPathKey(key string) string {\n\tkey = strings.ReplaceAll(key, `\\`, `\\\\`)\n\tkey = strings.ReplaceAll(key, `.`, `\\.`)\n\treturn key\n}\n\n// tolerantParseJSONObjectRaw attempts to parse a JSON-like object string into a JSON object string, tolerating\n// bareword values (unquoted strings) commonly seen during streamed tool calls.\n// Example input: {\"location\": 北京, \"unit\": celsius}\nfunc tolerantParseJSONObjectRaw(s string) string {\n\t// Ensure we operate within the outermost braces if present\n\tstart := strings.Index(s, \"{\")\n\tend := strings.LastIndex(s, \"}\")\n\tif start == -1 || end == -1 || start >= end {\n\t\treturn \"{}\"\n\t}\n\tcontent := s[start+1 : end]\n\n\trunes := []rune(content)\n\tn := len(runes)\n\ti := 0\n\tresult := \"{}\"\n\n\tfor i < n {\n\t\t// Skip whitespace and commas\n\t\tfor i < n && (runes[i] == ' ' || runes[i] == '\\n' || runes[i] == '\\r' || runes[i] == '\\t' || runes[i] == ',') {\n\t\t\ti++\n\t\t}\n\t\tif i >= n {\n\t\t\tbreak\n\t\t}\n\n\t\t// Expect quoted key\n\t\tif runes[i] != '\"' {\n\t\t\t// Unable to parse this segment reliably; skip to next comma\n\t\t\tfor i < n && runes[i] != ',' {\n\t\t\t\ti++\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse JSON string for key\n\t\tkeyToken, nextIdx := parseJSONStringRunes(runes, i)\n\t\tif nextIdx == -1 {\n\t\t\tbreak\n\t\t}\n\t\tkeyName := jsonStringTokenToRawString(keyToken)\n\t\tsjsonKey := escapeSjsonPathKey(keyName)\n\t\ti = nextIdx\n\n\t\t// Skip whitespace\n\t\tfor i < n && (runes[i] == ' ' || runes[i] == '\\n' || runes[i] == '\\r' || runes[i] == '\\t') {\n\t\t\ti++\n\t\t}\n\t\tif i >= n || runes[i] != ':' {\n\t\t\tbreak\n\t\t}\n\t\ti++ // skip ':'\n\t\t// Skip whitespace\n\t\tfor i < n && (runes[i] == ' ' || runes[i] == '\\n' || runes[i] == '\\r' || runes[i] == '\\t') {\n\t\t\ti++\n\t\t}\n\t\tif i >= n {\n\t\t\tbreak\n\t\t}\n\n\t\t// Parse value (string, number, object/array, bareword)\n\t\tswitch runes[i] {\n\t\tcase '\"':\n\t\t\t// JSON string\n\t\t\tvalToken, ni := parseJSONStringRunes(runes, i)\n\t\t\tif ni == -1 {\n\t\t\t\t// Malformed; treat as empty string\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, \"\")\n\t\t\t\ti = n\n\t\t\t} else {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, jsonStringTokenToRawString(valToken))\n\t\t\t\ti = ni\n\t\t\t}\n\t\tcase '{', '[':\n\t\t\t// Bracketed value: attempt to capture balanced structure\n\t\t\tseg, ni := captureBracketed(runes, i)\n\t\t\tif ni == -1 {\n\t\t\t\ti = n\n\t\t\t} else {\n\t\t\t\tif gjson.Valid(seg) {\n\t\t\t\t\tresult, _ = sjson.SetRaw(result, sjsonKey, seg)\n\t\t\t\t} else {\n\t\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, seg)\n\t\t\t\t}\n\t\t\t\ti = ni\n\t\t\t}\n\t\tdefault:\n\t\t\t// Bare token until next comma or end\n\t\t\tj := i\n\t\t\tfor j < n && runes[j] != ',' {\n\t\t\t\tj++\n\t\t\t}\n\t\t\ttoken := strings.TrimSpace(string(runes[i:j]))\n\t\t\t// Interpret common JSON atoms and numbers; otherwise treat as string\n\t\t\tif token == \"true\" {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, true)\n\t\t\t} else if token == \"false\" {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, false)\n\t\t\t} else if token == \"null\" {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, nil)\n\t\t\t} else if numVal, ok := tryParseNumber(token); ok {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, numVal)\n\t\t\t} else {\n\t\t\t\tresult, _ = sjson.Set(result, sjsonKey, token)\n\t\t\t}\n\t\t\ti = j\n\t\t}\n\n\t\t// Skip trailing whitespace and optional comma before next pair\n\t\tfor i < n && (runes[i] == ' ' || runes[i] == '\\n' || runes[i] == '\\r' || runes[i] == '\\t') {\n\t\t\ti++\n\t\t}\n\t\tif i < n && runes[i] == ',' {\n\t\t\ti++\n\t\t}\n\t}\n\n\treturn result\n}\n\n// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.\nfunc parseJSONStringRunes(runes []rune, start int) (string, int) {\n\tif start >= len(runes) || runes[start] != '\"' {\n\t\treturn \"\", -1\n\t}\n\ti := start + 1\n\tescaped := false\n\tfor i < len(runes) {\n\t\tr := runes[i]\n\t\tif r == '\\\\' && !escaped {\n\t\t\tescaped = true\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\tif r == '\"' && !escaped {\n\t\t\treturn string(runes[start : i+1]), i + 1\n\t\t}\n\t\tescaped = false\n\t\ti++\n\t}\n\treturn string(runes[start:]), -1\n}\n\n// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.\nfunc jsonStringTokenToRawString(token string) string {\n\tr := gjson.Parse(token)\n\tif r.Type == gjson.String {\n\t\treturn r.String()\n\t}\n\t// Fallback: strip surrounding quotes if present\n\tif len(token) >= 2 && token[0] == '\"' && token[len(token)-1] == '\"' {\n\t\treturn token[1 : len(token)-1]\n\t}\n\treturn token\n}\n\n// captureBracketed captures a balanced JSON object/array starting at index i.\n// Returns the segment string and the index just after it; -1 if malformed.\nfunc captureBracketed(runes []rune, i int) (string, int) {\n\tif i >= len(runes) {\n\t\treturn \"\", -1\n\t}\n\tstartRune := runes[i]\n\tvar endRune rune\n\tif startRune == '{' {\n\t\tendRune = '}'\n\t} else if startRune == '[' {\n\t\tendRune = ']'\n\t} else {\n\t\treturn \"\", -1\n\t}\n\tdepth := 0\n\tj := i\n\tinStr := false\n\tescaped := false\n\tfor j < len(runes) {\n\t\tr := runes[j]\n\t\tif inStr {\n\t\t\tif r == '\\\\' && !escaped {\n\t\t\t\tescaped = true\n\t\t\t\tj++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif r == '\"' && !escaped {\n\t\t\t\tinStr = false\n\t\t\t} else {\n\t\t\t\tescaped = false\n\t\t\t}\n\t\t\tj++\n\t\t\tcontinue\n\t\t}\n\t\tif r == '\"' {\n\t\t\tinStr = true\n\t\t\tj++\n\t\t\tcontinue\n\t\t}\n\t\tif r == startRune {\n\t\t\tdepth++\n\t\t} else if r == endRune {\n\t\t\tdepth--\n\t\t\tif depth == 0 {\n\t\t\t\treturn string(runes[i : j+1]), j + 1\n\t\t\t}\n\t\t}\n\t\tj++\n\t}\n\treturn string(runes[i:]), -1\n}\n\n// tryParseNumber attempts to parse a string as an int or float.\nfunc tryParseNumber(s string) (interface{}, bool) {\n\tif s == \"\" {\n\t\treturn nil, false\n\t}\n\t// Try integer\n\tif i64, errParseInt := strconv.ParseInt(s, 10, 64); errParseInt == nil {\n\t\treturn i64, true\n\t}\n\tif u64, errParseUInt := strconv.ParseUint(s, 10, 64); errParseUInt == nil {\n\t\treturn u64, true\n\t}\n\tif f64, errParseFloat := strconv.ParseFloat(s, 64); errParseFloat == nil {\n\t\treturn f64, true\n\t}\n\treturn nil, false\n}\n\n// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response.\nfunc ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Base Gemini response template without finishReason; set when known\n\tout := `{\"candidates\":[{\"content\":{\"parts\":[],\"role\":\"model\"},\"index\":0}]}`\n\n\t// Set model if available\n\tif model := root.Get(\"model\"); model.Exists() {\n\t\tout, _ = sjson.Set(out, \"model\", model.String())\n\t}\n\n\t// Process choices\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() {\n\t\tchoices.ForEach(func(choiceIndex, choice gjson.Result) bool {\n\t\t\tchoiceIdx := int(choice.Get(\"index\").Int())\n\t\t\tmessage := choice.Get(\"message\")\n\n\t\t\t// Set role\n\t\t\tif role := message.Get(\"role\"); role.Exists() {\n\t\t\t\tif role.String() == \"assistant\" {\n\t\t\t\t\tout, _ = sjson.Set(out, \"candidates.0.content.role\", \"model\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tpartIndex := 0\n\n\t\t\t// Handle reasoning content before visible text\n\t\t\tif reasoning := message.Get(\"reasoning_content\"); reasoning.Exists() {\n\t\t\t\tfor _, reasoningText := range extractReasoningTexts(reasoning) {\n\t\t\t\t\tif reasoningText == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tout, _ = sjson.Set(out, fmt.Sprintf(\"candidates.0.content.parts.%d.thought\", partIndex), true)\n\t\t\t\t\tout, _ = sjson.Set(out, fmt.Sprintf(\"candidates.0.content.parts.%d.text\", partIndex), reasoningText)\n\t\t\t\t\tpartIndex++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Handle content first\n\t\t\tif content := message.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\t\tout, _ = sjson.Set(out, fmt.Sprintf(\"candidates.0.content.parts.%d.text\", partIndex), content.String())\n\t\t\t\tpartIndex++\n\t\t\t}\n\n\t\t\t// Handle tool calls\n\t\t\tif toolCalls := message.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\t\ttoolCalls.ForEach(func(_, toolCall gjson.Result) bool {\n\t\t\t\t\tif toolCall.Get(\"type\").String() == \"function\" {\n\t\t\t\t\t\tfunction := toolCall.Get(\"function\")\n\t\t\t\t\t\tfunctionName := function.Get(\"name\").String()\n\t\t\t\t\t\tfunctionArgs := function.Get(\"arguments\").String()\n\n\t\t\t\t\t\tnamePath := fmt.Sprintf(\"candidates.0.content.parts.%d.functionCall.name\", partIndex)\n\t\t\t\t\t\targsPath := fmt.Sprintf(\"candidates.0.content.parts.%d.functionCall.args\", partIndex)\n\t\t\t\t\t\tout, _ = sjson.Set(out, namePath, functionName)\n\t\t\t\t\t\tout, _ = sjson.SetRaw(out, argsPath, parseArgsToObjectRaw(functionArgs))\n\t\t\t\t\t\tpartIndex++\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Handle finish reason\n\t\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() {\n\t\t\t\tgeminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())\n\t\t\t\tout, _ = sjson.Set(out, \"candidates.0.finishReason\", geminiFinishReason)\n\t\t\t}\n\n\t\t\t// Set index\n\t\t\tout, _ = sjson.Set(out, \"candidates.0.index\", choiceIdx)\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Handle usage information\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tout, _ = sjson.Set(out, \"usageMetadata.promptTokenCount\", usage.Get(\"prompt_tokens\").Int())\n\t\tout, _ = sjson.Set(out, \"usageMetadata.candidatesTokenCount\", usage.Get(\"completion_tokens\").Int())\n\t\tout, _ = sjson.Set(out, \"usageMetadata.totalTokenCount\", usage.Get(\"total_tokens\").Int())\n\t\tif reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usageMetadata.thoughtsTokenCount\", reasoningTokens)\n\t\t}\n\t}\n\n\treturn out\n}\n\nfunc GeminiTokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n\nfunc reasoningTokensFromUsage(usage gjson.Result) int64 {\n\tif usage.Exists() {\n\t\tif v := usage.Get(\"completion_tokens_details.reasoning_tokens\"); v.Exists() {\n\t\t\treturn v.Int()\n\t\t}\n\t\tif v := usage.Get(\"output_tokens_details.reasoning_tokens\"); v.Exists() {\n\t\t\treturn v.Int()\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc extractReasoningTexts(node gjson.Result) []string {\n\tvar texts []string\n\tif !node.Exists() {\n\t\treturn texts\n\t}\n\n\tif node.IsArray() {\n\t\tnode.ForEach(func(_, value gjson.Result) bool {\n\t\t\ttexts = append(texts, extractReasoningTexts(value)...)\n\t\t\treturn true\n\t\t})\n\t\treturn texts\n\t}\n\n\tswitch node.Type {\n\tcase gjson.String:\n\t\ttexts = append(texts, node.String())\n\tcase gjson.JSON:\n\t\tif text := node.Get(\"text\"); text.Exists() {\n\t\t\ttexts = append(texts, text.String())\n\t\t} else if raw := strings.TrimSpace(node.Raw); raw != \"\" && !strings.HasPrefix(raw, \"{\") && !strings.HasPrefix(raw, \"[\") {\n\t\t\ttexts = append(texts, raw)\n\t\t}\n\t}\n\n\treturn texts\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini-cli/init.go",
    "content": "package geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tGeminiCLI,\n\t\tOpenAI,\n\t\tConvertGeminiCLIRequestToOpenAI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:     ConvertOpenAIResponseToGeminiCLI,\n\t\t\tNonStream:  ConvertOpenAIResponseToGeminiCLINonStream,\n\t\t\tTokenCount: GeminiCLITokenCount,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini-cli/openai_gemini_request.go",
    "content": "// Package geminiCLI provides request translation functionality for Gemini to OpenAI API.\n// It handles parsing and transforming Gemini API requests into OpenAI Chat Completions API format,\n// extracting model information, generation config, message contents, and tool declarations.\n// The package performs JSON data transformation to ensure compatibility\n// between Gemini API format and OpenAI API's expected format.\npackage geminiCLI\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertGeminiCLIRequestToOpenAI parses and transforms a Gemini API request into OpenAI Chat Completions API format.\n// It extracts the model name, generation config, message contents, and tool declarations\n// from the raw JSON request and returns them in the format expected by the OpenAI API.\nfunc ConvertGeminiCLIRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\trawJSON = []byte(gjson.GetBytes(rawJSON, \"request\").Raw)\n\trawJSON, _ = sjson.SetBytes(rawJSON, \"model\", modelName)\n\tif gjson.GetBytes(rawJSON, \"systemInstruction\").Exists() {\n\t\trawJSON, _ = sjson.SetRawBytes(rawJSON, \"system_instruction\", []byte(gjson.GetBytes(rawJSON, \"systemInstruction\").Raw))\n\t\trawJSON, _ = sjson.DeleteBytes(rawJSON, \"systemInstruction\")\n\t}\n\n\treturn ConvertGeminiRequestToOpenAI(modelName, rawJSON, stream)\n}\n"
  },
  {
    "path": "internal/translator/openai/gemini-cli/openai_gemini_response.go",
    "content": "// Package geminiCLI provides response translation functionality for OpenAI to Gemini API.\n// This package handles the conversion of OpenAI Chat Completions API responses into Gemini API-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by Gemini API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, and usage metadata appropriately.\npackage geminiCLI\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertOpenAIResponseToGeminiCLI converts OpenAI Chat Completions streaming response format to Gemini API format.\n// This function processes OpenAI streaming chunks and transforms them into Gemini-compatible JSON responses.\n// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - []string: A slice of strings, each containing a Gemini-compatible JSON response.\nfunc ConvertOpenAIResponseToGeminiCLI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\toutputs := ConvertOpenAIResponseToGemini(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\tnewOutputs := make([]string, 0)\n\tfor i := 0; i < len(outputs); i++ {\n\t\tjson := `{\"response\": {}}`\n\t\toutput, _ := sjson.SetRaw(json, \"response\", outputs[i])\n\t\tnewOutputs = append(newOutputs, output)\n\t}\n\treturn newOutputs\n}\n\n// ConvertOpenAIResponseToGeminiCLINonStream converts a non-streaming OpenAI response to a non-streaming Gemini CLI response.\n//\n// Parameters:\n//   - ctx: The context for the request.\n//   - modelName: The name of the model.\n//   - rawJSON: The raw JSON response from the OpenAI API.\n//   - param: A pointer to a parameter object for the conversion.\n//\n// Returns:\n//   - string: A Gemini-compatible JSON response.\nfunc ConvertOpenAIResponseToGeminiCLINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tstrJSON := ConvertOpenAIResponseToGeminiNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\tjson := `{\"response\": {}}`\n\tstrJSON, _ = sjson.SetRaw(json, \"response\", strJSON)\n\treturn strJSON\n}\n\nfunc GeminiCLITokenCount(ctx context.Context, count int64) string {\n\treturn fmt.Sprintf(`{\"totalTokens\":%d,\"promptTokensDetails\":[{\"modality\":\"TEXT\",\"tokenCount\":%d}]}`, count, count)\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/chat-completions/init.go",
    "content": "package chat_completions\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenAI,\n\t\tOpenAI,\n\t\tConvertOpenAIRequestToOpenAI,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertOpenAIResponseToOpenAI,\n\t\t\tNonStream: ConvertOpenAIResponseToOpenAINonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/chat-completions/openai_openai_request.go",
    "content": "// Package openai provides request translation functionality for OpenAI to Gemini CLI API compatibility.\n// It converts OpenAI Chat Completions requests into Gemini CLI compatible JSON using gjson/sjson only.\npackage chat_completions\n\nimport (\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertOpenAIRequestToOpenAI converts an OpenAI Chat Completions request (raw JSON)\n// into a complete Gemini CLI request JSON. All JSON construction uses sjson and lookups use gjson.\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data from the OpenAI API\n//   - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)\n//\n// Returns:\n//   - []byte: The transformed request data in Gemini CLI API format\nfunc ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) []byte {\n\t// Update the \"model\" field in the JSON payload with the provided modelName\n\t// The sjson.SetBytes function returns a new byte slice with the updated JSON.\n\tupdatedJSON, err := sjson.SetBytes(inputRawJSON, \"model\", modelName)\n\tif err != nil {\n\t\t// If there's an error, return the original JSON or handle the error appropriately.\n\t\t// For now, we'll return the original, but in a real scenario, logging or a more robust error\n\t\t// handling mechanism would be needed.\n\t\treturn inputRawJSON\n\t}\n\treturn updatedJSON\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/chat-completions/openai_openai_response.go",
    "content": "// Package openai provides response translation functionality for Gemini CLI to OpenAI API compatibility.\n// This package handles the conversion of Gemini CLI API responses into OpenAI Chat Completions-compatible\n// JSON format, transforming streaming events and non-streaming responses into the format\n// expected by OpenAI API clients. It supports both streaming and non-streaming modes,\n// handling text content, tool calls, reasoning content, and usage metadata appropriately.\npackage chat_completions\n\nimport (\n\t\"bytes\"\n\t\"context\"\n)\n\n// ConvertOpenAIResponseToOpenAI translates a single chunk of a streaming response from the\n// Gemini CLI API format to the OpenAI Chat Completions streaming format.\n// It processes various Gemini CLI event types and transforms them into OpenAI-compatible JSON responses.\n// The function handles text content, tool calls, reasoning content, and usage metadata, outputting\n// responses that match the OpenAI API format. It supports incremental updates for streaming responses.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response (unused in current implementation)\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for maintaining state between calls\n//\n// Returns:\n//   - []string: A slice of strings, each containing an OpenAI-compatible JSON response\nfunc ConvertOpenAIResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\treturn []string{string(rawJSON)}\n}\n\n// ConvertOpenAIResponseToOpenAINonStream converts a non-streaming Gemini CLI response to a non-streaming OpenAI response.\n// This function processes the complete Gemini CLI response and transforms it into a single OpenAI-compatible\n// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all\n// the information into a single response that matches the OpenAI API format.\n//\n// Parameters:\n//   - ctx: The context for the request, used for cancellation and timeout handling\n//   - modelName: The name of the model being used for the response\n//   - rawJSON: The raw JSON response from the Gemini CLI API\n//   - param: A pointer to a parameter object for the conversion\n//\n// Returns:\n//   - string: An OpenAI-compatible JSON response containing all message content and metadata\nfunc ConvertOpenAIResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\treturn string(rawJSON)\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/responses/init.go",
    "content": "package responses\n\nimport (\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator\"\n)\n\nfunc init() {\n\ttranslator.Register(\n\t\tOpenaiResponse,\n\t\tOpenAI,\n\t\tConvertOpenAIResponsesRequestToOpenAIChatCompletions,\n\t\tinterfaces.TranslateResponse{\n\t\t\tStream:    ConvertOpenAIChatCompletionsResponseToOpenAIResponses,\n\t\t\tNonStream: ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream,\n\t\t},\n\t)\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/responses/openai_openai-responses_request.go",
    "content": "package responses\n\nimport (\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertOpenAIResponsesRequestToOpenAIChatCompletions converts OpenAI responses format to OpenAI chat completions format.\n// It transforms the OpenAI responses API format (with instructions and input array) into the standard\n// OpenAI chat completions format (with messages array and system content).\n//\n// The conversion handles:\n// 1. Model name and streaming configuration\n// 2. Instructions to system message conversion\n// 3. Input array to messages array transformation\n// 4. Tool definitions and tool choice conversion\n// 5. Function calls and function results handling\n// 6. Generation parameters mapping (max_tokens, reasoning, etc.)\n//\n// Parameters:\n//   - modelName: The name of the model to use for the request\n//   - rawJSON: The raw JSON request data in OpenAI responses format\n//   - stream: A boolean indicating if the request is for a streaming response\n//\n// Returns:\n//   - []byte: The transformed request data in OpenAI chat completions format\nfunc ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte {\n\trawJSON := inputRawJSON\n\t// Base OpenAI chat completions template with default values\n\tout := `{\"model\":\"\",\"messages\":[],\"stream\":false}`\n\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Set model name\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// Set stream configuration\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// Map generation parameters from responses format to chat completions format\n\tif maxTokens := root.Get(\"max_output_tokens\"); maxTokens.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t}\n\n\tif parallelToolCalls := root.Get(\"parallel_tool_calls\"); parallelToolCalls.Exists() {\n\t\tout, _ = sjson.Set(out, \"parallel_tool_calls\", parallelToolCalls.Bool())\n\t}\n\n\t// Convert instructions to system message\n\tif instructions := root.Get(\"instructions\"); instructions.Exists() {\n\t\tsystemMessage := `{\"role\":\"system\",\"content\":\"\"}`\n\t\tsystemMessage, _ = sjson.Set(systemMessage, \"content\", instructions.String())\n\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", systemMessage)\n\t}\n\n\t// Convert input array to messages\n\tif input := root.Get(\"input\"); input.Exists() && input.IsArray() {\n\t\tinput.ForEach(func(_, item gjson.Result) bool {\n\t\t\titemType := item.Get(\"type\").String()\n\t\t\tif itemType == \"\" && item.Get(\"role\").String() != \"\" {\n\t\t\t\titemType = \"message\"\n\t\t\t}\n\n\t\t\tswitch itemType {\n\t\t\tcase \"message\", \"\":\n\t\t\t\t// Handle regular message conversion\n\t\t\t\trole := item.Get(\"role\").String()\n\t\t\t\tif role == \"developer\" {\n\t\t\t\t\trole = \"user\"\n\t\t\t\t}\n\t\t\t\tmessage := `{\"role\":\"\",\"content\":[]}`\n\t\t\t\tmessage, _ = sjson.Set(message, \"role\", role)\n\n\t\t\t\tif content := item.Get(\"content\"); content.Exists() && content.IsArray() {\n\t\t\t\t\tvar messageContent string\n\t\t\t\t\tvar toolCalls []interface{}\n\n\t\t\t\t\tcontent.ForEach(func(_, contentItem gjson.Result) bool {\n\t\t\t\t\t\tcontentType := contentItem.Get(\"type\").String()\n\t\t\t\t\t\tif contentType == \"\" {\n\t\t\t\t\t\t\tcontentType = \"input_text\"\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tswitch contentType {\n\t\t\t\t\t\tcase \"input_text\", \"output_text\":\n\t\t\t\t\t\t\ttext := contentItem.Get(\"text\").String()\n\t\t\t\t\t\t\tcontentPart := `{\"type\":\"text\",\"text\":\"\"}`\n\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"text\", text)\n\t\t\t\t\t\t\tmessage, _ = sjson.SetRaw(message, \"content.-1\", contentPart)\n\t\t\t\t\t\tcase \"input_image\":\n\t\t\t\t\t\t\timageURL := contentItem.Get(\"image_url\").String()\n\t\t\t\t\t\t\tcontentPart := `{\"type\":\"image_url\",\"image_url\":{\"url\":\"\"}}`\n\t\t\t\t\t\t\tcontentPart, _ = sjson.Set(contentPart, \"image_url.url\", imageURL)\n\t\t\t\t\t\t\tmessage, _ = sjson.SetRaw(message, \"content.-1\", contentPart)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\n\t\t\t\t\tif messageContent != \"\" {\n\t\t\t\t\t\tmessage, _ = sjson.Set(message, \"content\", messageContent)\n\t\t\t\t\t}\n\n\t\t\t\t\tif len(toolCalls) > 0 {\n\t\t\t\t\t\tmessage, _ = sjson.Set(message, \"tool_calls\", toolCalls)\n\t\t\t\t\t}\n\t\t\t\t} else if content.Type == gjson.String {\n\t\t\t\t\tmessage, _ = sjson.Set(message, \"content\", content.String())\n\t\t\t\t}\n\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", message)\n\n\t\t\tcase \"function_call\":\n\t\t\t\t// Handle function call conversion to assistant message with tool_calls\n\t\t\t\tassistantMessage := `{\"role\":\"assistant\",\"tool_calls\":[]}`\n\n\t\t\t\ttoolCall := `{\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\n\t\t\t\tif callId := item.Get(\"call_id\"); callId.Exists() {\n\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"id\", callId.String())\n\t\t\t\t}\n\n\t\t\t\tif name := item.Get(\"name\"); name.Exists() {\n\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"function.name\", name.String())\n\t\t\t\t}\n\n\t\t\t\tif arguments := item.Get(\"arguments\"); arguments.Exists() {\n\t\t\t\t\ttoolCall, _ = sjson.Set(toolCall, \"function.arguments\", arguments.String())\n\t\t\t\t}\n\n\t\t\t\tassistantMessage, _ = sjson.SetRaw(assistantMessage, \"tool_calls.0\", toolCall)\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", assistantMessage)\n\n\t\t\tcase \"function_call_output\":\n\t\t\t\t// Handle function call output conversion to tool message\n\t\t\t\ttoolMessage := `{\"role\":\"tool\",\"tool_call_id\":\"\",\"content\":\"\"}`\n\n\t\t\t\tif callId := item.Get(\"call_id\"); callId.Exists() {\n\t\t\t\t\ttoolMessage, _ = sjson.Set(toolMessage, \"tool_call_id\", callId.String())\n\t\t\t\t}\n\n\t\t\t\tif output := item.Get(\"output\"); output.Exists() {\n\t\t\t\t\ttoolMessage, _ = sjson.Set(toolMessage, \"content\", output.String())\n\t\t\t\t}\n\n\t\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", toolMessage)\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t} else if input.Type == gjson.String {\n\t\tmsg := \"{}\"\n\t\tmsg, _ = sjson.Set(msg, \"role\", \"user\")\n\t\tmsg, _ = sjson.Set(msg, \"content\", input.String())\n\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t}\n\n\t// Convert tools from responses format to chat completions format\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\tvar chatCompletionsTools []interface{}\n\n\t\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\t\t// Built-in tools (e.g. {\"type\":\"web_search\"}) are already compatible with the Chat Completions schema.\n\t\t\t// Only function tools need structural conversion because Chat Completions nests details under \"function\".\n\t\t\ttoolType := tool.Get(\"type\").String()\n\t\t\tif toolType != \"\" && toolType != \"function\" && tool.IsObject() {\n\t\t\t\t// Almost all providers lack built-in tools, so we just ignore them.\n\t\t\t\t// chatCompletionsTools = append(chatCompletionsTools, tool.Value())\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\tchatTool := `{\"type\":\"function\",\"function\":{}}`\n\n\t\t\t// Convert tool structure from responses format to chat completions format\n\t\t\tfunction := `{\"name\":\"\",\"description\":\"\",\"parameters\":{}}`\n\n\t\t\tif name := tool.Get(\"name\"); name.Exists() {\n\t\t\t\tfunction, _ = sjson.Set(function, \"name\", name.String())\n\t\t\t}\n\n\t\t\tif description := tool.Get(\"description\"); description.Exists() {\n\t\t\t\tfunction, _ = sjson.Set(function, \"description\", description.String())\n\t\t\t}\n\n\t\t\tif parameters := tool.Get(\"parameters\"); parameters.Exists() {\n\t\t\t\tfunction, _ = sjson.SetRaw(function, \"parameters\", parameters.Raw)\n\t\t\t}\n\n\t\t\tchatTool, _ = sjson.SetRaw(chatTool, \"function\", function)\n\t\t\tchatCompletionsTools = append(chatCompletionsTools, gjson.Parse(chatTool).Value())\n\n\t\t\treturn true\n\t\t})\n\n\t\tif len(chatCompletionsTools) > 0 {\n\t\t\tout, _ = sjson.Set(out, \"tools\", chatCompletionsTools)\n\t\t}\n\t}\n\n\tif reasoningEffort := root.Get(\"reasoning.effort\"); reasoningEffort.Exists() {\n\t\teffort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))\n\t\tif effort != \"\" {\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", effort)\n\t\t}\n\t}\n\n\t// Convert tool_choice if present\n\tif toolChoice := root.Get(\"tool_choice\"); toolChoice.Exists() {\n\t\tout, _ = sjson.Set(out, \"tool_choice\", toolChoice.String())\n\t}\n\n\treturn []byte(out)\n}\n"
  },
  {
    "path": "internal/translator/openai/openai/responses/openai_openai-responses_response.go",
    "content": "package responses\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\ntype oaiToResponsesStateReasoning struct {\n\tReasoningID   string\n\tReasoningData string\n}\ntype oaiToResponsesState struct {\n\tSeq            int\n\tResponseID     string\n\tCreated        int64\n\tStarted        bool\n\tReasoningID    string\n\tReasoningIndex int\n\t// aggregation buffers for response.output\n\t// Per-output message text buffers by index\n\tMsgTextBuf   map[int]*strings.Builder\n\tReasoningBuf strings.Builder\n\tReasonings   []oaiToResponsesStateReasoning\n\tFuncArgsBuf  map[int]*strings.Builder // index -> args\n\tFuncNames    map[int]string           // index -> name\n\tFuncCallIDs  map[int]string           // index -> call_id\n\t// message item state per output index\n\tMsgItemAdded    map[int]bool // whether response.output_item.added emitted for message\n\tMsgContentAdded map[int]bool // whether response.content_part.added emitted for message\n\tMsgItemDone     map[int]bool // whether message done events were emitted\n\t// function item done state\n\tFuncArgsDone map[int]bool\n\tFuncItemDone map[int]bool\n\t// usage aggregation\n\tPromptTokens     int64\n\tCachedTokens     int64\n\tCompletionTokens int64\n\tTotalTokens      int64\n\tReasoningTokens  int64\n\tUsageSeen        bool\n}\n\n// responseIDCounter provides a process-wide unique counter for synthesized response identifiers.\nvar responseIDCounter uint64\n\nfunc emitRespEvent(event string, payload string) string {\n\treturn fmt.Sprintf(\"event: %s\\ndata: %s\", event, payload)\n}\n\n// ConvertOpenAIChatCompletionsResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks\n// to OpenAI Responses SSE events (response.*).\nfunc ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &oaiToResponsesState{\n\t\t\tFuncArgsBuf:     make(map[int]*strings.Builder),\n\t\t\tFuncNames:       make(map[int]string),\n\t\t\tFuncCallIDs:     make(map[int]string),\n\t\t\tMsgTextBuf:      make(map[int]*strings.Builder),\n\t\t\tMsgItemAdded:    make(map[int]bool),\n\t\t\tMsgContentAdded: make(map[int]bool),\n\t\t\tMsgItemDone:     make(map[int]bool),\n\t\t\tFuncArgsDone:    make(map[int]bool),\n\t\t\tFuncItemDone:    make(map[int]bool),\n\t\t\tReasonings:      make([]oaiToResponsesStateReasoning, 0),\n\t\t}\n\t}\n\tst := (*param).(*oaiToResponsesState)\n\n\tif bytes.HasPrefix(rawJSON, []byte(\"data:\")) {\n\t\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\t}\n\n\trawJSON = bytes.TrimSpace(rawJSON)\n\tif len(rawJSON) == 0 {\n\t\treturn []string{}\n\t}\n\tif bytes.Equal(rawJSON, []byte(\"[DONE]\")) {\n\t\treturn []string{}\n\t}\n\n\troot := gjson.ParseBytes(rawJSON)\n\tobj := root.Get(\"object\")\n\tif obj.Exists() && obj.String() != \"\" && obj.String() != \"chat.completion.chunk\" {\n\t\treturn []string{}\n\t}\n\tif !root.Get(\"choices\").Exists() || !root.Get(\"choices\").IsArray() {\n\t\treturn []string{}\n\t}\n\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tif v := usage.Get(\"prompt_tokens\"); v.Exists() {\n\t\t\tst.PromptTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t}\n\t\tif v := usage.Get(\"prompt_tokens_details.cached_tokens\"); v.Exists() {\n\t\t\tst.CachedTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t}\n\t\tif v := usage.Get(\"completion_tokens\"); v.Exists() {\n\t\t\tst.CompletionTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t} else if v := usage.Get(\"output_tokens\"); v.Exists() {\n\t\t\tst.CompletionTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t}\n\t\tif v := usage.Get(\"output_tokens_details.reasoning_tokens\"); v.Exists() {\n\t\t\tst.ReasoningTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t} else if v := usage.Get(\"completion_tokens_details.reasoning_tokens\"); v.Exists() {\n\t\t\tst.ReasoningTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t}\n\t\tif v := usage.Get(\"total_tokens\"); v.Exists() {\n\t\t\tst.TotalTokens = v.Int()\n\t\t\tst.UsageSeen = true\n\t\t}\n\t}\n\n\tnextSeq := func() int { st.Seq++; return st.Seq }\n\tvar out []string\n\n\tif !st.Started {\n\t\tst.ResponseID = root.Get(\"id\").String()\n\t\tst.Created = root.Get(\"created\").Int()\n\t\t// reset aggregation state for a new streaming response\n\t\tst.MsgTextBuf = make(map[int]*strings.Builder)\n\t\tst.ReasoningBuf.Reset()\n\t\tst.ReasoningID = \"\"\n\t\tst.ReasoningIndex = 0\n\t\tst.FuncArgsBuf = make(map[int]*strings.Builder)\n\t\tst.FuncNames = make(map[int]string)\n\t\tst.FuncCallIDs = make(map[int]string)\n\t\tst.MsgItemAdded = make(map[int]bool)\n\t\tst.MsgContentAdded = make(map[int]bool)\n\t\tst.MsgItemDone = make(map[int]bool)\n\t\tst.FuncArgsDone = make(map[int]bool)\n\t\tst.FuncItemDone = make(map[int]bool)\n\t\tst.PromptTokens = 0\n\t\tst.CachedTokens = 0\n\t\tst.CompletionTokens = 0\n\t\tst.TotalTokens = 0\n\t\tst.ReasoningTokens = 0\n\t\tst.UsageSeen = false\n\t\t// response.created\n\t\tcreated := `{\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"output\":[]}}`\n\t\tcreated, _ = sjson.Set(created, \"sequence_number\", nextSeq())\n\t\tcreated, _ = sjson.Set(created, \"response.id\", st.ResponseID)\n\t\tcreated, _ = sjson.Set(created, \"response.created_at\", st.Created)\n\t\tout = append(out, emitRespEvent(\"response.created\", created))\n\n\t\tinprog := `{\"type\":\"response.in_progress\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\"}}`\n\t\tinprog, _ = sjson.Set(inprog, \"sequence_number\", nextSeq())\n\t\tinprog, _ = sjson.Set(inprog, \"response.id\", st.ResponseID)\n\t\tinprog, _ = sjson.Set(inprog, \"response.created_at\", st.Created)\n\t\tout = append(out, emitRespEvent(\"response.in_progress\", inprog))\n\t\tst.Started = true\n\t}\n\n\tstopReasoning := func(text string) {\n\t\t// Emit reasoning done events\n\t\ttextDone := `{\"type\":\"response.reasoning_summary_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"text\":\"\"}`\n\t\ttextDone, _ = sjson.Set(textDone, \"sequence_number\", nextSeq())\n\t\ttextDone, _ = sjson.Set(textDone, \"item_id\", st.ReasoningID)\n\t\ttextDone, _ = sjson.Set(textDone, \"output_index\", st.ReasoningIndex)\n\t\ttextDone, _ = sjson.Set(textDone, \"text\", text)\n\t\tout = append(out, emitRespEvent(\"response.reasoning_summary_text.done\", textDone))\n\t\tpartDone := `{\"type\":\"response.reasoning_summary_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.ReasoningID)\n\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", st.ReasoningIndex)\n\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", text)\n\t\tout = append(out, emitRespEvent(\"response.reasoning_summary_part.done\", partDone))\n\t\toutputItemDone := `{\"type\":\"response.output_item.done\",\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"encrypted_content\":\"\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]},\"output_index\":0,\"sequence_number\":0}`\n\t\toutputItemDone, _ = sjson.Set(outputItemDone, \"sequence_number\", nextSeq())\n\t\toutputItemDone, _ = sjson.Set(outputItemDone, \"item.id\", st.ReasoningID)\n\t\toutputItemDone, _ = sjson.Set(outputItemDone, \"output_index\", st.ReasoningIndex)\n\t\toutputItemDone, _ = sjson.Set(outputItemDone, \"item.summary.text\", text)\n\t\tout = append(out, emitRespEvent(\"response.output_item.done\", outputItemDone))\n\n\t\tst.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text})\n\t\tst.ReasoningID = \"\"\n\t}\n\n\t// choices[].delta content / tool_calls / reasoning_content\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() {\n\t\tchoices.ForEach(func(_, choice gjson.Result) bool {\n\t\t\tidx := int(choice.Get(\"index\").Int())\n\t\t\tdelta := choice.Get(\"delta\")\n\t\t\tif delta.Exists() {\n\t\t\t\tif c := delta.Get(\"content\"); c.Exists() && c.String() != \"\" {\n\t\t\t\t\t// Ensure the message item and its first content part are announced before any text deltas\n\t\t\t\t\tif st.ReasoningID != \"\" {\n\t\t\t\t\t\tstopReasoning(st.ReasoningBuf.String())\n\t\t\t\t\t\tst.ReasoningBuf.Reset()\n\t\t\t\t\t}\n\t\t\t\t\tif !st.MsgItemAdded[idx] {\n\t\t\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"output_index\", idx)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"item.id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.added\", item))\n\t\t\t\t\t\tst.MsgItemAdded[idx] = true\n\t\t\t\t\t}\n\t\t\t\t\tif !st.MsgContentAdded[idx] {\n\t\t\t\t\t\tpart := `{\"type\":\"response.content_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"output_index\", idx)\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"content_index\", 0)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.content_part.added\", part))\n\t\t\t\t\t\tst.MsgContentAdded[idx] = true\n\t\t\t\t\t}\n\n\t\t\t\t\tmsg := `{\"type\":\"response.output_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"delta\":\"\",\"logprobs\":[]}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", idx)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"content_index\", 0)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", c.String())\n\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_text.delta\", msg))\n\t\t\t\t\t// aggregate for response.output\n\t\t\t\t\tif st.MsgTextBuf[idx] == nil {\n\t\t\t\t\t\tst.MsgTextBuf[idx] = &strings.Builder{}\n\t\t\t\t\t}\n\t\t\t\t\tst.MsgTextBuf[idx].WriteString(c.String())\n\t\t\t\t}\n\n\t\t\t\t// reasoning_content (OpenAI reasoning incremental text)\n\t\t\t\tif rc := delta.Get(\"reasoning_content\"); rc.Exists() && rc.String() != \"\" {\n\t\t\t\t\t// On first appearance, add reasoning item and part\n\t\t\t\t\tif st.ReasoningID == \"\" {\n\t\t\t\t\t\tst.ReasoningID = fmt.Sprintf(\"rs_%s_%d\", st.ResponseID, idx)\n\t\t\t\t\t\tst.ReasoningIndex = idx\n\t\t\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"summary\":[]}}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"output_index\", idx)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"item.id\", st.ReasoningID)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.added\", item))\n\t\t\t\t\t\tpart := `{\"type\":\"response.reasoning_summary_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"item_id\", st.ReasoningID)\n\t\t\t\t\t\tpart, _ = sjson.Set(part, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.reasoning_summary_part.added\", part))\n\t\t\t\t\t}\n\t\t\t\t\t// Append incremental text to reasoning buffer\n\t\t\t\t\tst.ReasoningBuf.WriteString(rc.String())\n\t\t\t\t\tmsg := `{\"type\":\"response.reasoning_summary_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"delta\":\"\"}`\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.ReasoningID)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", st.ReasoningIndex)\n\t\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", rc.String())\n\t\t\t\t\tout = append(out, emitRespEvent(\"response.reasoning_summary_text.delta\", msg))\n\t\t\t\t}\n\n\t\t\t\t// tool calls\n\t\t\t\tif tcs := delta.Get(\"tool_calls\"); tcs.Exists() && tcs.IsArray() {\n\t\t\t\t\tif st.ReasoningID != \"\" {\n\t\t\t\t\t\tstopReasoning(st.ReasoningBuf.String())\n\t\t\t\t\t\tst.ReasoningBuf.Reset()\n\t\t\t\t\t}\n\t\t\t\t\t// Before emitting any function events, if a message is open for this index,\n\t\t\t\t\t// close its text/content to match Codex expected ordering.\n\t\t\t\t\tif st.MsgItemAdded[idx] && !st.MsgItemDone[idx] {\n\t\t\t\t\t\tfullText := \"\"\n\t\t\t\t\t\tif b := st.MsgTextBuf[idx]; b != nil {\n\t\t\t\t\t\t\tfullText = b.String()\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdone := `{\"type\":\"response.output_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"text\":\"\",\"logprobs\":[]}`\n\t\t\t\t\t\tdone, _ = sjson.Set(done, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tdone, _ = sjson.Set(done, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\t\tdone, _ = sjson.Set(done, \"output_index\", idx)\n\t\t\t\t\t\tdone, _ = sjson.Set(done, \"content_index\", 0)\n\t\t\t\t\t\tdone, _ = sjson.Set(done, \"text\", fullText)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_text.done\", done))\n\n\t\t\t\t\t\tpartDone := `{\"type\":\"response.content_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", idx)\n\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"content_index\", 0)\n\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", fullText)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.content_part.done\", partDone))\n\n\t\t\t\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}}`\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", idx)\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, idx))\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.content.0.text\", fullText)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.done\", itemDone))\n\t\t\t\t\t\tst.MsgItemDone[idx] = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// Only emit item.added once per tool call and preserve call_id across chunks.\n\t\t\t\t\tnewCallID := tcs.Get(\"0.id\").String()\n\t\t\t\t\tnameChunk := tcs.Get(\"0.function.name\").String()\n\t\t\t\t\tif nameChunk != \"\" {\n\t\t\t\t\t\tst.FuncNames[idx] = nameChunk\n\t\t\t\t\t}\n\t\t\t\t\texistingCallID := st.FuncCallIDs[idx]\n\t\t\t\t\teffectiveCallID := existingCallID\n\t\t\t\t\tshouldEmitItem := false\n\t\t\t\t\tif existingCallID == \"\" && newCallID != \"\" {\n\t\t\t\t\t\t// First time seeing a valid call_id for this index\n\t\t\t\t\t\teffectiveCallID = newCallID\n\t\t\t\t\t\tst.FuncCallIDs[idx] = newCallID\n\t\t\t\t\t\tshouldEmitItem = true\n\t\t\t\t\t}\n\n\t\t\t\t\tif shouldEmitItem && effectiveCallID != \"\" {\n\t\t\t\t\t\to := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\t\t\to, _ = sjson.Set(o, \"sequence_number\", nextSeq())\n\t\t\t\t\t\to, _ = sjson.Set(o, \"output_index\", idx)\n\t\t\t\t\t\to, _ = sjson.Set(o, \"item.id\", fmt.Sprintf(\"fc_%s\", effectiveCallID))\n\t\t\t\t\t\to, _ = sjson.Set(o, \"item.call_id\", effectiveCallID)\n\t\t\t\t\t\tname := st.FuncNames[idx]\n\t\t\t\t\t\to, _ = sjson.Set(o, \"item.name\", name)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.added\", o))\n\t\t\t\t\t}\n\n\t\t\t\t\t// Ensure args buffer exists for this index\n\t\t\t\t\tif st.FuncArgsBuf[idx] == nil {\n\t\t\t\t\t\tst.FuncArgsBuf[idx] = &strings.Builder{}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Append arguments delta if available and we have a valid call_id to reference\n\t\t\t\t\tif args := tcs.Get(\"0.function.arguments\"); args.Exists() && args.String() != \"\" {\n\t\t\t\t\t\t// Prefer an already known call_id; fall back to newCallID if first time\n\t\t\t\t\t\trefCallID := st.FuncCallIDs[idx]\n\t\t\t\t\t\tif refCallID == \"\" {\n\t\t\t\t\t\t\trefCallID = newCallID\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif refCallID != \"\" {\n\t\t\t\t\t\t\tad := `{\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"delta\":\"\"}`\n\t\t\t\t\t\t\tad, _ = sjson.Set(ad, \"sequence_number\", nextSeq())\n\t\t\t\t\t\t\tad, _ = sjson.Set(ad, \"item_id\", fmt.Sprintf(\"fc_%s\", refCallID))\n\t\t\t\t\t\t\tad, _ = sjson.Set(ad, \"output_index\", idx)\n\t\t\t\t\t\t\tad, _ = sjson.Set(ad, \"delta\", args.String())\n\t\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.function_call_arguments.delta\", ad))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tst.FuncArgsBuf[idx].WriteString(args.String())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// finish_reason triggers finalization, including text done/content done/item done,\n\t\t\t// reasoning done/part.done, function args done/item done, and completed\n\t\t\tif fr := choice.Get(\"finish_reason\"); fr.Exists() && fr.String() != \"\" {\n\t\t\t\t// Emit message done events for all indices that started a message\n\t\t\t\tif len(st.MsgItemAdded) > 0 {\n\t\t\t\t\t// sort indices for deterministic order\n\t\t\t\t\tidxs := make([]int, 0, len(st.MsgItemAdded))\n\t\t\t\t\tfor i := range st.MsgItemAdded {\n\t\t\t\t\t\tidxs = append(idxs, i)\n\t\t\t\t\t}\n\t\t\t\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\t\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, i := range idxs {\n\t\t\t\t\t\tif st.MsgItemAdded[i] && !st.MsgItemDone[i] {\n\t\t\t\t\t\t\tfullText := \"\"\n\t\t\t\t\t\t\tif b := st.MsgTextBuf[i]; b != nil {\n\t\t\t\t\t\t\t\tfullText = b.String()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdone := `{\"type\":\"response.output_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"text\":\"\",\"logprobs\":[]}`\n\t\t\t\t\t\t\tdone, _ = sjson.Set(done, \"sequence_number\", nextSeq())\n\t\t\t\t\t\t\tdone, _ = sjson.Set(done, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, i))\n\t\t\t\t\t\t\tdone, _ = sjson.Set(done, \"output_index\", i)\n\t\t\t\t\t\t\tdone, _ = sjson.Set(done, \"content_index\", 0)\n\t\t\t\t\t\t\tdone, _ = sjson.Set(done, \"text\", fullText)\n\t\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_text.done\", done))\n\n\t\t\t\t\t\t\tpartDone := `{\"type\":\"response.content_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"item_id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, i))\n\t\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"output_index\", i)\n\t\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"content_index\", 0)\n\t\t\t\t\t\t\tpartDone, _ = sjson.Set(partDone, \"part.text\", fullText)\n\t\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.content_part.done\", partDone))\n\n\t\t\t\t\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}}`\n\t\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", i)\n\t\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, i))\n\t\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.content.0.text\", fullText)\n\t\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.done\", itemDone))\n\t\t\t\t\t\t\tst.MsgItemDone[i] = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif st.ReasoningID != \"\" {\n\t\t\t\t\tstopReasoning(st.ReasoningBuf.String())\n\t\t\t\t\tst.ReasoningBuf.Reset()\n\t\t\t\t}\n\n\t\t\t\t// Emit function call done events for any active function calls\n\t\t\t\tif len(st.FuncCallIDs) > 0 {\n\t\t\t\t\tidxs := make([]int, 0, len(st.FuncCallIDs))\n\t\t\t\t\tfor i := range st.FuncCallIDs {\n\t\t\t\t\t\tidxs = append(idxs, i)\n\t\t\t\t\t}\n\t\t\t\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\t\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, i := range idxs {\n\t\t\t\t\t\tcallID := st.FuncCallIDs[i]\n\t\t\t\t\t\tif callID == \"\" || st.FuncItemDone[i] {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\targs := \"{}\"\n\t\t\t\t\t\tif b := st.FuncArgsBuf[i]; b != nil && b.Len() > 0 {\n\t\t\t\t\t\t\targs = b.String()\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfcDone := `{\"type\":\"response.function_call_arguments.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"arguments\":\"\"}`\n\t\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"item_id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"output_index\", i)\n\t\t\t\t\t\tfcDone, _ = sjson.Set(fcDone, \"arguments\", args)\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.function_call_arguments.done\", fcDone))\n\n\t\t\t\t\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", i)\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.arguments\", args)\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.call_id\", callID)\n\t\t\t\t\t\titemDone, _ = sjson.Set(itemDone, \"item.name\", st.FuncNames[i])\n\t\t\t\t\t\tout = append(out, emitRespEvent(\"response.output_item.done\", itemDone))\n\t\t\t\t\t\tst.FuncItemDone[i] = true\n\t\t\t\t\t\tst.FuncArgsDone[i] = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcompleted := `{\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}`\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"sequence_number\", nextSeq())\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.id\", st.ResponseID)\n\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.created_at\", st.Created)\n\t\t\t\t// Inject original request fields into response as per docs/response.completed.json\n\t\t\t\tif requestRawJSON != nil {\n\t\t\t\t\treq := gjson.ParseBytes(requestRawJSON)\n\t\t\t\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.instructions\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_output_tokens\", v.Int())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_tool_calls\", v.Int())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.model\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.parallel_tool_calls\", v.Bool())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.previous_response_id\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.prompt_cache_key\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.reasoning\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.safety_identifier\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.service_tier\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.store\", v.Bool())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.temperature\", v.Float())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.text\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tool_choice\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.tools\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_logprobs\", v.Int())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_p\", v.Float())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.truncation\", v.String())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.user\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.metadata\", v.Value())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Build response.output using aggregated buffers\n\t\t\t\toutputsWrapper := `{\"arr\":[]}`\n\t\t\t\tif len(st.Reasonings) > 0 {\n\t\t\t\t\tfor _, r := range st.Reasonings {\n\t\t\t\t\t\titem := `{\"id\":\"\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"\"}]}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"id\", r.ReasoningID)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"summary.0.text\", r.ReasoningData)\n\t\t\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Append message items in ascending index order\n\t\t\t\tif len(st.MsgItemAdded) > 0 {\n\t\t\t\t\tmidxs := make([]int, 0, len(st.MsgItemAdded))\n\t\t\t\t\tfor i := range st.MsgItemAdded {\n\t\t\t\t\t\tmidxs = append(midxs, i)\n\t\t\t\t\t}\n\t\t\t\t\tfor i := 0; i < len(midxs); i++ {\n\t\t\t\t\t\tfor j := i + 1; j < len(midxs); j++ {\n\t\t\t\t\t\t\tif midxs[j] < midxs[i] {\n\t\t\t\t\t\t\t\tmidxs[i], midxs[j] = midxs[j], midxs[i]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, i := range midxs {\n\t\t\t\t\t\ttxt := \"\"\n\t\t\t\t\t\tif b := st.MsgTextBuf[i]; b != nil {\n\t\t\t\t\t\t\ttxt = b.String()\n\t\t\t\t\t\t}\n\t\t\t\t\t\titem := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, i))\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"content.0.text\", txt)\n\t\t\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(st.FuncArgsBuf) > 0 {\n\t\t\t\t\tidxs := make([]int, 0, len(st.FuncArgsBuf))\n\t\t\t\t\tfor i := range st.FuncArgsBuf {\n\t\t\t\t\t\tidxs = append(idxs, i)\n\t\t\t\t\t}\n\t\t\t\t\t// small-N sort without extra imports\n\t\t\t\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\t\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, i := range idxs {\n\t\t\t\t\t\targs := \"\"\n\t\t\t\t\t\tif b := st.FuncArgsBuf[i]; b != nil {\n\t\t\t\t\t\t\targs = b.String()\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcallID := st.FuncCallIDs[i]\n\t\t\t\t\t\tname := st.FuncNames[i]\n\t\t\t\t\t\titem := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"arguments\", args)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"call_id\", callID)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"name\", name)\n\t\t\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif gjson.Get(outputsWrapper, \"arr.#\").Int() > 0 {\n\t\t\t\t\tcompleted, _ = sjson.SetRaw(completed, \"response.output\", gjson.Get(outputsWrapper, \"arr\").Raw)\n\t\t\t\t}\n\t\t\t\tif st.UsageSeen {\n\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens\", st.PromptTokens)\n\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens_details.cached_tokens\", st.CachedTokens)\n\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens\", st.CompletionTokens)\n\t\t\t\t\tif st.ReasoningTokens > 0 {\n\t\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens_details.reasoning_tokens\", st.ReasoningTokens)\n\t\t\t\t\t}\n\t\t\t\t\ttotal := st.TotalTokens\n\t\t\t\t\tif total == 0 {\n\t\t\t\t\t\ttotal = st.PromptTokens + st.CompletionTokens\n\t\t\t\t\t}\n\t\t\t\t\tcompleted, _ = sjson.Set(completed, \"response.usage.total_tokens\", total)\n\t\t\t\t}\n\t\t\t\tout = append(out, emitRespEvent(\"response.completed\", completed))\n\t\t\t}\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\treturn out\n}\n\n// ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON\n// from a non-streaming OpenAI Chat Completions response.\nfunc ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Basic response scaffold\n\tresp := `{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"incomplete_details\":null}`\n\n\t// id: use provider id if present, otherwise synthesize\n\tid := root.Get(\"id\").String()\n\tif id == \"\" {\n\t\tid = fmt.Sprintf(\"resp_%x_%d\", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))\n\t}\n\tresp, _ = sjson.Set(resp, \"id\", id)\n\n\t// created_at: map from chat.completion created\n\tcreated := root.Get(\"created\").Int()\n\tif created == 0 {\n\t\tcreated = time.Now().Unix()\n\t}\n\tresp, _ = sjson.Set(resp, \"created_at\", created)\n\n\t// Echo request fields when available (aligns with streaming path behavior)\n\tif len(requestRawJSON) > 0 {\n\t\treq := gjson.ParseBytes(requestRawJSON)\n\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"instructions\", v.String())\n\t\t}\n\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"max_output_tokens\", v.Int())\n\t\t} else {\n\t\t\t// Also support max_tokens from chat completion style\n\t\t\tif v = req.Get(\"max_tokens\"); v.Exists() {\n\t\t\t\tresp, _ = sjson.Set(resp, \"max_output_tokens\", v.Int())\n\t\t\t}\n\t\t}\n\t\tif v := req.Get(\"max_tool_calls\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"max_tool_calls\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t\t} else if v = root.Get(\"model\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t\t}\n\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"parallel_tool_calls\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"previous_response_id\", v.String())\n\t\t}\n\t\tif v := req.Get(\"prompt_cache_key\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"prompt_cache_key\", v.String())\n\t\t}\n\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"reasoning\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"safety_identifier\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"safety_identifier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"service_tier\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"service_tier\", v.String())\n\t\t}\n\t\tif v := req.Get(\"store\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"store\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"temperature\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"text\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"text\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"tool_choice\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"tools\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"top_logprobs\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"top_logprobs\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"top_p\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"truncation\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"truncation\", v.String())\n\t\t}\n\t\tif v := req.Get(\"user\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"user\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"metadata\", v.Value())\n\t\t}\n\t} else if v := root.Get(\"model\"); v.Exists() {\n\t\t// Fallback model from response\n\t\tresp, _ = sjson.Set(resp, \"model\", v.String())\n\t}\n\n\t// Build output list from choices[...]\n\toutputsWrapper := `{\"arr\":[]}`\n\t// Detect and capture reasoning content if present\n\trcText := gjson.GetBytes(rawJSON, \"choices.0.message.reasoning_content\").String()\n\tincludeReasoning := rcText != \"\"\n\tif !includeReasoning && len(requestRawJSON) > 0 {\n\t\tincludeReasoning = gjson.GetBytes(requestRawJSON, \"reasoning\").Exists()\n\t}\n\tif includeReasoning {\n\t\trid := id\n\t\tif strings.HasPrefix(rid, \"resp_\") {\n\t\t\trid = strings.TrimPrefix(rid, \"resp_\")\n\t\t}\n\t\t// Prefer summary_text from reasoning_content; encrypted_content is optional\n\t\treasoningItem := `{\"id\":\"\",\"type\":\"reasoning\",\"encrypted_content\":\"\",\"summary\":[]}`\n\t\treasoningItem, _ = sjson.Set(reasoningItem, \"id\", fmt.Sprintf(\"rs_%s\", rid))\n\t\tif rcText != \"\" {\n\t\t\treasoningItem, _ = sjson.Set(reasoningItem, \"summary.0.type\", \"summary_text\")\n\t\t\treasoningItem, _ = sjson.Set(reasoningItem, \"summary.0.text\", rcText)\n\t\t}\n\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", reasoningItem)\n\t}\n\n\tif choices := root.Get(\"choices\"); choices.Exists() && choices.IsArray() {\n\t\tchoices.ForEach(func(_, choice gjson.Result) bool {\n\t\t\tmsg := choice.Get(\"message\")\n\t\t\tif msg.Exists() {\n\t\t\t\t// Text message part\n\t\t\t\tif c := msg.Get(\"content\"); c.Exists() && c.String() != \"\" {\n\t\t\t\t\titem := `{\"id\":\"\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}],\"role\":\"assistant\"}`\n\t\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"msg_%s_%d\", id, int(choice.Get(\"index\").Int())))\n\t\t\t\t\titem, _ = sjson.Set(item, \"content.0.text\", c.String())\n\t\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\t}\n\n\t\t\t\t// Function/tool calls\n\t\t\t\tif tcs := msg.Get(\"tool_calls\"); tcs.Exists() && tcs.IsArray() {\n\t\t\t\t\ttcs.ForEach(func(_, tc gjson.Result) bool {\n\t\t\t\t\t\tcallID := tc.Get(\"id\").String()\n\t\t\t\t\t\tname := tc.Get(\"function.name\").String()\n\t\t\t\t\t\targs := tc.Get(\"function.arguments\").String()\n\t\t\t\t\t\titem := `{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}`\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"arguments\", args)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"call_id\", callID)\n\t\t\t\t\t\titem, _ = sjson.Set(item, \"name\", name)\n\t\t\t\t\t\toutputsWrapper, _ = sjson.SetRaw(outputsWrapper, \"arr.-1\", item)\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\tif gjson.Get(outputsWrapper, \"arr.#\").Int() > 0 {\n\t\tresp, _ = sjson.SetRaw(resp, \"output\", gjson.Get(outputsWrapper, \"arr\").Raw)\n\t}\n\n\t// usage mapping\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\t// Map common tokens\n\t\tif usage.Get(\"prompt_tokens\").Exists() || usage.Get(\"completion_tokens\").Exists() || usage.Get(\"total_tokens\").Exists() {\n\t\t\tresp, _ = sjson.Set(resp, \"usage.input_tokens\", usage.Get(\"prompt_tokens\").Int())\n\t\t\tif d := usage.Get(\"prompt_tokens_details.cached_tokens\"); d.Exists() {\n\t\t\t\tresp, _ = sjson.Set(resp, \"usage.input_tokens_details.cached_tokens\", d.Int())\n\t\t\t}\n\t\t\tresp, _ = sjson.Set(resp, \"usage.output_tokens\", usage.Get(\"completion_tokens\").Int())\n\t\t\t// Reasoning tokens not available in Chat Completions; set only if present under output_tokens_details\n\t\t\tif d := usage.Get(\"output_tokens_details.reasoning_tokens\"); d.Exists() {\n\t\t\t\tresp, _ = sjson.Set(resp, \"usage.output_tokens_details.reasoning_tokens\", d.Int())\n\t\t\t}\n\t\t\tresp, _ = sjson.Set(resp, \"usage.total_tokens\", usage.Get(\"total_tokens\").Int())\n\t\t} else {\n\t\t\t// Fallback to raw usage object if structure differs\n\t\t\tresp, _ = sjson.Set(resp, \"usage\", usage.Value())\n\t\t}\n\t}\n\n\treturn resp\n}\n"
  },
  {
    "path": "internal/translator/translator/translator.go",
    "content": "// Package translator provides request and response translation functionality\n// between different AI API formats. It acts as a wrapper around the SDK translator\n// registry, providing convenient functions for translating requests and responses\n// between OpenAI, Claude, Gemini, and other API formats.\npackage translator\n\nimport (\n\t\"context\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n)\n\n// registry holds the default translator registry instance.\nvar registry = sdktranslator.Default()\n\n// Register registers a new translator for converting between two API formats.\n//\n// Parameters:\n//   - from: The source API format identifier\n//   - to: The target API format identifier\n//   - request: The request translation function\n//   - response: The response translation function\nfunc Register(from, to string, request interfaces.TranslateRequestFunc, response interfaces.TranslateResponse) {\n\tregistry.Register(sdktranslator.FromString(from), sdktranslator.FromString(to), request, response)\n}\n\n// Request translates a request from one API format to another.\n//\n// Parameters:\n//   - from: The source API format identifier\n//   - to: The target API format identifier\n//   - modelName: The model name for the request\n//   - rawJSON: The raw JSON request data\n//   - stream: Whether this is a streaming request\n//\n// Returns:\n//   - []byte: The translated request JSON\nfunc Request(from, to, modelName string, rawJSON []byte, stream bool) []byte {\n\treturn registry.TranslateRequest(sdktranslator.FromString(from), sdktranslator.FromString(to), modelName, rawJSON, stream)\n}\n\n// NeedConvert checks if a response translation is needed between two API formats.\n//\n// Parameters:\n//   - from: The source API format identifier\n//   - to: The target API format identifier\n//\n// Returns:\n//   - bool: True if response translation is needed, false otherwise\nfunc NeedConvert(from, to string) bool {\n\treturn registry.HasResponseTransformer(sdktranslator.FromString(from), sdktranslator.FromString(to))\n}\n\n// Response translates a streaming response from one API format to another.\n//\n// Parameters:\n//   - from: The source API format identifier\n//   - to: The target API format identifier\n//   - ctx: The context for the translation\n//   - modelName: The model name for the response\n//   - originalRequestRawJSON: The original request JSON\n//   - requestRawJSON: The translated request JSON\n//   - rawJSON: The raw response JSON\n//   - param: Additional parameters for translation\n//\n// Returns:\n//   - []string: The translated response lines\nfunc Response(from, to string, ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\treturn registry.TranslateStream(ctx, sdktranslator.FromString(from), sdktranslator.FromString(to), modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\n// ResponseNonStream translates a non-streaming response from one API format to another.\n//\n// Parameters:\n//   - from: The source API format identifier\n//   - to: The target API format identifier\n//   - ctx: The context for the translation\n//   - modelName: The model name for the response\n//   - originalRequestRawJSON: The original request JSON\n//   - requestRawJSON: The translated request JSON\n//   - rawJSON: The raw response JSON\n//   - param: Additional parameters for translation\n//\n// Returns:\n//   - string: The translated response JSON\nfunc ResponseNonStream(from, to string, ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\treturn registry.TranslateNonStream(ctx, sdktranslator.FromString(from), sdktranslator.FromString(to), modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n"
  },
  {
    "path": "internal/tui/app.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// Tab identifiers\nconst (\n\ttabDashboard = iota\n\ttabConfig\n\ttabAuthFiles\n\ttabAPIKeys\n\ttabOAuth\n\ttabUsage\n\ttabLogs\n)\n\n// App is the root bubbletea model that contains all tab sub-models.\ntype App struct {\n\tactiveTab int\n\ttabs      []string\n\n\tstandalone  bool\n\tlogsEnabled bool\n\n\tauthenticated  bool\n\tauthInput      textinput.Model\n\tauthError      string\n\tauthConnecting bool\n\n\tdashboard dashboardModel\n\tconfig    configTabModel\n\tauth      authTabModel\n\tkeys      keysTabModel\n\toauth     oauthTabModel\n\tusage     usageTabModel\n\tlogs      logsTabModel\n\n\tclient *Client\n\n\twidth  int\n\theight int\n\tready  bool\n\n\t// Track which tabs have been initialized (fetched data)\n\tinitialized [7]bool\n}\n\ntype authConnectMsg struct {\n\tcfg map[string]any\n\terr error\n}\n\n// NewApp creates the root TUI application model.\nfunc NewApp(port int, secretKey string, hook *LogHook) App {\n\tstandalone := hook != nil\n\tauthRequired := !standalone\n\tti := textinput.New()\n\tti.CharLimit = 512\n\tti.EchoMode = textinput.EchoPassword\n\tti.EchoCharacter = '*'\n\tti.SetValue(strings.TrimSpace(secretKey))\n\tti.Focus()\n\n\tclient := NewClient(port, secretKey)\n\tapp := App{\n\t\tactiveTab:     tabDashboard,\n\t\tstandalone:    standalone,\n\t\tlogsEnabled:   true,\n\t\tauthenticated: !authRequired,\n\t\tauthInput:     ti,\n\t\tdashboard:     newDashboardModel(client),\n\t\tconfig:        newConfigTabModel(client),\n\t\tauth:          newAuthTabModel(client),\n\t\tkeys:          newKeysTabModel(client),\n\t\toauth:         newOAuthTabModel(client),\n\t\tusage:         newUsageTabModel(client),\n\t\tlogs:          newLogsTabModel(client, hook),\n\t\tclient:        client,\n\t\tinitialized: [7]bool{\n\t\t\ttabDashboard: true,\n\t\t\ttabLogs:      true,\n\t\t},\n\t}\n\n\tapp.refreshTabs()\n\tif authRequired {\n\t\tapp.initialized = [7]bool{}\n\t}\n\tapp.setAuthInputPrompt()\n\treturn app\n}\n\nfunc (a App) Init() tea.Cmd {\n\tif !a.authenticated {\n\t\treturn textinput.Blink\n\t}\n\tcmds := []tea.Cmd{a.dashboard.Init()}\n\tif a.logsEnabled {\n\t\tcmds = append(cmds, a.logs.Init())\n\t}\n\treturn tea.Batch(cmds...)\n}\n\nfunc (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\ta.width = msg.Width\n\t\ta.height = msg.Height\n\t\ta.ready = true\n\t\tif a.width > 0 {\n\t\t\ta.authInput.Width = a.width - 6\n\t\t}\n\t\tcontentH := a.height - 4 // tab bar + status bar\n\t\tif contentH < 1 {\n\t\t\tcontentH = 1\n\t\t}\n\t\tcontentW := a.width\n\t\ta.dashboard.SetSize(contentW, contentH)\n\t\ta.config.SetSize(contentW, contentH)\n\t\ta.auth.SetSize(contentW, contentH)\n\t\ta.keys.SetSize(contentW, contentH)\n\t\ta.oauth.SetSize(contentW, contentH)\n\t\ta.usage.SetSize(contentW, contentH)\n\t\ta.logs.SetSize(contentW, contentH)\n\t\treturn a, nil\n\n\tcase authConnectMsg:\n\t\ta.authConnecting = false\n\t\tif msg.err != nil {\n\t\t\ta.authError = fmt.Sprintf(T(\"auth_gate_connect_fail\"), msg.err.Error())\n\t\t\treturn a, nil\n\t\t}\n\t\ta.authError = \"\"\n\t\ta.authenticated = true\n\t\ta.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg)\n\t\ta.refreshTabs()\n\t\ta.initialized = [7]bool{}\n\t\ta.initialized[tabDashboard] = true\n\t\tcmds := []tea.Cmd{a.dashboard.Init()}\n\t\tif a.logsEnabled {\n\t\t\ta.initialized[tabLogs] = true\n\t\t\tcmds = append(cmds, a.logs.Init())\n\t\t}\n\t\treturn a, tea.Batch(cmds...)\n\n\tcase configUpdateMsg:\n\t\tvar cmdLogs tea.Cmd\n\t\tif !a.standalone && msg.err == nil && msg.path == \"logging-to-file\" {\n\t\t\tlogsEnabledConfig, okConfig := msg.value.(bool)\n\t\t\tif okConfig {\n\t\t\t\tlogsEnabledBefore := a.logsEnabled\n\t\t\t\ta.logsEnabled = logsEnabledConfig\n\t\t\t\tif logsEnabledBefore != a.logsEnabled {\n\t\t\t\t\ta.refreshTabs()\n\t\t\t\t}\n\t\t\t\tif !a.logsEnabled {\n\t\t\t\t\ta.initialized[tabLogs] = false\n\t\t\t\t}\n\t\t\t\tif !logsEnabledBefore && a.logsEnabled {\n\t\t\t\t\ta.initialized[tabLogs] = true\n\t\t\t\t\tcmdLogs = a.logs.Init()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tvar cmdConfig tea.Cmd\n\t\ta.config, cmdConfig = a.config.Update(msg)\n\t\tif cmdConfig != nil && cmdLogs != nil {\n\t\t\treturn a, tea.Batch(cmdConfig, cmdLogs)\n\t\t}\n\t\tif cmdConfig != nil {\n\t\t\treturn a, cmdConfig\n\t\t}\n\t\treturn a, cmdLogs\n\n\tcase tea.KeyMsg:\n\t\tif !a.authenticated {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"ctrl+c\", \"q\":\n\t\t\t\treturn a, tea.Quit\n\t\t\tcase \"L\":\n\t\t\t\tToggleLocale()\n\t\t\t\ta.refreshTabs()\n\t\t\t\ta.setAuthInputPrompt()\n\t\t\t\treturn a, nil\n\t\t\tcase \"enter\":\n\t\t\t\tif a.authConnecting {\n\t\t\t\t\treturn a, nil\n\t\t\t\t}\n\t\t\t\tpassword := strings.TrimSpace(a.authInput.Value())\n\t\t\t\tif password == \"\" {\n\t\t\t\t\ta.authError = T(\"auth_gate_password_required\")\n\t\t\t\t\treturn a, nil\n\t\t\t\t}\n\t\t\t\ta.authError = \"\"\n\t\t\t\ta.authConnecting = true\n\t\t\t\treturn a, a.connectWithPassword(password)\n\t\t\tdefault:\n\t\t\t\tvar cmd tea.Cmd\n\t\t\t\ta.authInput, cmd = a.authInput.Update(msg)\n\t\t\t\treturn a, cmd\n\t\t\t}\n\t\t}\n\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\":\n\t\t\treturn a, tea.Quit\n\t\tcase \"q\":\n\t\t\t// Only quit if not in logs tab (where 'q' might be useful)\n\t\t\tif !a.logsEnabled || a.activeTab != tabLogs {\n\t\t\t\treturn a, tea.Quit\n\t\t\t}\n\t\tcase \"L\":\n\t\t\tToggleLocale()\n\t\t\ta.refreshTabs()\n\t\t\treturn a.broadcastToAllTabs(localeChangedMsg{})\n\t\tcase \"tab\":\n\t\t\tif len(a.tabs) == 0 {\n\t\t\t\treturn a, nil\n\t\t\t}\n\t\t\tprevTab := a.activeTab\n\t\t\ta.activeTab = (a.activeTab + 1) % len(a.tabs)\n\t\t\treturn a, a.initTabIfNeeded(prevTab)\n\t\tcase \"shift+tab\":\n\t\t\tif len(a.tabs) == 0 {\n\t\t\t\treturn a, nil\n\t\t\t}\n\t\t\tprevTab := a.activeTab\n\t\t\ta.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)\n\t\t\treturn a, a.initTabIfNeeded(prevTab)\n\t\t}\n\t}\n\n\tif !a.authenticated {\n\t\tvar cmd tea.Cmd\n\t\ta.authInput, cmd = a.authInput.Update(msg)\n\t\treturn a, cmd\n\t}\n\n\t// Route msg to active tab\n\tvar cmd tea.Cmd\n\tswitch a.activeTab {\n\tcase tabDashboard:\n\t\ta.dashboard, cmd = a.dashboard.Update(msg)\n\tcase tabConfig:\n\t\ta.config, cmd = a.config.Update(msg)\n\tcase tabAuthFiles:\n\t\ta.auth, cmd = a.auth.Update(msg)\n\tcase tabAPIKeys:\n\t\ta.keys, cmd = a.keys.Update(msg)\n\tcase tabOAuth:\n\t\ta.oauth, cmd = a.oauth.Update(msg)\n\tcase tabUsage:\n\t\ta.usage, cmd = a.usage.Update(msg)\n\tcase tabLogs:\n\t\ta.logs, cmd = a.logs.Update(msg)\n\t}\n\n\t// Keep logs polling alive even when logs tab is not active.\n\tif a.logsEnabled && a.activeTab != tabLogs {\n\t\tswitch msg.(type) {\n\t\tcase logsPollMsg, logsTickMsg, logLineMsg:\n\t\t\tvar logCmd tea.Cmd\n\t\t\ta.logs, logCmd = a.logs.Update(msg)\n\t\t\tif logCmd != nil {\n\t\t\t\tcmd = logCmd\n\t\t\t}\n\t\t}\n\t}\n\n\treturn a, cmd\n}\n\n// localeChangedMsg is broadcast to all tabs when the user toggles locale.\ntype localeChangedMsg struct{}\n\nfunc (a *App) refreshTabs() {\n\tnames := TabNames()\n\tif a.logsEnabled {\n\t\ta.tabs = names\n\t} else {\n\t\tfiltered := make([]string, 0, len(names)-1)\n\t\tfor idx, name := range names {\n\t\t\tif idx == tabLogs {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfiltered = append(filtered, name)\n\t\t}\n\t\ta.tabs = filtered\n\t}\n\n\tif len(a.tabs) == 0 {\n\t\ta.activeTab = tabDashboard\n\t\treturn\n\t}\n\tif a.activeTab >= len(a.tabs) {\n\t\ta.activeTab = len(a.tabs) - 1\n\t}\n}\n\nfunc (a *App) initTabIfNeeded(_ int) tea.Cmd {\n\tif a.initialized[a.activeTab] {\n\t\treturn nil\n\t}\n\ta.initialized[a.activeTab] = true\n\tswitch a.activeTab {\n\tcase tabDashboard:\n\t\treturn a.dashboard.Init()\n\tcase tabConfig:\n\t\treturn a.config.Init()\n\tcase tabAuthFiles:\n\t\treturn a.auth.Init()\n\tcase tabAPIKeys:\n\t\treturn a.keys.Init()\n\tcase tabOAuth:\n\t\treturn a.oauth.Init()\n\tcase tabUsage:\n\t\treturn a.usage.Init()\n\tcase tabLogs:\n\t\tif !a.logsEnabled {\n\t\t\treturn nil\n\t\t}\n\t\treturn a.logs.Init()\n\t}\n\treturn nil\n}\n\nfunc (a App) View() string {\n\tif !a.authenticated {\n\t\treturn a.renderAuthView()\n\t}\n\n\tif !a.ready {\n\t\treturn T(\"initializing_tui\")\n\t}\n\n\tvar sb strings.Builder\n\n\t// Tab bar\n\tsb.WriteString(a.renderTabBar())\n\tsb.WriteString(\"\\n\")\n\n\t// Content\n\tswitch a.activeTab {\n\tcase tabDashboard:\n\t\tsb.WriteString(a.dashboard.View())\n\tcase tabConfig:\n\t\tsb.WriteString(a.config.View())\n\tcase tabAuthFiles:\n\t\tsb.WriteString(a.auth.View())\n\tcase tabAPIKeys:\n\t\tsb.WriteString(a.keys.View())\n\tcase tabOAuth:\n\t\tsb.WriteString(a.oauth.View())\n\tcase tabUsage:\n\t\tsb.WriteString(a.usage.View())\n\tcase tabLogs:\n\t\tif a.logsEnabled {\n\t\t\tsb.WriteString(a.logs.View())\n\t\t}\n\t}\n\n\t// Status bar\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(a.renderStatusBar())\n\n\treturn sb.String()\n}\n\nfunc (a App) renderAuthView() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"auth_gate_title\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"auth_gate_help\")))\n\tsb.WriteString(\"\\n\\n\")\n\tif a.authConnecting {\n\t\tsb.WriteString(warningStyle.Render(T(\"auth_gate_connecting\")))\n\t\tsb.WriteString(\"\\n\\n\")\n\t}\n\tif strings.TrimSpace(a.authError) != \"\" {\n\t\tsb.WriteString(errorStyle.Render(a.authError))\n\t\tsb.WriteString(\"\\n\\n\")\n\t}\n\tsb.WriteString(a.authInput.View())\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"auth_gate_enter\")))\n\treturn sb.String()\n}\n\nfunc (a App) renderTabBar() string {\n\tvar tabs []string\n\tfor i, name := range a.tabs {\n\t\tif i == a.activeTab {\n\t\t\ttabs = append(tabs, tabActiveStyle.Render(name))\n\t\t} else {\n\t\t\ttabs = append(tabs, tabInactiveStyle.Render(name))\n\t\t}\n\t}\n\ttabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)\n\treturn tabBarStyle.Width(a.width).Render(tabBar)\n}\n\nfunc (a App) renderStatusBar() string {\n\tleft := strings.TrimRight(T(\"status_left\"), \" \")\n\tright := strings.TrimRight(T(\"status_right\"), \" \")\n\n\twidth := a.width\n\tif width < 1 {\n\t\twidth = 1\n\t}\n\n\t// statusBarStyle has left/right padding(1), so content area is width-2.\n\tcontentWidth := width - 2\n\tif contentWidth < 0 {\n\t\tcontentWidth = 0\n\t}\n\n\tif lipgloss.Width(left) > contentWidth {\n\t\tleft = fitStringWidth(left, contentWidth)\n\t\tright = \"\"\n\t}\n\n\tremaining := contentWidth - lipgloss.Width(left)\n\tif remaining < 0 {\n\t\tremaining = 0\n\t}\n\tif lipgloss.Width(right) > remaining {\n\t\tright = fitStringWidth(right, remaining)\n\t}\n\n\tgap := contentWidth - lipgloss.Width(left) - lipgloss.Width(right)\n\tif gap < 0 {\n\t\tgap = 0\n\t}\n\treturn statusBarStyle.Width(width).Render(left + strings.Repeat(\" \", gap) + right)\n}\n\nfunc fitStringWidth(text string, maxWidth int) string {\n\tif maxWidth <= 0 {\n\t\treturn \"\"\n\t}\n\tif lipgloss.Width(text) <= maxWidth {\n\t\treturn text\n\t}\n\n\tout := \"\"\n\tfor _, r := range text {\n\t\tnext := out + string(r)\n\t\tif lipgloss.Width(next) > maxWidth {\n\t\t\tbreak\n\t\t}\n\t\tout = next\n\t}\n\treturn out\n}\n\nfunc isLogsEnabledFromConfig(cfg map[string]any) bool {\n\tif cfg == nil {\n\t\treturn true\n\t}\n\tvalue, ok := cfg[\"logging-to-file\"]\n\tif !ok {\n\t\treturn true\n\t}\n\tenabled, ok := value.(bool)\n\tif !ok {\n\t\treturn true\n\t}\n\treturn enabled\n}\n\nfunc (a *App) setAuthInputPrompt() {\n\tif a == nil {\n\t\treturn\n\t}\n\ta.authInput.Prompt = fmt.Sprintf(\"  %s: \", T(\"auth_gate_password\"))\n}\n\nfunc (a App) connectWithPassword(password string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\ta.client.SetSecretKey(password)\n\t\tcfg, errGetConfig := a.client.GetConfig()\n\t\treturn authConnectMsg{cfg: cfg, err: errGetConfig}\n\t}\n}\n\n// Run starts the TUI application.\n// output specifies where bubbletea renders. If nil, defaults to os.Stdout.\nfunc Run(port int, secretKey string, hook *LogHook, output io.Writer) error {\n\tif output == nil {\n\t\toutput = os.Stdout\n\t}\n\tapp := NewApp(port, secretKey, hook)\n\tp := tea.NewProgram(app, tea.WithAltScreen(), tea.WithOutput(output))\n\t_, err := p.Run()\n\treturn err\n}\n\nfunc (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tvar cmds []tea.Cmd\n\tvar cmd tea.Cmd\n\n\ta.dashboard, cmd = a.dashboard.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.config, cmd = a.config.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.auth, cmd = a.auth.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.keys, cmd = a.keys.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.oauth, cmd = a.oauth.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.usage, cmd = a.usage.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\ta.logs, cmd = a.logs.Update(msg)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\treturn a, tea.Batch(cmds...)\n}\n"
  },
  {
    "path": "internal/tui/auth_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// editableField represents an editable field on an auth file.\ntype editableField struct {\n\tlabel string\n\tkey   string // API field key: \"prefix\", \"proxy_url\", \"priority\"\n}\n\nvar authEditableFields = []editableField{\n\t{label: \"Prefix\", key: \"prefix\"},\n\t{label: \"Proxy URL\", key: \"proxy_url\"},\n\t{label: \"Priority\", key: \"priority\"},\n}\n\n// authTabModel displays auth credential files with interactive management.\ntype authTabModel struct {\n\tclient   *Client\n\tviewport viewport.Model\n\tfiles    []map[string]any\n\terr      error\n\twidth    int\n\theight   int\n\tready    bool\n\tcursor   int\n\texpanded int // -1 = none expanded, >=0 = expanded index\n\tconfirm  int // -1 = no confirmation, >=0 = confirm delete for index\n\tstatus   string\n\n\t// Editing state\n\tediting      bool            // true when editing a field\n\teditField    int             // index into authEditableFields\n\teditInput    textinput.Model // text input for editing\n\teditFileName string          // name of file being edited\n}\n\ntype authFilesMsg struct {\n\tfiles []map[string]any\n\terr   error\n}\n\ntype authActionMsg struct {\n\taction string // \"deleted\", \"toggled\", \"updated\"\n\terr    error\n}\n\nfunc newAuthTabModel(client *Client) authTabModel {\n\tti := textinput.New()\n\tti.CharLimit = 256\n\treturn authTabModel{\n\t\tclient:    client,\n\t\texpanded:  -1,\n\t\tconfirm:   -1,\n\t\teditInput: ti,\n\t}\n}\n\nfunc (m authTabModel) Init() tea.Cmd {\n\treturn m.fetchFiles\n}\n\nfunc (m authTabModel) fetchFiles() tea.Msg {\n\tfiles, err := m.client.GetAuthFiles()\n\treturn authFilesMsg{files: files, err: err}\n}\n\nfunc (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase authFilesMsg:\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t} else {\n\t\t\tm.err = nil\n\t\t\tm.files = msg.files\n\t\t\tif m.cursor >= len(m.files) {\n\t\t\t\tm.cursor = max(0, len(m.files)-1)\n\t\t\t}\n\t\t\tm.status = \"\"\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase authActionMsg:\n\t\tif msg.err != nil {\n\t\t\tm.status = errorStyle.Render(\"✗ \" + msg.err.Error())\n\t\t} else {\n\t\t\tm.status = successStyle.Render(\"✓ \" + msg.action)\n\t\t}\n\t\tm.confirm = -1\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, m.fetchFiles\n\n\tcase tea.KeyMsg:\n\t\t// ---- Editing mode ----\n\t\tif m.editing {\n\t\t\treturn m.handleEditInput(msg)\n\t\t}\n\n\t\t// ---- Delete confirmation mode ----\n\t\tif m.confirm >= 0 {\n\t\t\treturn m.handleConfirmInput(msg)\n\t\t}\n\n\t\t// ---- Normal mode ----\n\t\treturn m.handleNormalInput(msg)\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\n// startEdit activates inline editing for a field on the currently selected auth file.\nfunc (m *authTabModel) startEdit(fieldIdx int) tea.Cmd {\n\tif m.cursor >= len(m.files) {\n\t\treturn nil\n\t}\n\tf := m.files[m.cursor]\n\tm.editFileName = getString(f, \"name\")\n\tm.editField = fieldIdx\n\tm.editing = true\n\n\t// Pre-populate with current value\n\tkey := authEditableFields[fieldIdx].key\n\tcurrentVal := getAnyString(f, key)\n\tm.editInput.SetValue(currentVal)\n\tm.editInput.Focus()\n\tm.editInput.Prompt = fmt.Sprintf(\"  %s: \", authEditableFields[fieldIdx].label)\n\tm.viewport.SetContent(m.renderContent())\n\treturn textinput.Blink\n}\n\nfunc (m *authTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tm.editInput.Width = w - 20\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m authTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m authTabModel) renderContent() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"auth_title\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"auth_help1\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"auth_help2\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(strings.Repeat(\"─\", m.width))\n\tsb.WriteString(\"\\n\")\n\n\tif m.err != nil {\n\t\tsb.WriteString(errorStyle.Render(\"⚠ Error: \" + m.err.Error()))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\tif len(m.files) == 0 {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"no_auth_files\")))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\tfor i, f := range m.files {\n\t\tname := getString(f, \"name\")\n\t\tchannel := getString(f, \"channel\")\n\t\temail := getString(f, \"email\")\n\t\tdisabled := getBool(f, \"disabled\")\n\n\t\tstatusIcon := successStyle.Render(\"●\")\n\t\tstatusText := T(\"status_active\")\n\t\tif disabled {\n\t\t\tstatusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render(\"○\")\n\t\t\tstatusText = T(\"status_disabled\")\n\t\t}\n\n\t\tcursor := \"  \"\n\t\trowStyle := lipgloss.NewStyle()\n\t\tif i == m.cursor {\n\t\t\tcursor = \"▸ \"\n\t\t\trowStyle = lipgloss.NewStyle().Bold(true)\n\t\t}\n\n\t\tdisplayName := name\n\t\tif len(displayName) > 24 {\n\t\t\tdisplayName = displayName[:21] + \"...\"\n\t\t}\n\t\tdisplayEmail := email\n\t\tif len(displayEmail) > 28 {\n\t\t\tdisplayEmail = displayEmail[:25] + \"...\"\n\t\t}\n\n\t\trow := fmt.Sprintf(\"%s%s %-24s %-12s %-28s %s\",\n\t\t\tcursor, statusIcon, displayName, channel, displayEmail, statusText)\n\t\tsb.WriteString(rowStyle.Render(row))\n\t\tsb.WriteString(\"\\n\")\n\n\t\t// Delete confirmation\n\t\tif m.confirm == i {\n\t\t\tsb.WriteString(warningStyle.Render(fmt.Sprintf(\"    \"+T(\"confirm_delete\"), name)))\n\t\t\tsb.WriteString(\"\\n\")\n\t\t}\n\n\t\t// Inline edit input\n\t\tif m.editing && i == m.cursor {\n\t\t\tsb.WriteString(m.editInput.View())\n\t\t\tsb.WriteString(\"\\n\")\n\t\t\tsb.WriteString(helpStyle.Render(\"    \" + T(\"enter_save\") + \" • \" + T(\"esc_cancel\")))\n\t\t\tsb.WriteString(\"\\n\")\n\t\t}\n\n\t\t// Expanded detail view\n\t\tif m.expanded == i {\n\t\t\tsb.WriteString(m.renderDetail(f))\n\t\t}\n\t}\n\n\tif m.status != \"\" {\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(m.status)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\nfunc (m authTabModel) renderDetail(f map[string]any) string {\n\tvar sb strings.Builder\n\n\tlabelStyle := lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"111\")).\n\t\tBold(true)\n\tvalueStyle := lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"252\"))\n\teditableMarker := lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"214\")).\n\t\tRender(\" ✎\")\n\n\tsb.WriteString(\"    ┌─────────────────────────────────────────────\\n\")\n\n\tfields := []struct {\n\t\tlabel    string\n\t\tkey      string\n\t\teditable bool\n\t}{\n\t\t{\"Name\", \"name\", false},\n\t\t{\"Channel\", \"channel\", false},\n\t\t{\"Email\", \"email\", false},\n\t\t{\"Status\", \"status\", false},\n\t\t{\"Status Msg\", \"status_message\", false},\n\t\t{\"File Name\", \"file_name\", false},\n\t\t{\"Auth Type\", \"auth_type\", false},\n\t\t{\"Prefix\", \"prefix\", true},\n\t\t{\"Proxy URL\", \"proxy_url\", true},\n\t\t{\"Priority\", \"priority\", true},\n\t\t{\"Project ID\", \"project_id\", false},\n\t\t{\"Disabled\", \"disabled\", false},\n\t\t{\"Created\", \"created_at\", false},\n\t\t{\"Updated\", \"updated_at\", false},\n\t}\n\n\tfor _, field := range fields {\n\t\tval := getAnyString(f, field.key)\n\t\tif val == \"\" || val == \"<nil>\" {\n\t\t\tif field.editable {\n\t\t\t\tval = T(\"not_set\")\n\t\t\t} else {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\teditMark := \"\"\n\t\tif field.editable {\n\t\t\teditMark = editableMarker\n\t\t}\n\t\tline := fmt.Sprintf(\"    │ %s %s%s\",\n\t\t\tlabelStyle.Render(fmt.Sprintf(\"%-12s:\", field.label)),\n\t\t\tvalueStyle.Render(val),\n\t\t\teditMark)\n\t\tsb.WriteString(line)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tsb.WriteString(\"    └─────────────────────────────────────────────\\n\")\n\treturn sb.String()\n}\n\n// getAnyString converts any value to its string representation.\nfunc getAnyString(m map[string]any, key string) string {\n\tv, ok := m[key]\n\tif !ok || v == nil {\n\t\treturn \"\"\n\t}\n\treturn fmt.Sprintf(\"%v\", v)\n}\n\nfunc max(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc (m authTabModel) handleEditInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) {\n\tswitch msg.String() {\n\tcase \"enter\":\n\t\tvalue := m.editInput.Value()\n\t\tfieldKey := authEditableFields[m.editField].key\n\t\tfileName := m.editFileName\n\t\tm.editing = false\n\t\tm.editInput.Blur()\n\t\tfields := map[string]any{}\n\t\tif fieldKey == \"priority\" {\n\t\t\tp, err := strconv.Atoi(value)\n\t\t\tif err != nil {\n\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\treturn authActionMsg{err: fmt.Errorf(\"%s: %s\", T(\"invalid_int\"), value)}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfields[fieldKey] = p\n\t\t} else {\n\t\t\tfields[fieldKey] = value\n\t\t}\n\t\treturn m, func() tea.Msg {\n\t\t\terr := m.client.PatchAuthFileFields(fileName, fields)\n\t\t\tif err != nil {\n\t\t\t\treturn authActionMsg{err: err}\n\t\t\t}\n\t\t\treturn authActionMsg{action: fmt.Sprintf(T(\"updated_field\"), fieldKey, fileName)}\n\t\t}\n\tcase \"esc\":\n\t\tm.editing = false\n\t\tm.editInput.Blur()\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tdefault:\n\t\tvar cmd tea.Cmd\n\t\tm.editInput, cmd = m.editInput.Update(msg)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, cmd\n\t}\n}\n\nfunc (m authTabModel) handleConfirmInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) {\n\tswitch msg.String() {\n\tcase \"y\", \"Y\":\n\t\tidx := m.confirm\n\t\tm.confirm = -1\n\t\tif idx < len(m.files) {\n\t\t\tname := getString(m.files[idx], \"name\")\n\t\t\treturn m, func() tea.Msg {\n\t\t\t\terr := m.client.DeleteAuthFile(name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn authActionMsg{err: err}\n\t\t\t\t}\n\t\t\t\treturn authActionMsg{action: fmt.Sprintf(T(\"deleted\"), name)}\n\t\t\t}\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase \"n\", \"N\", \"esc\":\n\t\tm.confirm = -1\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\t}\n\treturn m, nil\n}\n\nfunc (m authTabModel) handleNormalInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) {\n\tswitch msg.String() {\n\tcase \"j\", \"down\":\n\t\tif len(m.files) > 0 {\n\t\t\tm.cursor = (m.cursor + 1) % len(m.files)\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t}\n\t\treturn m, nil\n\tcase \"k\", \"up\":\n\t\tif len(m.files) > 0 {\n\t\t\tm.cursor = (m.cursor - 1 + len(m.files)) % len(m.files)\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t}\n\t\treturn m, nil\n\tcase \"enter\", \" \":\n\t\tif m.expanded == m.cursor {\n\t\t\tm.expanded = -1\n\t\t} else {\n\t\t\tm.expanded = m.cursor\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase \"d\", \"D\":\n\t\tif m.cursor < len(m.files) {\n\t\t\tm.confirm = m.cursor\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t}\n\t\treturn m, nil\n\tcase \"e\", \"E\":\n\t\tif m.cursor < len(m.files) {\n\t\t\tf := m.files[m.cursor]\n\t\t\tname := getString(f, \"name\")\n\t\t\tdisabled := getBool(f, \"disabled\")\n\t\t\tnewDisabled := !disabled\n\t\t\treturn m, func() tea.Msg {\n\t\t\t\terr := m.client.ToggleAuthFile(name, newDisabled)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn authActionMsg{err: err}\n\t\t\t\t}\n\t\t\t\taction := T(\"enabled\")\n\t\t\t\tif newDisabled {\n\t\t\t\t\taction = T(\"disabled\")\n\t\t\t\t}\n\t\t\t\treturn authActionMsg{action: fmt.Sprintf(\"%s %s\", action, name)}\n\t\t\t}\n\t\t}\n\t\treturn m, nil\n\tcase \"1\":\n\t\treturn m, m.startEdit(0) // prefix\n\tcase \"2\":\n\t\treturn m, m.startEdit(1) // proxy_url\n\tcase \"3\":\n\t\treturn m, m.startEdit(2) // priority\n\tcase \"r\":\n\t\tm.status = \"\"\n\t\treturn m, m.fetchFiles\n\tdefault:\n\t\tvar cmd tea.Cmd\n\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\treturn m, cmd\n\t}\n}\n"
  },
  {
    "path": "internal/tui/browser.go",
    "content": "package tui\n\nimport (\n\t\"os/exec\"\n\t\"runtime\"\n)\n\n// openBrowser opens the specified URL in the user's default browser.\nfunc openBrowser(url string) error {\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", url).Start()\n\tcase \"linux\":\n\t\treturn exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\treturn exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url).Start()\n\tdefault:\n\t\treturn exec.Command(\"xdg-open\", url).Start()\n\t}\n}\n"
  },
  {
    "path": "internal/tui/client.go",
    "content": "package tui\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Client wraps HTTP calls to the management API.\ntype Client struct {\n\tbaseURL   string\n\tsecretKey string\n\thttp      *http.Client\n}\n\n// NewClient creates a new management API client.\nfunc NewClient(port int, secretKey string) *Client {\n\treturn &Client{\n\t\tbaseURL:   fmt.Sprintf(\"http://127.0.0.1:%d\", port),\n\t\tsecretKey: strings.TrimSpace(secretKey),\n\t\thttp: &http.Client{\n\t\t\tTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\n// SetSecretKey updates management API bearer token used by this client.\nfunc (c *Client) SetSecretKey(secretKey string) {\n\tc.secretKey = strings.TrimSpace(secretKey)\n}\n\nfunc (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) {\n\turl := c.baseURL + path\n\treq, err := http.NewRequest(method, url, body)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tif c.secretKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+c.secretKey)\n\t}\n\tif body != nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t}\n\tresp, err := c.http.Do(req)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer resp.Body.Close()\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, resp.StatusCode, err\n\t}\n\treturn data, resp.StatusCode, nil\n}\n\nfunc (c *Client) get(path string) ([]byte, error) {\n\tdata, code, err := c.doRequest(\"GET\", path, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif code >= 400 {\n\t\treturn nil, fmt.Errorf(\"HTTP %d: %s\", code, strings.TrimSpace(string(data)))\n\t}\n\treturn data, nil\n}\n\nfunc (c *Client) put(path string, body io.Reader) ([]byte, error) {\n\tdata, code, err := c.doRequest(\"PUT\", path, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif code >= 400 {\n\t\treturn nil, fmt.Errorf(\"HTTP %d: %s\", code, strings.TrimSpace(string(data)))\n\t}\n\treturn data, nil\n}\n\nfunc (c *Client) patch(path string, body io.Reader) ([]byte, error) {\n\tdata, code, err := c.doRequest(\"PATCH\", path, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif code >= 400 {\n\t\treturn nil, fmt.Errorf(\"HTTP %d: %s\", code, strings.TrimSpace(string(data)))\n\t}\n\treturn data, nil\n}\n\n// getJSON fetches a path and unmarshals JSON into a generic map.\nfunc (c *Client) getJSON(path string) (map[string]any, error) {\n\tdata, err := c.get(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result map[string]any\n\tif err := json.Unmarshal(data, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\n// postJSON sends a JSON body via POST and checks for errors.\nfunc (c *Client) postJSON(path string, body any) error {\n\tjsonBody, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, code, err := c.doRequest(\"POST\", path, strings.NewReader(string(jsonBody)))\n\tif err != nil {\n\t\treturn err\n\t}\n\tif code >= 400 {\n\t\treturn fmt.Errorf(\"HTTP %d\", code)\n\t}\n\treturn nil\n}\n\n// GetConfig fetches the parsed config.\nfunc (c *Client) GetConfig() (map[string]any, error) {\n\treturn c.getJSON(\"/v0/management/config\")\n}\n\n// GetConfigYAML fetches the raw config.yaml content.\nfunc (c *Client) GetConfigYAML() (string, error) {\n\tdata, err := c.get(\"/v0/management/config.yaml\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(data), nil\n}\n\n// PutConfigYAML uploads new config.yaml content.\nfunc (c *Client) PutConfigYAML(yamlContent string) error {\n\t_, err := c.put(\"/v0/management/config.yaml\", strings.NewReader(yamlContent))\n\treturn err\n}\n\n// GetUsage fetches usage statistics.\nfunc (c *Client) GetUsage() (map[string]any, error) {\n\treturn c.getJSON(\"/v0/management/usage\")\n}\n\n// GetAuthFiles lists auth credential files.\n// API returns {\"files\": [...]}.\nfunc (c *Client) GetAuthFiles() ([]map[string]any, error) {\n\twrapper, err := c.getJSON(\"/v0/management/auth-files\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn extractList(wrapper, \"files\")\n}\n\n// DeleteAuthFile deletes a single auth file by name.\nfunc (c *Client) DeleteAuthFile(name string) error {\n\tquery := url.Values{}\n\tquery.Set(\"name\", name)\n\tpath := \"/v0/management/auth-files?\" + query.Encode()\n\t_, code, err := c.doRequest(\"DELETE\", path, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif code >= 400 {\n\t\treturn fmt.Errorf(\"delete failed (HTTP %d)\", code)\n\t}\n\treturn nil\n}\n\n// ToggleAuthFile enables or disables an auth file.\nfunc (c *Client) ToggleAuthFile(name string, disabled bool) error {\n\tbody, _ := json.Marshal(map[string]any{\"name\": name, \"disabled\": disabled})\n\t_, err := c.patch(\"/v0/management/auth-files/status\", strings.NewReader(string(body)))\n\treturn err\n}\n\n// PatchAuthFileFields updates editable fields on an auth file.\nfunc (c *Client) PatchAuthFileFields(name string, fields map[string]any) error {\n\tfields[\"name\"] = name\n\tbody, _ := json.Marshal(fields)\n\t_, err := c.patch(\"/v0/management/auth-files/fields\", strings.NewReader(string(body)))\n\treturn err\n}\n\n// GetLogs fetches log lines from the server.\nfunc (c *Client) GetLogs(after int64, limit int) ([]string, int64, error) {\n\tquery := url.Values{}\n\tif limit > 0 {\n\t\tquery.Set(\"limit\", strconv.Itoa(limit))\n\t}\n\tif after > 0 {\n\t\tquery.Set(\"after\", strconv.FormatInt(after, 10))\n\t}\n\n\tpath := \"/v0/management/logs\"\n\tencodedQuery := query.Encode()\n\tif encodedQuery != \"\" {\n\t\tpath += \"?\" + encodedQuery\n\t}\n\n\twrapper, err := c.getJSON(path)\n\tif err != nil {\n\t\treturn nil, after, err\n\t}\n\n\tlines := []string{}\n\tif rawLines, ok := wrapper[\"lines\"]; ok && rawLines != nil {\n\t\trawJSON, errMarshal := json.Marshal(rawLines)\n\t\tif errMarshal != nil {\n\t\t\treturn nil, after, errMarshal\n\t\t}\n\t\tif errUnmarshal := json.Unmarshal(rawJSON, &lines); errUnmarshal != nil {\n\t\t\treturn nil, after, errUnmarshal\n\t\t}\n\t}\n\n\tlatest := after\n\tif rawLatest, ok := wrapper[\"latest-timestamp\"]; ok {\n\t\tswitch value := rawLatest.(type) {\n\t\tcase float64:\n\t\t\tlatest = int64(value)\n\t\tcase json.Number:\n\t\t\tif parsed, errParse := value.Int64(); errParse == nil {\n\t\t\t\tlatest = parsed\n\t\t\t}\n\t\tcase int64:\n\t\t\tlatest = value\n\t\tcase int:\n\t\t\tlatest = int64(value)\n\t\t}\n\t}\n\tif latest < after {\n\t\tlatest = after\n\t}\n\n\treturn lines, latest, nil\n}\n\n// GetAPIKeys fetches the list of API keys.\n// API returns {\"api-keys\": [...]}.\nfunc (c *Client) GetAPIKeys() ([]string, error) {\n\twrapper, err := c.getJSON(\"/v0/management/api-keys\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tarr, ok := wrapper[\"api-keys\"]\n\tif !ok {\n\t\treturn nil, nil\n\t}\n\traw, err := json.Marshal(arr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result []string\n\tif err := json.Unmarshal(raw, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\n// AddAPIKey adds a new API key by sending old=nil, new=key which appends.\nfunc (c *Client) AddAPIKey(key string) error {\n\tbody := map[string]any{\"old\": nil, \"new\": key}\n\tjsonBody, _ := json.Marshal(body)\n\t_, err := c.patch(\"/v0/management/api-keys\", strings.NewReader(string(jsonBody)))\n\treturn err\n}\n\n// EditAPIKey replaces an API key at the given index.\nfunc (c *Client) EditAPIKey(index int, newValue string) error {\n\tbody := map[string]any{\"index\": index, \"value\": newValue}\n\tjsonBody, _ := json.Marshal(body)\n\t_, err := c.patch(\"/v0/management/api-keys\", strings.NewReader(string(jsonBody)))\n\treturn err\n}\n\n// DeleteAPIKey deletes an API key by index.\nfunc (c *Client) DeleteAPIKey(index int) error {\n\t_, code, err := c.doRequest(\"DELETE\", fmt.Sprintf(\"/v0/management/api-keys?index=%d\", index), nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif code >= 400 {\n\t\treturn fmt.Errorf(\"delete failed (HTTP %d)\", code)\n\t}\n\treturn nil\n}\n\n// GetGeminiKeys fetches Gemini API keys.\n// API returns {\"gemini-api-key\": [...]}.\nfunc (c *Client) GetGeminiKeys() ([]map[string]any, error) {\n\treturn c.getWrappedKeyList(\"/v0/management/gemini-api-key\", \"gemini-api-key\")\n}\n\n// GetClaudeKeys fetches Claude API keys.\nfunc (c *Client) GetClaudeKeys() ([]map[string]any, error) {\n\treturn c.getWrappedKeyList(\"/v0/management/claude-api-key\", \"claude-api-key\")\n}\n\n// GetCodexKeys fetches Codex API keys.\nfunc (c *Client) GetCodexKeys() ([]map[string]any, error) {\n\treturn c.getWrappedKeyList(\"/v0/management/codex-api-key\", \"codex-api-key\")\n}\n\n// GetVertexKeys fetches Vertex API keys.\nfunc (c *Client) GetVertexKeys() ([]map[string]any, error) {\n\treturn c.getWrappedKeyList(\"/v0/management/vertex-api-key\", \"vertex-api-key\")\n}\n\n// GetOpenAICompat fetches OpenAI compatibility entries.\nfunc (c *Client) GetOpenAICompat() ([]map[string]any, error) {\n\treturn c.getWrappedKeyList(\"/v0/management/openai-compatibility\", \"openai-compatibility\")\n}\n\n// getWrappedKeyList fetches a wrapped list from the API.\nfunc (c *Client) getWrappedKeyList(path, key string) ([]map[string]any, error) {\n\twrapper, err := c.getJSON(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn extractList(wrapper, key)\n}\n\n// extractList pulls an array of maps from a wrapper object by key.\nfunc extractList(wrapper map[string]any, key string) ([]map[string]any, error) {\n\tarr, ok := wrapper[key]\n\tif !ok || arr == nil {\n\t\treturn nil, nil\n\t}\n\traw, err := json.Marshal(arr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result []map[string]any\n\tif err := json.Unmarshal(raw, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\n// GetDebug fetches the current debug setting.\nfunc (c *Client) GetDebug() (bool, error) {\n\twrapper, err := c.getJSON(\"/v0/management/debug\")\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif v, ok := wrapper[\"debug\"]; ok {\n\t\tif b, ok := v.(bool); ok {\n\t\t\treturn b, nil\n\t\t}\n\t}\n\treturn false, nil\n}\n\n// GetAuthStatus polls the OAuth session status.\n// Returns status (\"wait\", \"ok\", \"error\") and optional error message.\nfunc (c *Client) GetAuthStatus(state string) (string, string, error) {\n\tquery := url.Values{}\n\tquery.Set(\"state\", state)\n\tpath := \"/v0/management/get-auth-status?\" + query.Encode()\n\twrapper, err := c.getJSON(path)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tstatus := getString(wrapper, \"status\")\n\terrMsg := getString(wrapper, \"error\")\n\treturn status, errMsg, nil\n}\n\n// ----- Config field update methods -----\n\n// PutBoolField updates a boolean config field.\nfunc (c *Client) PutBoolField(path string, value bool) error {\n\tbody, _ := json.Marshal(map[string]any{\"value\": value})\n\t_, err := c.put(\"/v0/management/\"+path, strings.NewReader(string(body)))\n\treturn err\n}\n\n// PutIntField updates an integer config field.\nfunc (c *Client) PutIntField(path string, value int) error {\n\tbody, _ := json.Marshal(map[string]any{\"value\": value})\n\t_, err := c.put(\"/v0/management/\"+path, strings.NewReader(string(body)))\n\treturn err\n}\n\n// PutStringField updates a string config field.\nfunc (c *Client) PutStringField(path string, value string) error {\n\tbody, _ := json.Marshal(map[string]any{\"value\": value})\n\t_, err := c.put(\"/v0/management/\"+path, strings.NewReader(string(body)))\n\treturn err\n}\n\n// DeleteField sends a DELETE request for a config field.\nfunc (c *Client) DeleteField(path string) error {\n\t_, _, err := c.doRequest(\"DELETE\", \"/v0/management/\"+path, nil)\n\treturn err\n}\n"
  },
  {
    "path": "internal/tui/config_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// configField represents a single editable config field.\ntype configField struct {\n\tlabel    string\n\tapiPath  string // management API path (e.g. \"debug\", \"proxy-url\")\n\tkind     string // \"bool\", \"int\", \"string\", \"readonly\"\n\tvalue    string // current display value\n\trawValue any    // raw value from API\n}\n\n// configTabModel displays parsed config with interactive editing.\ntype configTabModel struct {\n\tclient    *Client\n\tviewport  viewport.Model\n\tfields    []configField\n\tcursor    int\n\tediting   bool\n\ttextInput textinput.Model\n\terr       error\n\tmessage   string // status message (success/error)\n\twidth     int\n\theight    int\n\tready     bool\n}\n\ntype configDataMsg struct {\n\tconfig map[string]any\n\terr    error\n}\n\ntype configUpdateMsg struct {\n\tpath  string\n\tvalue any\n\terr   error\n}\n\nfunc newConfigTabModel(client *Client) configTabModel {\n\tti := textinput.New()\n\tti.CharLimit = 256\n\treturn configTabModel{\n\t\tclient:    client,\n\t\ttextInput: ti,\n\t}\n}\n\nfunc (m configTabModel) Init() tea.Cmd {\n\treturn m.fetchConfig\n}\n\nfunc (m configTabModel) fetchConfig() tea.Msg {\n\tcfg, err := m.client.GetConfig()\n\treturn configDataMsg{config: cfg, err: err}\n}\n\nfunc (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase configDataMsg:\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t\tm.fields = nil\n\t\t} else {\n\t\t\tm.err = nil\n\t\t\tm.fields = m.parseConfig(msg.config)\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase configUpdateMsg:\n\t\tif msg.err != nil {\n\t\t\tm.message = errorStyle.Render(\"✗ \" + msg.err.Error())\n\t\t} else {\n\t\t\tm.message = successStyle.Render(T(\"updated_ok\"))\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\t// Refresh config from server\n\t\treturn m, m.fetchConfig\n\n\tcase tea.KeyMsg:\n\t\tif m.editing {\n\t\t\treturn m.handleEditingKey(msg)\n\t\t}\n\t\treturn m.handleNormalKey(msg)\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) {\n\tswitch msg.String() {\n\tcase \"r\":\n\t\tm.message = \"\"\n\t\treturn m, m.fetchConfig\n\tcase \"up\", \"k\":\n\t\tif m.cursor > 0 {\n\t\t\tm.cursor--\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t// Ensure cursor is visible\n\t\t\tm.ensureCursorVisible()\n\t\t}\n\t\treturn m, nil\n\tcase \"down\", \"j\":\n\t\tif m.cursor < len(m.fields)-1 {\n\t\t\tm.cursor++\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\tm.ensureCursorVisible()\n\t\t}\n\t\treturn m, nil\n\tcase \"enter\", \" \":\n\t\tif m.cursor >= 0 && m.cursor < len(m.fields) {\n\t\t\tf := m.fields[m.cursor]\n\t\t\tif f.kind == \"readonly\" {\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tif f.kind == \"bool\" {\n\t\t\t\t// Toggle directly\n\t\t\t\treturn m, m.toggleBool(m.cursor)\n\t\t\t}\n\t\t\t// Start editing for int/string\n\t\t\tm.editing = true\n\t\t\tm.textInput.SetValue(configFieldEditValue(f))\n\t\t\tm.textInput.Focus()\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\treturn m, textinput.Blink\n\t\t}\n\t\treturn m, nil\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m configTabModel) handleEditingKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) {\n\tswitch msg.String() {\n\tcase \"enter\":\n\t\tm.editing = false\n\t\tm.textInput.Blur()\n\t\treturn m, m.submitEdit(m.cursor, m.textInput.Value())\n\tcase \"esc\":\n\t\tm.editing = false\n\t\tm.textInput.Blur()\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tdefault:\n\t\tvar cmd tea.Cmd\n\t\tm.textInput, cmd = m.textInput.Update(msg)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, cmd\n\t}\n}\n\nfunc (m configTabModel) toggleBool(idx int) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tf := m.fields[idx]\n\t\tcurrent := f.value == \"true\"\n\t\tnewValue := !current\n\t\terrPutBool := m.client.PutBoolField(f.apiPath, newValue)\n\t\treturn configUpdateMsg{\n\t\t\tpath:  f.apiPath,\n\t\t\tvalue: newValue,\n\t\t\terr:   errPutBool,\n\t\t}\n\t}\n}\n\nfunc (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tf := m.fields[idx]\n\t\tvar err error\n\t\tvar value any\n\t\tswitch f.kind {\n\t\tcase \"int\":\n\t\t\tvalueInt, errAtoi := strconv.Atoi(newValue)\n\t\t\tif errAtoi != nil {\n\t\t\t\treturn configUpdateMsg{\n\t\t\t\t\tpath: f.apiPath,\n\t\t\t\t\terr:  fmt.Errorf(\"%s: %s\", T(\"invalid_int\"), newValue),\n\t\t\t\t}\n\t\t\t}\n\t\t\tvalue = valueInt\n\t\t\terr = m.client.PutIntField(f.apiPath, valueInt)\n\t\tcase \"string\":\n\t\t\tvalue = newValue\n\t\t\terr = m.client.PutStringField(f.apiPath, newValue)\n\t\t}\n\t\treturn configUpdateMsg{\n\t\t\tpath:  f.apiPath,\n\t\t\tvalue: value,\n\t\t\terr:   err,\n\t\t}\n\t}\n}\n\nfunc configFieldEditValue(f configField) string {\n\tif rawString, ok := f.rawValue.(string); ok {\n\t\treturn rawString\n\t}\n\treturn f.value\n}\n\nfunc (m *configTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m *configTabModel) ensureCursorVisible() {\n\t// Each field takes ~1 line, header takes ~4 lines\n\ttargetLine := m.cursor + 5\n\tif targetLine < m.viewport.YOffset {\n\t\tm.viewport.SetYOffset(targetLine)\n\t}\n\tif targetLine >= m.viewport.YOffset+m.viewport.Height {\n\t\tm.viewport.SetYOffset(targetLine - m.viewport.Height + 1)\n\t}\n}\n\nfunc (m configTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m configTabModel) renderContent() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"config_title\")))\n\tsb.WriteString(\"\\n\")\n\n\tif m.message != \"\" {\n\t\tsb.WriteString(\"  \" + m.message)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tsb.WriteString(helpStyle.Render(T(\"config_help1\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"config_help2\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\tif m.err != nil {\n\t\tsb.WriteString(errorStyle.Render(\"  ⚠ Error: \" + m.err.Error()))\n\t\treturn sb.String()\n\t}\n\n\tif len(m.fields) == 0 {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"no_config\")))\n\t\treturn sb.String()\n\t}\n\n\tcurrentSection := \"\"\n\tfor i, f := range m.fields {\n\t\t// Section headers\n\t\tsection := fieldSection(f.apiPath)\n\t\tif section != currentSection {\n\t\t\tcurrentSection = section\n\t\t\tsb.WriteString(\"\\n\")\n\t\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(\"  ── \" + section + \" \"))\n\t\t\tsb.WriteString(\"\\n\")\n\t\t}\n\n\t\tisSelected := i == m.cursor\n\t\tprefix := \"  \"\n\t\tif isSelected {\n\t\t\tprefix = \"▸ \"\n\t\t}\n\n\t\tlabelStr := lipgloss.NewStyle().\n\t\t\tForeground(colorInfo).\n\t\t\tBold(isSelected).\n\t\t\tWidth(32).\n\t\t\tRender(f.label)\n\n\t\tvar valueStr string\n\t\tif m.editing && isSelected {\n\t\t\tvalueStr = m.textInput.View()\n\t\t} else {\n\t\t\tswitch f.kind {\n\t\t\tcase \"bool\":\n\t\t\t\tif f.value == \"true\" {\n\t\t\t\t\tvalueStr = successStyle.Render(\"● ON\")\n\t\t\t\t} else {\n\t\t\t\t\tvalueStr = lipgloss.NewStyle().Foreground(colorMuted).Render(\"○ OFF\")\n\t\t\t\t}\n\t\t\tcase \"readonly\":\n\t\t\t\tvalueStr = lipgloss.NewStyle().Foreground(colorSubtext).Render(f.value)\n\t\t\tdefault:\n\t\t\t\tvalueStr = valueStyle.Render(f.value)\n\t\t\t}\n\t\t}\n\n\t\tline := prefix + labelStr + \"  \" + valueStr\n\t\tif isSelected && !m.editing {\n\t\t\tline = lipgloss.NewStyle().Background(colorSurface).Render(line)\n\t\t}\n\t\tsb.WriteString(line + \"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\nfunc (m configTabModel) parseConfig(cfg map[string]any) []configField {\n\tvar fields []configField\n\n\t// Server settings\n\tfields = append(fields, configField{\"Port\", \"port\", \"readonly\", fmt.Sprintf(\"%.0f\", getFloat(cfg, \"port\")), nil})\n\tfields = append(fields, configField{\"Host\", \"host\", \"readonly\", getString(cfg, \"host\"), nil})\n\tfields = append(fields, configField{\"Debug\", \"debug\", \"bool\", fmt.Sprintf(\"%v\", getBool(cfg, \"debug\")), nil})\n\tfields = append(fields, configField{\"Proxy URL\", \"proxy-url\", \"string\", getString(cfg, \"proxy-url\"), nil})\n\tfields = append(fields, configField{\"Request Retry\", \"request-retry\", \"int\", fmt.Sprintf(\"%.0f\", getFloat(cfg, \"request-retry\")), nil})\n\tfields = append(fields, configField{\"Max Retry Interval (s)\", \"max-retry-interval\", \"int\", fmt.Sprintf(\"%.0f\", getFloat(cfg, \"max-retry-interval\")), nil})\n\tfields = append(fields, configField{\"Force Model Prefix\", \"force-model-prefix\", \"string\", getString(cfg, \"force-model-prefix\"), nil})\n\n\t// Logging\n\tfields = append(fields, configField{\"Logging to File\", \"logging-to-file\", \"bool\", fmt.Sprintf(\"%v\", getBool(cfg, \"logging-to-file\")), nil})\n\tfields = append(fields, configField{\"Logs Max Total Size (MB)\", \"logs-max-total-size-mb\", \"int\", fmt.Sprintf(\"%.0f\", getFloat(cfg, \"logs-max-total-size-mb\")), nil})\n\tfields = append(fields, configField{\"Error Logs Max Files\", \"error-logs-max-files\", \"int\", fmt.Sprintf(\"%.0f\", getFloat(cfg, \"error-logs-max-files\")), nil})\n\tfields = append(fields, configField{\"Usage Stats Enabled\", \"usage-statistics-enabled\", \"bool\", fmt.Sprintf(\"%v\", getBool(cfg, \"usage-statistics-enabled\")), nil})\n\tfields = append(fields, configField{\"Request Log\", \"request-log\", \"bool\", fmt.Sprintf(\"%v\", getBool(cfg, \"request-log\")), nil})\n\n\t// Quota exceeded\n\tfields = append(fields, configField{\"Switch Project on Quota\", \"quota-exceeded/switch-project\", \"bool\", fmt.Sprintf(\"%v\", getBoolNested(cfg, \"quota-exceeded\", \"switch-project\")), nil})\n\tfields = append(fields, configField{\"Switch Preview Model\", \"quota-exceeded/switch-preview-model\", \"bool\", fmt.Sprintf(\"%v\", getBoolNested(cfg, \"quota-exceeded\", \"switch-preview-model\")), nil})\n\n\t// Routing\n\tif routing, ok := cfg[\"routing\"].(map[string]any); ok {\n\t\tfields = append(fields, configField{\"Routing Strategy\", \"routing/strategy\", \"string\", getString(routing, \"strategy\"), nil})\n\t} else {\n\t\tfields = append(fields, configField{\"Routing Strategy\", \"routing/strategy\", \"string\", \"\", nil})\n\t}\n\n\t// WebSocket auth\n\tfields = append(fields, configField{\"WebSocket Auth\", \"ws-auth\", \"bool\", fmt.Sprintf(\"%v\", getBool(cfg, \"ws-auth\")), nil})\n\n\t// AMP settings\n\tif amp, ok := cfg[\"ampcode\"].(map[string]any); ok {\n\t\tupstreamURL := getString(amp, \"upstream-url\")\n\t\tupstreamAPIKey := getString(amp, \"upstream-api-key\")\n\t\tfields = append(fields, configField{\"AMP Upstream URL\", \"ampcode/upstream-url\", \"string\", upstreamURL, upstreamURL})\n\t\tfields = append(fields, configField{\"AMP Upstream API Key\", \"ampcode/upstream-api-key\", \"string\", maskIfNotEmpty(upstreamAPIKey), upstreamAPIKey})\n\t\tfields = append(fields, configField{\"AMP Restrict Mgmt Localhost\", \"ampcode/restrict-management-to-localhost\", \"bool\", fmt.Sprintf(\"%v\", getBool(amp, \"restrict-management-to-localhost\")), nil})\n\t}\n\n\treturn fields\n}\n\nfunc fieldSection(apiPath string) string {\n\tif strings.HasPrefix(apiPath, \"ampcode/\") {\n\t\treturn T(\"section_ampcode\")\n\t}\n\tif strings.HasPrefix(apiPath, \"quota-exceeded/\") {\n\t\treturn T(\"section_quota\")\n\t}\n\tif strings.HasPrefix(apiPath, \"routing/\") {\n\t\treturn T(\"section_routing\")\n\t}\n\tswitch apiPath {\n\tcase \"port\", \"host\", \"debug\", \"proxy-url\", \"request-retry\", \"max-retry-interval\", \"force-model-prefix\":\n\t\treturn T(\"section_server\")\n\tcase \"logging-to-file\", \"logs-max-total-size-mb\", \"error-logs-max-files\", \"usage-statistics-enabled\", \"request-log\":\n\t\treturn T(\"section_logging\")\n\tcase \"ws-auth\":\n\t\treturn T(\"section_websocket\")\n\tdefault:\n\t\treturn T(\"section_other\")\n\t}\n}\n\nfunc getBoolNested(m map[string]any, keys ...string) bool {\n\tcurrent := m\n\tfor i, key := range keys {\n\t\tif i == len(keys)-1 {\n\t\t\treturn getBool(current, key)\n\t\t}\n\t\tif nested, ok := current[key].(map[string]any); ok {\n\t\t\tcurrent = nested\n\t\t} else {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn false\n}\n\nfunc maskIfNotEmpty(s string) string {\n\tif s == \"\" {\n\t\treturn T(\"not_set\")\n\t}\n\treturn maskKey(s)\n}\n"
  },
  {
    "path": "internal/tui/dashboard.go",
    "content": "package tui\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// dashboardModel displays server info, stats cards, and config overview.\ntype dashboardModel struct {\n\tclient   *Client\n\tviewport viewport.Model\n\tcontent  string\n\terr      error\n\twidth    int\n\theight   int\n\tready    bool\n\n\t// Cached data for re-rendering on locale change\n\tlastConfig    map[string]any\n\tlastUsage     map[string]any\n\tlastAuthFiles []map[string]any\n\tlastAPIKeys   []string\n}\n\ntype dashboardDataMsg struct {\n\tconfig    map[string]any\n\tusage     map[string]any\n\tauthFiles []map[string]any\n\tapiKeys   []string\n\terr       error\n}\n\nfunc newDashboardModel(client *Client) dashboardModel {\n\treturn dashboardModel{\n\t\tclient: client,\n\t}\n}\n\nfunc (m dashboardModel) Init() tea.Cmd {\n\treturn m.fetchData\n}\n\nfunc (m dashboardModel) fetchData() tea.Msg {\n\tcfg, cfgErr := m.client.GetConfig()\n\tusage, usageErr := m.client.GetUsage()\n\tauthFiles, authErr := m.client.GetAuthFiles()\n\tapiKeys, keysErr := m.client.GetAPIKeys()\n\n\tvar err error\n\tfor _, e := range []error{cfgErr, usageErr, authErr, keysErr} {\n\t\tif e != nil {\n\t\t\terr = e\n\t\t\tbreak\n\t\t}\n\t}\n\treturn dashboardDataMsg{config: cfg, usage: usage, authFiles: authFiles, apiKeys: apiKeys, err: err}\n}\n\nfunc (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\t// Re-render immediately with cached data using new locale\n\t\tm.content = m.renderDashboard(m.lastConfig, m.lastUsage, m.lastAuthFiles, m.lastAPIKeys)\n\t\tm.viewport.SetContent(m.content)\n\t\t// Also fetch fresh data in background\n\t\treturn m, m.fetchData\n\n\tcase dashboardDataMsg:\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t\tm.content = errorStyle.Render(\"⚠ Error: \" + msg.err.Error())\n\t\t} else {\n\t\t\tm.err = nil\n\t\t\t// Cache data for locale switching\n\t\t\tm.lastConfig = msg.config\n\t\t\tm.lastUsage = msg.usage\n\t\t\tm.lastAuthFiles = msg.authFiles\n\t\t\tm.lastAPIKeys = msg.apiKeys\n\n\t\t\tm.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys)\n\t\t}\n\t\tm.viewport.SetContent(m.content)\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\tif msg.String() == \"r\" {\n\t\t\treturn m, m.fetchData\n\t\t}\n\t\tvar cmd tea.Cmd\n\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\treturn m, cmd\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m *dashboardModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.content)\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m dashboardModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"dashboard_title\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"dashboard_help\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// ━━━ Connection Status ━━━\n\tconnStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess)\n\tsb.WriteString(connStyle.Render(T(\"connected\")))\n\tsb.WriteString(fmt.Sprintf(\"  %s\", m.client.baseURL))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// ━━━ Stats Cards ━━━\n\tcardWidth := 25\n\tif m.width > 0 {\n\t\tcardWidth = (m.width - 6) / 4\n\t\tif cardWidth < 18 {\n\t\t\tcardWidth = 18\n\t\t}\n\t}\n\n\tcardStyle := lipgloss.NewStyle().\n\t\tBorder(lipgloss.RoundedBorder()).\n\t\tBorderForeground(lipgloss.Color(\"240\")).\n\t\tPadding(0, 1).\n\t\tWidth(cardWidth).\n\t\tHeight(2)\n\n\t// Card 1: API Keys\n\tkeyCount := len(apiKeys)\n\tcard1 := cardStyle.Render(fmt.Sprintf(\n\t\t\"%s\\n%s\",\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"111\")).Render(fmt.Sprintf(\"🔑 %d\", keyCount)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"mgmt_keys\")),\n\t))\n\n\t// Card 2: Auth Files\n\tauthCount := len(authFiles)\n\tactiveAuth := 0\n\tfor _, f := range authFiles {\n\t\tif !getBool(f, \"disabled\") {\n\t\t\tactiveAuth++\n\t\t}\n\t}\n\tcard2 := cardStyle.Render(fmt.Sprintf(\n\t\t\"%s\\n%s\",\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"76\")).Render(fmt.Sprintf(\"📄 %d\", authCount)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%s (%d %s)\", T(\"auth_files_label\"), activeAuth, T(\"active_suffix\"))),\n\t))\n\n\t// Card 3: Total Requests\n\ttotalReqs := int64(0)\n\tsuccessReqs := int64(0)\n\tfailedReqs := int64(0)\n\ttotalTokens := int64(0)\n\tif usage != nil {\n\t\tif usageMap, ok := usage[\"usage\"].(map[string]any); ok {\n\t\t\ttotalReqs = int64(getFloat(usageMap, \"total_requests\"))\n\t\t\tsuccessReqs = int64(getFloat(usageMap, \"success_count\"))\n\t\t\tfailedReqs = int64(getFloat(usageMap, \"failure_count\"))\n\t\t\ttotalTokens = int64(getFloat(usageMap, \"total_tokens\"))\n\t\t}\n\t}\n\tcard3 := cardStyle.Render(fmt.Sprintf(\n\t\t\"%s\\n%s\",\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"214\")).Render(fmt.Sprintf(\"📈 %d\", totalReqs)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%s (✓%d ✗%d)\", T(\"total_requests\"), successReqs, failedReqs)),\n\t))\n\n\t// Card 4: Total Tokens\n\ttokenStr := formatLargeNumber(totalTokens)\n\tcard4 := cardStyle.Render(fmt.Sprintf(\n\t\t\"%s\\n%s\",\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"170\")).Render(fmt.Sprintf(\"🔤 %s\", tokenStr)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"total_tokens\")),\n\t))\n\n\tsb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, \" \", card2, \" \", card3, \" \", card4))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// ━━━ Current Config ━━━\n\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"current_config\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 60)))\n\tsb.WriteString(\"\\n\")\n\n\tif cfg != nil {\n\t\tdebug := getBool(cfg, \"debug\")\n\t\tretry := getFloat(cfg, \"request-retry\")\n\t\tproxyURL := getString(cfg, \"proxy-url\")\n\t\tloggingToFile := getBool(cfg, \"logging-to-file\")\n\t\tusageEnabled := true\n\t\tif v, ok := cfg[\"usage-statistics-enabled\"]; ok {\n\t\t\tif b, ok2 := v.(bool); ok2 {\n\t\t\t\tusageEnabled = b\n\t\t\t}\n\t\t}\n\n\t\tconfigItems := []struct {\n\t\t\tlabel string\n\t\t\tvalue string\n\t\t}{\n\t\t\t{T(\"debug_mode\"), boolEmoji(debug)},\n\t\t\t{T(\"usage_stats\"), boolEmoji(usageEnabled)},\n\t\t\t{T(\"log_to_file\"), boolEmoji(loggingToFile)},\n\t\t\t{T(\"retry_count\"), fmt.Sprintf(\"%.0f\", retry)},\n\t\t}\n\t\tif proxyURL != \"\" {\n\t\t\tconfigItems = append(configItems, struct {\n\t\t\t\tlabel string\n\t\t\t\tvalue string\n\t\t\t}{T(\"proxy_url\"), proxyURL})\n\t\t}\n\n\t\t// Render config items as a compact row\n\t\tfor _, item := range configItems {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %s %s\\n\",\n\t\t\t\tlabelStyle.Render(item.label+\":\"),\n\t\t\t\tvalueStyle.Render(item.value)))\n\t\t}\n\n\t\t// Routing strategy\n\t\tstrategy := \"round-robin\"\n\t\tif routing, ok := cfg[\"routing\"].(map[string]any); ok {\n\t\t\tif s := getString(routing, \"strategy\"); s != \"\" {\n\t\t\t\tstrategy = s\n\t\t\t}\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"  %s %s\\n\",\n\t\t\tlabelStyle.Render(T(\"routing_strategy\")+\":\"),\n\t\t\tvalueStyle.Render(strategy)))\n\t}\n\n\tsb.WriteString(\"\\n\")\n\n\t// ━━━ Per-Model Usage ━━━\n\tif usage != nil {\n\t\tif usageMap, ok := usage[\"usage\"].(map[string]any); ok {\n\t\t\tif apis, ok := usageMap[\"apis\"].(map[string]any); ok && len(apis) > 0 {\n\t\t\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"model_stats\")))\n\t\t\t\tsb.WriteString(\"\\n\")\n\t\t\t\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 60)))\n\t\t\t\tsb.WriteString(\"\\n\")\n\n\t\t\t\theader := fmt.Sprintf(\"  %-40s %10s %12s\", T(\"model\"), T(\"requests\"), T(\"tokens\"))\n\t\t\t\tsb.WriteString(tableHeaderStyle.Render(header))\n\t\t\t\tsb.WriteString(\"\\n\")\n\n\t\t\t\tfor _, apiSnap := range apis {\n\t\t\t\t\tif apiMap, ok := apiSnap.(map[string]any); ok {\n\t\t\t\t\t\tif models, ok := apiMap[\"models\"].(map[string]any); ok {\n\t\t\t\t\t\t\tfor model, v := range models {\n\t\t\t\t\t\t\t\tif stats, ok := v.(map[string]any); ok {\n\t\t\t\t\t\t\t\t\treqs := int64(getFloat(stats, \"total_requests\"))\n\t\t\t\t\t\t\t\t\ttoks := int64(getFloat(stats, \"total_tokens\"))\n\t\t\t\t\t\t\t\t\trow := fmt.Sprintf(\"  %-40s %10d %12s\", truncate(model, 40), reqs, formatLargeNumber(toks))\n\t\t\t\t\t\t\t\t\tsb.WriteString(tableCellStyle.Render(row))\n\t\t\t\t\t\t\t\t\tsb.WriteString(\"\\n\")\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn sb.String()\n}\n\nfunc formatKV(key, value string) string {\n\treturn fmt.Sprintf(\"  %s %s\\n\", labelStyle.Render(key+\":\"), valueStyle.Render(value))\n}\n\nfunc getString(m map[string]any, key string) string {\n\tif v, ok := m[key]; ok {\n\t\tif s, ok := v.(string); ok {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc getFloat(m map[string]any, key string) float64 {\n\tif v, ok := m[key]; ok {\n\t\tswitch n := v.(type) {\n\t\tcase float64:\n\t\t\treturn n\n\t\tcase json.Number:\n\t\t\tf, _ := n.Float64()\n\t\t\treturn f\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc getBool(m map[string]any, key string) bool {\n\tif v, ok := m[key]; ok {\n\t\tif b, ok := v.(bool); ok {\n\t\t\treturn b\n\t\t}\n\t}\n\treturn false\n}\n\nfunc boolEmoji(b bool) string {\n\tif b {\n\t\treturn T(\"bool_yes\")\n\t}\n\treturn T(\"bool_no\")\n}\n\nfunc formatLargeNumber(n int64) string {\n\tif n >= 1_000_000 {\n\t\treturn fmt.Sprintf(\"%.1fM\", float64(n)/1_000_000)\n\t}\n\tif n >= 1_000 {\n\t\treturn fmt.Sprintf(\"%.1fK\", float64(n)/1_000)\n\t}\n\treturn fmt.Sprintf(\"%d\", n)\n}\n\nfunc truncate(s string, maxLen int) string {\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen-3] + \"...\"\n\t}\n\treturn s\n}\n\nfunc minInt(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n"
  },
  {
    "path": "internal/tui/i18n.go",
    "content": "package tui\n\n// i18n provides a simple internationalization system for the TUI.\n// Supported locales: \"zh\" (Chinese, default), \"en\" (English).\n\nvar currentLocale = \"en\"\n\n// SetLocale changes the active locale.\nfunc SetLocale(locale string) {\n\tif _, ok := locales[locale]; ok {\n\t\tcurrentLocale = locale\n\t}\n}\n\n// CurrentLocale returns the active locale code.\nfunc CurrentLocale() string {\n\treturn currentLocale\n}\n\n// ToggleLocale switches between zh and en.\nfunc ToggleLocale() {\n\tif currentLocale == \"zh\" {\n\t\tcurrentLocale = \"en\"\n\t} else {\n\t\tcurrentLocale = \"zh\"\n\t}\n}\n\n// T returns the translated string for the given key.\nfunc T(key string) string {\n\tif m, ok := locales[currentLocale]; ok {\n\t\tif v, ok := m[key]; ok {\n\t\t\treturn v\n\t\t}\n\t}\n\t// Fallback to English\n\tif m, ok := locales[\"en\"]; ok {\n\t\tif v, ok := m[key]; ok {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn key\n}\n\nvar locales = map[string]map[string]string{\n\t\"zh\": zhStrings,\n\t\"en\": enStrings,\n}\n\n// ──────────────────────────────────────────\n// Tab names\n// ──────────────────────────────────────────\nvar zhTabNames = []string{\"仪表盘\", \"配置\", \"认证文件\", \"API 密钥\", \"OAuth\", \"使用统计\", \"日志\"}\nvar enTabNames = []string{\"Dashboard\", \"Config\", \"Auth Files\", \"API Keys\", \"OAuth\", \"Usage\", \"Logs\"}\n\n// TabNames returns tab names in the current locale.\nfunc TabNames() []string {\n\tif currentLocale == \"zh\" {\n\t\treturn zhTabNames\n\t}\n\treturn enTabNames\n}\n\nvar zhStrings = map[string]string{\n\t// ── Common ──\n\t\"loading\":      \"加载中...\",\n\t\"refresh\":      \"刷新\",\n\t\"save\":         \"保存\",\n\t\"cancel\":       \"取消\",\n\t\"confirm\":      \"确认\",\n\t\"yes\":          \"是\",\n\t\"no\":           \"否\",\n\t\"error\":        \"错误\",\n\t\"success\":      \"成功\",\n\t\"navigate\":     \"导航\",\n\t\"scroll\":       \"滚动\",\n\t\"enter_save\":   \"Enter: 保存\",\n\t\"esc_cancel\":   \"Esc: 取消\",\n\t\"enter_submit\": \"Enter: 提交\",\n\t\"press_r\":      \"[r] 刷新\",\n\t\"press_scroll\": \"[↑↓] 滚动\",\n\t\"not_set\":      \"(未设置)\",\n\t\"error_prefix\": \"⚠ 错误: \",\n\n\t// ── Status bar ──\n\t\"status_left\":                 \" CLIProxyAPI 管理终端\",\n\t\"status_right\":                \"Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 \",\n\t\"initializing_tui\":            \"正在初始化...\",\n\t\"auth_gate_title\":             \"🔐 连接管理 API\",\n\t\"auth_gate_help\":              \" 请输入管理密码并按 Enter 连接\",\n\t\"auth_gate_password\":          \"密码\",\n\t\"auth_gate_enter\":             \" Enter: 连接 • q/Ctrl+C: 退出 • L: 语言\",\n\t\"auth_gate_connecting\":        \"正在连接...\",\n\t\"auth_gate_connect_fail\":      \"连接失败：%s\",\n\t\"auth_gate_password_required\": \"请输入密码\",\n\n\t// ── Dashboard ──\n\t\"dashboard_title\":  \"📊 仪表盘\",\n\t\"dashboard_help\":   \" [r] 刷新 • [↑↓] 滚动\",\n\t\"connected\":        \"● 已连接\",\n\t\"mgmt_keys\":        \"管理密钥\",\n\t\"auth_files_label\": \"认证文件\",\n\t\"active_suffix\":    \"活跃\",\n\t\"total_requests\":   \"请求\",\n\t\"success_label\":    \"成功\",\n\t\"failure_label\":    \"失败\",\n\t\"total_tokens\":     \"总 Tokens\",\n\t\"current_config\":   \"当前配置\",\n\t\"debug_mode\":       \"启用调试模式\",\n\t\"usage_stats\":      \"启用使用统计\",\n\t\"log_to_file\":      \"启用日志记录到文件\",\n\t\"retry_count\":      \"重试次数\",\n\t\"proxy_url\":        \"代理 URL\",\n\t\"routing_strategy\": \"路由策略\",\n\t\"model_stats\":      \"模型统计\",\n\t\"model\":            \"模型\",\n\t\"requests\":         \"请求数\",\n\t\"tokens\":           \"Tokens\",\n\t\"bool_yes\":         \"是 ✓\",\n\t\"bool_no\":          \"否\",\n\n\t// ── Config ──\n\t\"config_title\":      \"⚙ 配置\",\n\t\"config_help1\":      \"  [↑↓/jk] 导航 • [Enter/Space] 编辑 • [r] 刷新\",\n\t\"config_help2\":      \"  布尔: Enter 切换 • 文本/数字: Enter 输入, Enter 确认, Esc 取消\",\n\t\"updated_ok\":        \"✓ 更新成功\",\n\t\"no_config\":         \"  未加载配置\",\n\t\"invalid_int\":       \"无效整数\",\n\t\"section_server\":    \"服务器\",\n\t\"section_logging\":   \"日志与统计\",\n\t\"section_quota\":     \"配额超限处理\",\n\t\"section_routing\":   \"路由\",\n\t\"section_websocket\": \"WebSocket\",\n\t\"section_ampcode\":   \"AMP Code\",\n\t\"section_other\":     \"其他\",\n\n\t// ── Auth Files ──\n\t\"auth_title\":      \"🔑 认证文件\",\n\t\"auth_help1\":      \" [↑↓/jk] 导航 • [Enter] 展开 • [e] 启用/停用 • [d] 删除 • [r] 刷新\",\n\t\"auth_help2\":      \" [1] 编辑 prefix • [2] 编辑 proxy_url • [3] 编辑 priority\",\n\t\"no_auth_files\":   \"  无认证文件\",\n\t\"confirm_delete\":  \"⚠ 删除 %s? [y/n]\",\n\t\"deleted\":         \"已删除 %s\",\n\t\"enabled\":         \"已启用\",\n\t\"disabled\":        \"已停用\",\n\t\"updated_field\":   \"已更新 %s 的 %s\",\n\t\"status_active\":   \"活跃\",\n\t\"status_disabled\": \"已停用\",\n\n\t// ── API Keys ──\n\t\"keys_title\":         \"🔐 API 密钥\",\n\t\"keys_help\":          \" [↑↓/jk] 导航 • [a] 添加 • [e] 编辑 • [d] 删除 • [c] 复制 • [r] 刷新\",\n\t\"no_keys\":            \"  无 API Key，按 [a] 添加\",\n\t\"access_keys\":        \"Access API Keys\",\n\t\"confirm_delete_key\": \"⚠ 确认删除 %s? [y/n]\",\n\t\"key_added\":          \"已添加 API Key\",\n\t\"key_updated\":        \"已更新 API Key\",\n\t\"key_deleted\":        \"已删除 API Key\",\n\t\"copied\":             \"✓ 已复制到剪贴板\",\n\t\"copy_failed\":        \"✗ 复制失败\",\n\t\"new_key_prompt\":     \"  New Key: \",\n\t\"edit_key_prompt\":    \"  Edit Key: \",\n\t\"enter_add\":          \"    Enter: 添加 • Esc: 取消\",\n\t\"enter_save_esc\":     \"    Enter: 保存 • Esc: 取消\",\n\n\t// ── OAuth ──\n\t\"oauth_title\":        \"🔐 OAuth 登录\",\n\t\"oauth_select\":       \"  选择提供商并按 [Enter] 开始 OAuth 登录:\",\n\t\"oauth_help\":         \"  [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态\",\n\t\"oauth_initiating\":   \"⏳ 正在初始化 %s 登录...\",\n\t\"oauth_success\":      \"认证成功! 请刷新 Auth Files 标签查看新凭证。\",\n\t\"oauth_completed\":    \"认证流程已完成。\",\n\t\"oauth_failed\":       \"认证失败\",\n\t\"oauth_timeout\":      \"OAuth 流程超时 (5 分钟)\",\n\t\"oauth_press_esc\":    \"  按 [Esc] 取消\",\n\t\"oauth_auth_url\":     \"  授权链接:\",\n\t\"oauth_remote_hint\":  \"  远程浏览器模式：在浏览器中打开上述链接完成授权后，将回调 URL 粘贴到下方。\",\n\t\"oauth_callback_url\": \"  回调 URL:\",\n\t\"oauth_press_c\":      \"  按 [c] 输入回调 URL • [Esc] 返回\",\n\t\"oauth_submitting\":   \"⏳ 提交回调中...\",\n\t\"oauth_submit_ok\":    \"✓ 回调已提交，等待处理...\",\n\t\"oauth_submit_fail\":  \"✗ 提交回调失败\",\n\t\"oauth_waiting\":      \"  等待认证中...\",\n\n\t// ── Usage ──\n\t\"usage_title\":         \"📈 使用统计\",\n\t\"usage_help\":          \" [r] 刷新 • [↑↓] 滚动\",\n\t\"usage_no_data\":       \"  使用数据不可用\",\n\t\"usage_total_reqs\":    \"总请求数\",\n\t\"usage_total_tokens\":  \"总 Token 数\",\n\t\"usage_success\":       \"成功\",\n\t\"usage_failure\":       \"失败\",\n\t\"usage_total_token_l\": \"总Token\",\n\t\"usage_rpm\":           \"RPM\",\n\t\"usage_tpm\":           \"TPM\",\n\t\"usage_req_by_hour\":   \"请求趋势 (按小时)\",\n\t\"usage_tok_by_hour\":   \"Token 使用趋势 (按小时)\",\n\t\"usage_req_by_day\":    \"请求趋势 (按天)\",\n\t\"usage_api_detail\":    \"API 详细统计\",\n\t\"usage_input\":         \"输入\",\n\t\"usage_output\":        \"输出\",\n\t\"usage_cached\":        \"缓存\",\n\t\"usage_reasoning\":     \"思考\",\n\n\t// ── Logs ──\n\t\"logs_title\":       \"📋 日志\",\n\t\"logs_auto_scroll\": \"● 自动滚动\",\n\t\"logs_paused\":      \"○ 已暂停\",\n\t\"logs_filter\":      \"过滤\",\n\t\"logs_lines\":       \"行数\",\n\t\"logs_help\":        \" [a] 自动滚动 • [c] 清除 • [1] 全部 [2] info+ [3] warn+ [4] error • [↑↓] 滚动\",\n\t\"logs_waiting\":     \"  等待日志输出...\",\n}\n\nvar enStrings = map[string]string{\n\t// ── Common ──\n\t\"loading\":      \"Loading...\",\n\t\"refresh\":      \"Refresh\",\n\t\"save\":         \"Save\",\n\t\"cancel\":       \"Cancel\",\n\t\"confirm\":      \"Confirm\",\n\t\"yes\":          \"Yes\",\n\t\"no\":           \"No\",\n\t\"error\":        \"Error\",\n\t\"success\":      \"Success\",\n\t\"navigate\":     \"Navigate\",\n\t\"scroll\":       \"Scroll\",\n\t\"enter_save\":   \"Enter: Save\",\n\t\"esc_cancel\":   \"Esc: Cancel\",\n\t\"enter_submit\": \"Enter: Submit\",\n\t\"press_r\":      \"[r] Refresh\",\n\t\"press_scroll\": \"[↑↓] Scroll\",\n\t\"not_set\":      \"(not set)\",\n\t\"error_prefix\": \"⚠ Error: \",\n\n\t// ── Status bar ──\n\t\"status_left\":                 \" CLIProxyAPI Management TUI\",\n\t\"status_right\":                \"Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit \",\n\t\"initializing_tui\":            \"Initializing...\",\n\t\"auth_gate_title\":             \"🔐 Connect Management API\",\n\t\"auth_gate_help\":              \" Enter management password and press Enter to connect\",\n\t\"auth_gate_password\":          \"Password\",\n\t\"auth_gate_enter\":             \" Enter: connect • q/Ctrl+C: quit • L: lang\",\n\t\"auth_gate_connecting\":        \"Connecting...\",\n\t\"auth_gate_connect_fail\":      \"Connection failed: %s\",\n\t\"auth_gate_password_required\": \"password is required\",\n\n\t// ── Dashboard ──\n\t\"dashboard_title\":  \"📊 Dashboard\",\n\t\"dashboard_help\":   \" [r] Refresh • [↑↓] Scroll\",\n\t\"connected\":        \"● Connected\",\n\t\"mgmt_keys\":        \"Mgmt Keys\",\n\t\"auth_files_label\": \"Auth Files\",\n\t\"active_suffix\":    \"active\",\n\t\"total_requests\":   \"Requests\",\n\t\"success_label\":    \"Success\",\n\t\"failure_label\":    \"Failed\",\n\t\"total_tokens\":     \"Total Tokens\",\n\t\"current_config\":   \"Current Config\",\n\t\"debug_mode\":       \"Debug Mode\",\n\t\"usage_stats\":      \"Usage Statistics\",\n\t\"log_to_file\":      \"Log to File\",\n\t\"retry_count\":      \"Retry Count\",\n\t\"proxy_url\":        \"Proxy URL\",\n\t\"routing_strategy\": \"Routing Strategy\",\n\t\"model_stats\":      \"Model Stats\",\n\t\"model\":            \"Model\",\n\t\"requests\":         \"Requests\",\n\t\"tokens\":           \"Tokens\",\n\t\"bool_yes\":         \"Yes ✓\",\n\t\"bool_no\":          \"No\",\n\n\t// ── Config ──\n\t\"config_title\":      \"⚙ Configuration\",\n\t\"config_help1\":      \"  [↑↓/jk] Navigate • [Enter/Space] Edit • [r] Refresh\",\n\t\"config_help2\":      \"  Bool: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel\",\n\t\"updated_ok\":        \"✓ Updated successfully\",\n\t\"no_config\":         \"  No configuration loaded\",\n\t\"invalid_int\":       \"invalid integer\",\n\t\"section_server\":    \"Server\",\n\t\"section_logging\":   \"Logging & Stats\",\n\t\"section_quota\":     \"Quota Exceeded Handling\",\n\t\"section_routing\":   \"Routing\",\n\t\"section_websocket\": \"WebSocket\",\n\t\"section_ampcode\":   \"AMP Code\",\n\t\"section_other\":     \"Other\",\n\n\t// ── Auth Files ──\n\t\"auth_title\":      \"🔑 Auth Files\",\n\t\"auth_help1\":      \" [↑↓/jk] Navigate • [Enter] Expand • [e] Enable/Disable • [d] Delete • [r] Refresh\",\n\t\"auth_help2\":      \" [1] Edit prefix • [2] Edit proxy_url • [3] Edit priority\",\n\t\"no_auth_files\":   \"  No auth files found\",\n\t\"confirm_delete\":  \"⚠ Delete %s? [y/n]\",\n\t\"deleted\":         \"Deleted %s\",\n\t\"enabled\":         \"Enabled\",\n\t\"disabled\":        \"Disabled\",\n\t\"updated_field\":   \"Updated %s on %s\",\n\t\"status_active\":   \"active\",\n\t\"status_disabled\": \"disabled\",\n\n\t// ── API Keys ──\n\t\"keys_title\":         \"🔐 API Keys\",\n\t\"keys_help\":          \" [↑↓/jk] Navigate • [a] Add • [e] Edit • [d] Delete • [c] Copy • [r] Refresh\",\n\t\"no_keys\":            \"  No API Keys. Press [a] to add\",\n\t\"access_keys\":        \"Access API Keys\",\n\t\"confirm_delete_key\": \"⚠ Delete %s? [y/n]\",\n\t\"key_added\":          \"API Key added\",\n\t\"key_updated\":        \"API Key updated\",\n\t\"key_deleted\":        \"API Key deleted\",\n\t\"copied\":             \"✓ Copied to clipboard\",\n\t\"copy_failed\":        \"✗ Copy failed\",\n\t\"new_key_prompt\":     \"  New Key: \",\n\t\"edit_key_prompt\":    \"  Edit Key: \",\n\t\"enter_add\":          \"    Enter: Add • Esc: Cancel\",\n\t\"enter_save_esc\":     \"    Enter: Save • Esc: Cancel\",\n\n\t// ── OAuth ──\n\t\"oauth_title\":        \"🔐 OAuth Login\",\n\t\"oauth_select\":       \"  Select a provider and press [Enter] to start OAuth login:\",\n\t\"oauth_help\":         \"  [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status\",\n\t\"oauth_initiating\":   \"⏳ Initiating %s login...\",\n\t\"oauth_success\":      \"Authentication successful! Refresh Auth Files tab to see the new credential.\",\n\t\"oauth_completed\":    \"Authentication flow completed.\",\n\t\"oauth_failed\":       \"Authentication failed\",\n\t\"oauth_timeout\":      \"OAuth flow timed out (5 minutes)\",\n\t\"oauth_press_esc\":    \"  Press [Esc] to cancel\",\n\t\"oauth_auth_url\":     \"  Authorization URL:\",\n\t\"oauth_remote_hint\":  \"  Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.\",\n\t\"oauth_callback_url\": \"  Callback URL:\",\n\t\"oauth_press_c\":      \"  Press [c] to enter callback URL • [Esc] to go back\",\n\t\"oauth_submitting\":   \"⏳ Submitting callback...\",\n\t\"oauth_submit_ok\":    \"✓ Callback submitted, waiting...\",\n\t\"oauth_submit_fail\":  \"✗ Callback submission failed\",\n\t\"oauth_waiting\":      \"  Waiting for authentication...\",\n\n\t// ── Usage ──\n\t\"usage_title\":         \"📈 Usage Statistics\",\n\t\"usage_help\":          \" [r] Refresh • [↑↓] Scroll\",\n\t\"usage_no_data\":       \"  Usage data not available\",\n\t\"usage_total_reqs\":    \"Total Requests\",\n\t\"usage_total_tokens\":  \"Total Tokens\",\n\t\"usage_success\":       \"Success\",\n\t\"usage_failure\":       \"Failed\",\n\t\"usage_total_token_l\": \"Total Tokens\",\n\t\"usage_rpm\":           \"RPM\",\n\t\"usage_tpm\":           \"TPM\",\n\t\"usage_req_by_hour\":   \"Requests by Hour\",\n\t\"usage_tok_by_hour\":   \"Token Usage by Hour\",\n\t\"usage_req_by_day\":    \"Requests by Day\",\n\t\"usage_api_detail\":    \"API Detail Statistics\",\n\t\"usage_input\":         \"Input\",\n\t\"usage_output\":        \"Output\",\n\t\"usage_cached\":        \"Cached\",\n\t\"usage_reasoning\":     \"Reasoning\",\n\n\t// ── Logs ──\n\t\"logs_title\":       \"📋 Logs\",\n\t\"logs_auto_scroll\": \"● AUTO-SCROLL\",\n\t\"logs_paused\":      \"○ PAUSED\",\n\t\"logs_filter\":      \"Filter\",\n\t\"logs_lines\":       \"Lines\",\n\t\"logs_help\":        \" [a] Auto-scroll • [c] Clear • [1] All [2] info+ [3] warn+ [4] error • [↑↓] Scroll\",\n\t\"logs_waiting\":     \"  Waiting for log output...\",\n}\n"
  },
  {
    "path": "internal/tui/keys_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/atotto/clipboard\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// keysTabModel displays and manages API keys.\ntype keysTabModel struct {\n\tclient   *Client\n\tviewport viewport.Model\n\tkeys     []string\n\tgemini   []map[string]any\n\tclaude   []map[string]any\n\tcodex    []map[string]any\n\tvertex   []map[string]any\n\topenai   []map[string]any\n\terr      error\n\twidth    int\n\theight   int\n\tready    bool\n\tcursor   int\n\tconfirm  int // -1 = no deletion pending\n\tstatus   string\n\n\t// Editing / Adding\n\tediting   bool\n\tadding    bool\n\teditIdx   int\n\teditInput textinput.Model\n}\n\ntype keysDataMsg struct {\n\tapiKeys []string\n\tgemini  []map[string]any\n\tclaude  []map[string]any\n\tcodex   []map[string]any\n\tvertex  []map[string]any\n\topenai  []map[string]any\n\terr     error\n}\n\ntype keyActionMsg struct {\n\taction string\n\terr    error\n}\n\nfunc newKeysTabModel(client *Client) keysTabModel {\n\tti := textinput.New()\n\tti.CharLimit = 512\n\tti.Prompt = \"  Key: \"\n\treturn keysTabModel{\n\t\tclient:    client,\n\t\tconfirm:   -1,\n\t\teditInput: ti,\n\t}\n}\n\nfunc (m keysTabModel) Init() tea.Cmd {\n\treturn m.fetchKeys\n}\n\nfunc (m keysTabModel) fetchKeys() tea.Msg {\n\tresult := keysDataMsg{}\n\tapiKeys, err := m.client.GetAPIKeys()\n\tif err != nil {\n\t\tresult.err = err\n\t\treturn result\n\t}\n\tresult.apiKeys = apiKeys\n\tresult.gemini, _ = m.client.GetGeminiKeys()\n\tresult.claude, _ = m.client.GetClaudeKeys()\n\tresult.codex, _ = m.client.GetCodexKeys()\n\tresult.vertex, _ = m.client.GetVertexKeys()\n\tresult.openai, _ = m.client.GetOpenAICompat()\n\treturn result\n}\n\nfunc (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase keysDataMsg:\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t} else {\n\t\t\tm.err = nil\n\t\t\tm.keys = msg.apiKeys\n\t\t\tm.gemini = msg.gemini\n\t\t\tm.claude = msg.claude\n\t\t\tm.codex = msg.codex\n\t\t\tm.vertex = msg.vertex\n\t\t\tm.openai = msg.openai\n\t\t\tif m.cursor >= len(m.keys) {\n\t\t\t\tm.cursor = max(0, len(m.keys)-1)\n\t\t\t}\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase keyActionMsg:\n\t\tif msg.err != nil {\n\t\t\tm.status = errorStyle.Render(\"✗ \" + msg.err.Error())\n\t\t} else {\n\t\t\tm.status = successStyle.Render(\"✓ \" + msg.action)\n\t\t}\n\t\tm.confirm = -1\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, m.fetchKeys\n\n\tcase tea.KeyMsg:\n\t\t// ---- Editing / Adding mode ----\n\t\tif m.editing || m.adding {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"enter\":\n\t\t\t\tvalue := strings.TrimSpace(m.editInput.Value())\n\t\t\t\tif value == \"\" {\n\t\t\t\t\tm.editing = false\n\t\t\t\t\tm.adding = false\n\t\t\t\t\tm.editInput.Blur()\n\t\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\t\t\t\tisAdding := m.adding\n\t\t\t\teditIdx := m.editIdx\n\t\t\t\tm.editing = false\n\t\t\t\tm.adding = false\n\t\t\t\tm.editInput.Blur()\n\t\t\t\tif isAdding {\n\t\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\t\terr := m.client.AddAPIKey(value)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn keyActionMsg{err: err}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn keyActionMsg{action: T(\"key_added\")}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\terr := m.client.EditAPIKey(editIdx, value)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn keyActionMsg{err: err}\n\t\t\t\t\t}\n\t\t\t\t\treturn keyActionMsg{action: T(\"key_updated\")}\n\t\t\t\t}\n\t\t\tcase \"esc\":\n\t\t\t\tm.editing = false\n\t\t\t\tm.adding = false\n\t\t\t\tm.editInput.Blur()\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, nil\n\t\t\tdefault:\n\t\t\t\tvar cmd tea.Cmd\n\t\t\t\tm.editInput, cmd = m.editInput.Update(msg)\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, cmd\n\t\t\t}\n\t\t}\n\n\t\t// ---- Delete confirmation ----\n\t\tif m.confirm >= 0 {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"y\", \"Y\":\n\t\t\t\tidx := m.confirm\n\t\t\t\tm.confirm = -1\n\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\terr := m.client.DeleteAPIKey(idx)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn keyActionMsg{err: err}\n\t\t\t\t\t}\n\t\t\t\t\treturn keyActionMsg{action: T(\"key_deleted\")}\n\t\t\t\t}\n\t\t\tcase \"n\", \"N\", \"esc\":\n\t\t\t\tm.confirm = -1\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\t// ---- Normal mode ----\n\t\tswitch msg.String() {\n\t\tcase \"j\", \"down\":\n\t\t\tif len(m.keys) > 0 {\n\t\t\t\tm.cursor = (m.cursor + 1) % len(m.keys)\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"k\", \"up\":\n\t\t\tif len(m.keys) > 0 {\n\t\t\t\tm.cursor = (m.cursor - 1 + len(m.keys)) % len(m.keys)\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"a\":\n\t\t\t// Add new key\n\t\t\tm.adding = true\n\t\t\tm.editing = false\n\t\t\tm.editInput.SetValue(\"\")\n\t\t\tm.editInput.Prompt = T(\"new_key_prompt\")\n\t\t\tm.editInput.Focus()\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\treturn m, textinput.Blink\n\t\tcase \"e\":\n\t\t\t// Edit selected key\n\t\t\tif m.cursor < len(m.keys) {\n\t\t\t\tm.editing = true\n\t\t\t\tm.adding = false\n\t\t\t\tm.editIdx = m.cursor\n\t\t\t\tm.editInput.SetValue(m.keys[m.cursor])\n\t\t\t\tm.editInput.Prompt = T(\"edit_key_prompt\")\n\t\t\t\tm.editInput.Focus()\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, textinput.Blink\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"d\":\n\t\t\t// Delete selected key\n\t\t\tif m.cursor < len(m.keys) {\n\t\t\t\tm.confirm = m.cursor\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"c\":\n\t\t\t// Copy selected key to clipboard\n\t\t\tif m.cursor < len(m.keys) {\n\t\t\t\tkey := m.keys[m.cursor]\n\t\t\t\tif err := clipboard.WriteAll(key); err != nil {\n\t\t\t\t\tm.status = errorStyle.Render(T(\"copy_failed\") + \": \" + err.Error())\n\t\t\t\t} else {\n\t\t\t\t\tm.status = successStyle.Render(T(\"copied\"))\n\t\t\t\t}\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"r\":\n\t\t\tm.status = \"\"\n\t\t\treturn m, m.fetchKeys\n\t\tdefault:\n\t\t\tvar cmd tea.Cmd\n\t\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\t\treturn m, cmd\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m *keysTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tm.editInput.Width = w - 16\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m keysTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m keysTabModel) renderContent() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"keys_title\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"keys_help\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(strings.Repeat(\"─\", m.width))\n\tsb.WriteString(\"\\n\")\n\n\tif m.err != nil {\n\t\tsb.WriteString(errorStyle.Render(T(\"error_prefix\") + m.err.Error()))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\t// ━━━ Access API Keys (interactive) ━━━\n\tsb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(\"  %s (%d)\", T(\"access_keys\"), len(m.keys))))\n\tsb.WriteString(\"\\n\")\n\n\tif len(m.keys) == 0 {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"no_keys\")))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tfor i, key := range m.keys {\n\t\tcursor := \"  \"\n\t\trowStyle := lipgloss.NewStyle()\n\t\tif i == m.cursor {\n\t\t\tcursor = \"▸ \"\n\t\t\trowStyle = lipgloss.NewStyle().Bold(true)\n\t\t}\n\n\t\trow := fmt.Sprintf(\"%s%d. %s\", cursor, i+1, maskKey(key))\n\t\tsb.WriteString(rowStyle.Render(row))\n\t\tsb.WriteString(\"\\n\")\n\n\t\t// Delete confirmation\n\t\tif m.confirm == i {\n\t\t\tsb.WriteString(warningStyle.Render(fmt.Sprintf(\"    \"+T(\"confirm_delete_key\"), maskKey(key))))\n\t\t\tsb.WriteString(\"\\n\")\n\t\t}\n\n\t\t// Edit input\n\t\tif m.editing && m.editIdx == i {\n\t\t\tsb.WriteString(m.editInput.View())\n\t\t\tsb.WriteString(\"\\n\")\n\t\t\tsb.WriteString(helpStyle.Render(T(\"enter_save_esc\")))\n\t\t\tsb.WriteString(\"\\n\")\n\t\t}\n\t}\n\n\t// Add input\n\tif m.adding {\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(m.editInput.View())\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(helpStyle.Render(T(\"enter_add\")))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tsb.WriteString(\"\\n\")\n\n\t// ━━━ Provider Keys (read-only display) ━━━\n\trenderProviderKeys(&sb, \"Gemini API Keys\", m.gemini)\n\trenderProviderKeys(&sb, \"Claude API Keys\", m.claude)\n\trenderProviderKeys(&sb, \"Codex API Keys\", m.codex)\n\trenderProviderKeys(&sb, \"Vertex API Keys\", m.vertex)\n\n\tif len(m.openai) > 0 {\n\t\trenderSection(&sb, \"OpenAI Compatibility\", len(m.openai))\n\t\tfor i, entry := range m.openai {\n\t\t\tname := getString(entry, \"name\")\n\t\t\tbaseURL := getString(entry, \"base-url\")\n\t\t\tprefix := getString(entry, \"prefix\")\n\t\t\tinfo := name\n\t\t\tif prefix != \"\" {\n\t\t\t\tinfo += \" (prefix: \" + prefix + \")\"\n\t\t\t}\n\t\t\tif baseURL != \"\" {\n\t\t\t\tinfo += \" → \" + baseURL\n\t\t\t}\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %d. %s\\n\", i+1, info))\n\t\t}\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tif m.status != \"\" {\n\t\tsb.WriteString(m.status)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\nfunc renderSection(sb *strings.Builder, title string, count int) {\n\theader := fmt.Sprintf(\"%s (%d)\", title, count)\n\tsb.WriteString(tableHeaderStyle.Render(\"  \" + header))\n\tsb.WriteString(\"\\n\")\n}\n\nfunc renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any) {\n\tif len(keys) == 0 {\n\t\treturn\n\t}\n\trenderSection(sb, title, len(keys))\n\tfor i, key := range keys {\n\t\tapiKey := getString(key, \"api-key\")\n\t\tprefix := getString(key, \"prefix\")\n\t\tbaseURL := getString(key, \"base-url\")\n\t\tinfo := maskKey(apiKey)\n\t\tif prefix != \"\" {\n\t\t\tinfo += \" (prefix: \" + prefix + \")\"\n\t\t}\n\t\tif baseURL != \"\" {\n\t\t\tinfo += \" → \" + baseURL\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"  %d. %s\\n\", i+1, info))\n\t}\n\tsb.WriteString(\"\\n\")\n}\n\nfunc maskKey(key string) string {\n\tif len(key) <= 8 {\n\t\treturn strings.Repeat(\"*\", len(key))\n\t}\n\treturn key[:4] + strings.Repeat(\"*\", len(key)-8) + key[len(key)-4:]\n}\n"
  },
  {
    "path": "internal/tui/loghook.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// LogHook is a logrus hook that captures log entries and sends them to a channel.\ntype LogHook struct {\n\tch        chan string\n\tformatter log.Formatter\n\tmu        sync.Mutex\n\tlevels    []log.Level\n}\n\n// NewLogHook creates a new LogHook with a buffered channel of the given size.\nfunc NewLogHook(bufSize int) *LogHook {\n\treturn &LogHook{\n\t\tch:        make(chan string, bufSize),\n\t\tformatter: &log.TextFormatter{DisableColors: true, FullTimestamp: true},\n\t\tlevels:    log.AllLevels,\n\t}\n}\n\n// SetFormatter sets a custom formatter for the hook.\nfunc (h *LogHook) SetFormatter(f log.Formatter) {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\th.formatter = f\n}\n\n// Levels returns the log levels this hook should fire on.\nfunc (h *LogHook) Levels() []log.Level {\n\treturn h.levels\n}\n\n// Fire is called by logrus when a log entry is fired.\nfunc (h *LogHook) Fire(entry *log.Entry) error {\n\th.mu.Lock()\n\tf := h.formatter\n\th.mu.Unlock()\n\n\tvar line string\n\tif f != nil {\n\t\tb, err := f.Format(entry)\n\t\tif err == nil {\n\t\t\tline = strings.TrimRight(string(b), \"\\n\\r\")\n\t\t} else {\n\t\t\tline = fmt.Sprintf(\"[%s] %s\", entry.Level, entry.Message)\n\t\t}\n\t} else {\n\t\tline = fmt.Sprintf(\"[%s] %s\", entry.Level, entry.Message)\n\t}\n\n\t// Non-blocking send\n\tselect {\n\tcase h.ch <- line:\n\tdefault:\n\t\t// Drop oldest if full\n\t\tselect {\n\t\tcase <-h.ch:\n\t\tdefault:\n\t\t}\n\t\tselect {\n\t\tcase h.ch <- line:\n\t\tdefault:\n\t\t}\n\t}\n\treturn nil\n}\n\n// Chan returns the channel to read log lines from.\nfunc (h *LogHook) Chan() <-chan string {\n\treturn h.ch\n}\n"
  },
  {
    "path": "internal/tui/logs_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\n// logsTabModel displays real-time log lines from hook/API source.\ntype logsTabModel struct {\n\tclient     *Client\n\thook       *LogHook\n\tviewport   viewport.Model\n\tlines      []string\n\tmaxLines   int\n\tautoScroll bool\n\twidth      int\n\theight     int\n\tready      bool\n\tfilter     string // \"\", \"debug\", \"info\", \"warn\", \"error\"\n\tafter      int64\n\tlastErr    error\n}\n\ntype logsPollMsg struct {\n\tlines  []string\n\tlatest int64\n\terr    error\n}\n\ntype logsTickMsg struct{}\ntype logLineMsg string\n\nfunc newLogsTabModel(client *Client, hook *LogHook) logsTabModel {\n\treturn logsTabModel{\n\t\tclient:     client,\n\t\thook:       hook,\n\t\tmaxLines:   5000,\n\t\tautoScroll: true,\n\t}\n}\n\nfunc (m logsTabModel) Init() tea.Cmd {\n\tif m.hook != nil {\n\t\treturn m.waitForLog\n\t}\n\treturn m.fetchLogs\n}\n\nfunc (m logsTabModel) fetchLogs() tea.Msg {\n\tlines, latest, err := m.client.GetLogs(m.after, 200)\n\treturn logsPollMsg{\n\t\tlines:  lines,\n\t\tlatest: latest,\n\t\terr:    err,\n\t}\n}\n\nfunc (m logsTabModel) waitForNextPoll() tea.Cmd {\n\treturn tea.Tick(2*time.Second, func(_ time.Time) tea.Msg {\n\t\treturn logsTickMsg{}\n\t})\n}\n\nfunc (m logsTabModel) waitForLog() tea.Msg {\n\tif m.hook == nil {\n\t\treturn nil\n\t}\n\tline, ok := <-m.hook.Chan()\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn logLineMsg(line)\n}\n\nfunc (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderLogs())\n\t\treturn m, nil\n\tcase logsTickMsg:\n\t\tif m.hook != nil {\n\t\t\treturn m, nil\n\t\t}\n\t\treturn m, m.fetchLogs\n\tcase logsPollMsg:\n\t\tif m.hook != nil {\n\t\t\treturn m, nil\n\t\t}\n\t\tif msg.err != nil {\n\t\t\tm.lastErr = msg.err\n\t\t} else {\n\t\t\tm.lastErr = nil\n\t\t\tm.after = msg.latest\n\t\t\tif len(msg.lines) > 0 {\n\t\t\t\tm.lines = append(m.lines, msg.lines...)\n\t\t\t\tif len(m.lines) > m.maxLines {\n\t\t\t\t\tm.lines = m.lines[len(m.lines)-m.maxLines:]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tm.viewport.SetContent(m.renderLogs())\n\t\tif m.autoScroll {\n\t\t\tm.viewport.GotoBottom()\n\t\t}\n\t\treturn m, m.waitForNextPoll()\n\tcase logLineMsg:\n\t\tm.lines = append(m.lines, string(msg))\n\t\tif len(m.lines) > m.maxLines {\n\t\t\tm.lines = m.lines[len(m.lines)-m.maxLines:]\n\t\t}\n\t\tm.viewport.SetContent(m.renderLogs())\n\t\tif m.autoScroll {\n\t\t\tm.viewport.GotoBottom()\n\t\t}\n\t\treturn m, m.waitForLog\n\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"a\":\n\t\t\tm.autoScroll = !m.autoScroll\n\t\t\tif m.autoScroll {\n\t\t\t\tm.viewport.GotoBottom()\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"c\":\n\t\t\tm.lines = nil\n\t\t\tm.lastErr = nil\n\t\t\tm.viewport.SetContent(m.renderLogs())\n\t\t\treturn m, nil\n\t\tcase \"1\":\n\t\t\tm.filter = \"\"\n\t\t\tm.viewport.SetContent(m.renderLogs())\n\t\t\treturn m, nil\n\t\tcase \"2\":\n\t\t\tm.filter = \"info\"\n\t\t\tm.viewport.SetContent(m.renderLogs())\n\t\t\treturn m, nil\n\t\tcase \"3\":\n\t\t\tm.filter = \"warn\"\n\t\t\tm.viewport.SetContent(m.renderLogs())\n\t\t\treturn m, nil\n\t\tcase \"4\":\n\t\t\tm.filter = \"error\"\n\t\t\tm.viewport.SetContent(m.renderLogs())\n\t\t\treturn m, nil\n\t\tdefault:\n\t\t\twasAtBottom := m.viewport.AtBottom()\n\t\t\tvar cmd tea.Cmd\n\t\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\t\t// If user scrolls up, disable auto-scroll\n\t\t\tif !m.viewport.AtBottom() && wasAtBottom {\n\t\t\t\tm.autoScroll = false\n\t\t\t}\n\t\t\t// If user scrolls to bottom, re-enable auto-scroll\n\t\t\tif m.viewport.AtBottom() {\n\t\t\t\tm.autoScroll = true\n\t\t\t}\n\t\t\treturn m, cmd\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m *logsTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderLogs())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m logsTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m logsTabModel) renderLogs() string {\n\tvar sb strings.Builder\n\n\tscrollStatus := successStyle.Render(T(\"logs_auto_scroll\"))\n\tif !m.autoScroll {\n\t\tscrollStatus = warningStyle.Render(T(\"logs_paused\"))\n\t}\n\tfilterLabel := \"ALL\"\n\tif m.filter != \"\" {\n\t\tfilterLabel = strings.ToUpper(m.filter) + \"+\"\n\t}\n\n\theader := fmt.Sprintf(\" %s  %s  %s: %s  %s: %d\",\n\t\tT(\"logs_title\"), scrollStatus, T(\"logs_filter\"), filterLabel, T(\"logs_lines\"), len(m.lines))\n\tsb.WriteString(titleStyle.Render(header))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"logs_help\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(strings.Repeat(\"─\", m.width))\n\tsb.WriteString(\"\\n\")\n\n\tif m.lastErr != nil {\n\t\tsb.WriteString(errorStyle.Render(\"⚠ Error: \" + m.lastErr.Error()))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tif len(m.lines) == 0 {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"logs_waiting\")))\n\t\treturn sb.String()\n\t}\n\n\tfor _, line := range m.lines {\n\t\tif m.filter != \"\" && !m.matchLevel(line) {\n\t\t\tcontinue\n\t\t}\n\t\tstyled := m.styleLine(line)\n\t\tsb.WriteString(styled)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\nfunc (m logsTabModel) matchLevel(line string) bool {\n\tswitch m.filter {\n\tcase \"error\":\n\t\treturn strings.Contains(line, \"[error]\") || strings.Contains(line, \"[fatal]\") || strings.Contains(line, \"[panic]\")\n\tcase \"warn\":\n\t\treturn strings.Contains(line, \"[warn\") || strings.Contains(line, \"[error]\") || strings.Contains(line, \"[fatal]\")\n\tcase \"info\":\n\t\treturn !strings.Contains(line, \"[debug]\")\n\tdefault:\n\t\treturn true\n\t}\n}\n\nfunc (m logsTabModel) styleLine(line string) string {\n\tif strings.Contains(line, \"[error]\") || strings.Contains(line, \"[fatal]\") {\n\t\treturn logErrorStyle.Render(line)\n\t}\n\tif strings.Contains(line, \"[warn\") {\n\t\treturn logWarnStyle.Render(line)\n\t}\n\tif strings.Contains(line, \"[info\") {\n\t\treturn logInfoStyle.Render(line)\n\t}\n\tif strings.Contains(line, \"[debug]\") {\n\t\treturn logDebugStyle.Render(line)\n\t}\n\treturn line\n}\n"
  },
  {
    "path": "internal/tui/oauth_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// oauthProvider represents an OAuth provider option.\ntype oauthProvider struct {\n\tname    string\n\tapiPath string // management API path\n\temoji   string\n}\n\nvar oauthProviders = []oauthProvider{\n\t{\"Gemini CLI\", \"gemini-cli-auth-url\", \"🟦\"},\n\t{\"Claude (Anthropic)\", \"anthropic-auth-url\", \"🟧\"},\n\t{\"Codex (OpenAI)\", \"codex-auth-url\", \"🟩\"},\n\t{\"Antigravity\", \"antigravity-auth-url\", \"🟪\"},\n\t{\"Qwen\", \"qwen-auth-url\", \"🟨\"},\n\t{\"Kimi\", \"kimi-auth-url\", \"🟫\"},\n\t{\"IFlow\", \"iflow-auth-url\", \"⬜\"},\n}\n\n// oauthTabModel handles OAuth login flows.\ntype oauthTabModel struct {\n\tclient   *Client\n\tviewport viewport.Model\n\tcursor   int\n\tstate    oauthState\n\tmessage  string\n\terr      error\n\twidth    int\n\theight   int\n\tready    bool\n\n\t// Remote browser mode\n\tauthURL       string // auth URL to display\n\tauthState     string // OAuth state parameter\n\tproviderName  string // current provider name\n\tcallbackInput textinput.Model\n\tinputActive   bool // true when user is typing callback URL\n}\n\ntype oauthState int\n\nconst (\n\toauthIdle oauthState = iota\n\toauthPending\n\toauthRemote // remote browser mode: waiting for manual callback\n\toauthSuccess\n\toauthError\n)\n\n// Messages\ntype oauthStartMsg struct {\n\turl          string\n\tstate        string\n\tproviderName string\n\terr          error\n}\n\ntype oauthPollMsg struct {\n\tdone    bool\n\tmessage string\n\terr     error\n}\n\ntype oauthCallbackSubmitMsg struct {\n\terr error\n}\n\nfunc newOAuthTabModel(client *Client) oauthTabModel {\n\tti := textinput.New()\n\tti.Placeholder = \"http://localhost:.../auth/callback?code=...&state=...\"\n\tti.CharLimit = 2048\n\tti.Prompt = \"  回调 URL: \"\n\treturn oauthTabModel{\n\t\tclient:        client,\n\t\tcallbackInput: ti,\n\t}\n}\n\nfunc (m oauthTabModel) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase oauthStartMsg:\n\t\tif msg.err != nil {\n\t\t\tm.state = oauthError\n\t\t\tm.err = msg.err\n\t\t\tm.message = errorStyle.Render(\"✗ \" + msg.err.Error())\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\treturn m, nil\n\t\t}\n\t\tm.authURL = msg.url\n\t\tm.authState = msg.state\n\t\tm.providerName = msg.providerName\n\t\tm.state = oauthRemote\n\t\tm.callbackInput.SetValue(\"\")\n\t\tm.callbackInput.Focus()\n\t\tm.inputActive = true\n\t\tm.message = \"\"\n\t\tm.viewport.SetContent(m.renderContent())\n\t\t// Also start polling in the background\n\t\treturn m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state))\n\n\tcase oauthPollMsg:\n\t\tif msg.err != nil {\n\t\t\tm.state = oauthError\n\t\t\tm.err = msg.err\n\t\t\tm.message = errorStyle.Render(\"✗ \" + msg.err.Error())\n\t\t\tm.inputActive = false\n\t\t\tm.callbackInput.Blur()\n\t\t} else if msg.done {\n\t\t\tm.state = oauthSuccess\n\t\t\tm.message = successStyle.Render(\"✓ \" + msg.message)\n\t\t\tm.inputActive = false\n\t\t\tm.callbackInput.Blur()\n\t\t} else {\n\t\t\tm.message = warningStyle.Render(\"⏳ \" + msg.message)\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase oauthCallbackSubmitMsg:\n\t\tif msg.err != nil {\n\t\t\tm.message = errorStyle.Render(T(\"oauth_submit_fail\") + \": \" + msg.err.Error())\n\t\t} else {\n\t\t\tm.message = successStyle.Render(T(\"oauth_submit_ok\"))\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\t// ---- Input active: typing callback URL ----\n\t\tif m.inputActive {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"enter\":\n\t\t\t\tcallbackURL := m.callbackInput.Value()\n\t\t\t\tif callbackURL == \"\" {\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\t\t\t\tm.inputActive = false\n\t\t\t\tm.callbackInput.Blur()\n\t\t\t\tm.message = warningStyle.Render(T(\"oauth_submitting\"))\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, m.submitCallback(callbackURL)\n\t\t\tcase \"esc\":\n\t\t\t\tm.inputActive = false\n\t\t\t\tm.callbackInput.Blur()\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, nil\n\t\t\tdefault:\n\t\t\t\tvar cmd tea.Cmd\n\t\t\t\tm.callbackInput, cmd = m.callbackInput.Update(msg)\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, cmd\n\t\t\t}\n\t\t}\n\n\t\t// ---- Remote mode but not typing ----\n\t\tif m.state == oauthRemote {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"c\", \"C\":\n\t\t\t\t// Re-activate input\n\t\t\t\tm.inputActive = true\n\t\t\t\tm.callbackInput.Focus()\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, textinput.Blink\n\t\t\tcase \"esc\":\n\t\t\t\tm.state = oauthIdle\n\t\t\t\tm.message = \"\"\n\t\t\t\tm.authURL = \"\"\n\t\t\t\tm.authState = \"\"\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tvar cmd tea.Cmd\n\t\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\t\treturn m, cmd\n\t\t}\n\n\t\t// ---- Pending (auto polling) ----\n\t\tif m.state == oauthPending {\n\t\t\tif msg.String() == \"esc\" {\n\t\t\t\tm.state = oauthIdle\n\t\t\t\tm.message = \"\"\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\t// ---- Idle ----\n\t\tswitch msg.String() {\n\t\tcase \"up\", \"k\":\n\t\t\tif m.cursor > 0 {\n\t\t\t\tm.cursor--\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"down\", \"j\":\n\t\t\tif m.cursor < len(oauthProviders)-1 {\n\t\t\t\tm.cursor++\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"enter\":\n\t\t\tif m.cursor >= 0 && m.cursor < len(oauthProviders) {\n\t\t\t\tprovider := oauthProviders[m.cursor]\n\t\t\t\tm.state = oauthPending\n\t\t\t\tm.message = warningStyle.Render(fmt.Sprintf(T(\"oauth_initiating\"), provider.name))\n\t\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\t\treturn m, m.startOAuth(provider)\n\t\t\t}\n\t\t\treturn m, nil\n\t\tcase \"esc\":\n\t\t\tm.state = oauthIdle\n\t\t\tm.message = \"\"\n\t\t\tm.err = nil\n\t\t\tm.viewport.SetContent(m.renderContent())\n\t\t\treturn m, nil\n\t\t}\n\n\t\tvar cmd tea.Cmd\n\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\treturn m, cmd\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd {\n\treturn func() tea.Msg {\n\t\t// Call the auth URL endpoint with is_webui=true\n\t\tdata, err := m.client.getJSON(\"/v0/management/\" + provider.apiPath + \"?is_webui=true\")\n\t\tif err != nil {\n\t\t\treturn oauthStartMsg{err: fmt.Errorf(\"failed to start %s login: %w\", provider.name, err)}\n\t\t}\n\n\t\tauthURL := getString(data, \"url\")\n\t\tstate := getString(data, \"state\")\n\t\tif authURL == \"\" {\n\t\t\treturn oauthStartMsg{err: fmt.Errorf(\"no auth URL returned for %s\", provider.name)}\n\t\t}\n\n\t\t// Try to open browser (best effort)\n\t\t_ = openBrowser(authURL)\n\n\t\treturn oauthStartMsg{url: authURL, state: state, providerName: provider.name}\n\t}\n}\n\nfunc (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\t// Determine provider from current context\n\t\tproviderKey := \"\"\n\t\tfor _, p := range oauthProviders {\n\t\t\tif p.name == m.providerName {\n\t\t\t\t// Map provider name to the canonical key the API expects\n\t\t\t\tswitch p.apiPath {\n\t\t\t\tcase \"gemini-cli-auth-url\":\n\t\t\t\t\tproviderKey = \"gemini\"\n\t\t\t\tcase \"anthropic-auth-url\":\n\t\t\t\t\tproviderKey = \"anthropic\"\n\t\t\t\tcase \"codex-auth-url\":\n\t\t\t\t\tproviderKey = \"codex\"\n\t\t\t\tcase \"antigravity-auth-url\":\n\t\t\t\t\tproviderKey = \"antigravity\"\n\t\t\t\tcase \"qwen-auth-url\":\n\t\t\t\t\tproviderKey = \"qwen\"\n\t\t\t\tcase \"kimi-auth-url\":\n\t\t\t\t\tproviderKey = \"kimi\"\n\t\t\t\tcase \"iflow-auth-url\":\n\t\t\t\t\tproviderKey = \"iflow\"\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tbody := map[string]string{\n\t\t\t\"provider\":     providerKey,\n\t\t\t\"redirect_url\": callbackURL,\n\t\t\t\"state\":        m.authState,\n\t\t}\n\t\terr := m.client.postJSON(\"/v0/management/oauth-callback\", body)\n\t\tif err != nil {\n\t\t\treturn oauthCallbackSubmitMsg{err: err}\n\t\t}\n\t\treturn oauthCallbackSubmitMsg{}\n\t}\n}\n\nfunc (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\t// Poll session status for up to 5 minutes\n\t\tdeadline := time.Now().Add(5 * time.Minute)\n\t\tfor {\n\t\t\tif time.Now().After(deadline) {\n\t\t\t\treturn oauthPollMsg{done: false, err: fmt.Errorf(\"%s\", T(\"oauth_timeout\"))}\n\t\t\t}\n\n\t\t\ttime.Sleep(2 * time.Second)\n\n\t\t\tstatus, errMsg, err := m.client.GetAuthStatus(state)\n\t\t\tif err != nil {\n\t\t\t\tcontinue // Ignore transient errors\n\t\t\t}\n\n\t\t\tswitch status {\n\t\t\tcase \"ok\":\n\t\t\t\treturn oauthPollMsg{\n\t\t\t\t\tdone:    true,\n\t\t\t\t\tmessage: T(\"oauth_success\"),\n\t\t\t\t}\n\t\t\tcase \"error\":\n\t\t\t\treturn oauthPollMsg{\n\t\t\t\t\tdone: false,\n\t\t\t\t\terr:  fmt.Errorf(\"%s: %s\", T(\"oauth_failed\"), errMsg),\n\t\t\t\t}\n\t\t\tcase \"wait\":\n\t\t\t\tcontinue\n\t\t\tdefault:\n\t\t\t\treturn oauthPollMsg{\n\t\t\t\t\tdone:    true,\n\t\t\t\t\tmessage: T(\"oauth_completed\"),\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (m *oauthTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tm.callbackInput.Width = w - 16\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m oauthTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m oauthTabModel) renderContent() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"oauth_title\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\tif m.message != \"\" {\n\t\tsb.WriteString(\"  \" + m.message)\n\t\tsb.WriteString(\"\\n\\n\")\n\t}\n\n\t// ---- Remote browser mode ----\n\tif m.state == oauthRemote {\n\t\tsb.WriteString(m.renderRemoteMode())\n\t\treturn sb.String()\n\t}\n\n\tif m.state == oauthPending {\n\t\tsb.WriteString(helpStyle.Render(T(\"oauth_press_esc\")))\n\t\treturn sb.String()\n\t}\n\n\tsb.WriteString(helpStyle.Render(T(\"oauth_select\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\tfor i, p := range oauthProviders {\n\t\tisSelected := i == m.cursor\n\t\tprefix := \"  \"\n\t\tif isSelected {\n\t\t\tprefix = \"▸ \"\n\t\t}\n\n\t\tlabel := fmt.Sprintf(\"%s %s\", p.emoji, p.name)\n\t\tif isSelected {\n\t\t\tlabel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"#FFFFFF\")).Background(colorPrimary).Padding(0, 1).Render(label)\n\t\t} else {\n\t\t\tlabel = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label)\n\t\t}\n\n\t\tsb.WriteString(prefix + label + \"\\n\")\n\t}\n\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"oauth_help\")))\n\n\treturn sb.String()\n}\n\nfunc (m oauthTabModel) renderRemoteMode() string {\n\tvar sb strings.Builder\n\n\tproviderStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight)\n\tsb.WriteString(providerStyle.Render(fmt.Sprintf(\"  ✦ %s OAuth\", m.providerName)))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// Auth URL section\n\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T(\"oauth_auth_url\")))\n\tsb.WriteString(\"\\n\")\n\n\t// Wrap URL to fit terminal width\n\turlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(\"252\"))\n\tmaxURLWidth := m.width - 6\n\tif maxURLWidth < 40 {\n\t\tmaxURLWidth = 40\n\t}\n\twrappedURL := wrapText(m.authURL, maxURLWidth)\n\tfor _, line := range wrappedURL {\n\t\tsb.WriteString(\"  \" + urlStyle.Render(line) + \"\\n\")\n\t}\n\tsb.WriteString(\"\\n\")\n\n\tsb.WriteString(helpStyle.Render(T(\"oauth_remote_hint\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// Callback URL input\n\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T(\"oauth_callback_url\")))\n\tsb.WriteString(\"\\n\")\n\n\tif m.inputActive {\n\t\tsb.WriteString(m.callbackInput.View())\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(helpStyle.Render(\"  \" + T(\"enter_submit\") + \" • \" + T(\"esc_cancel\")))\n\t} else {\n\t\tsb.WriteString(helpStyle.Render(T(\"oauth_press_c\")))\n\t}\n\n\tsb.WriteString(\"\\n\\n\")\n\tsb.WriteString(warningStyle.Render(T(\"oauth_waiting\")))\n\n\treturn sb.String()\n}\n\n// wrapText splits a long string into lines of at most maxWidth characters.\nfunc wrapText(s string, maxWidth int) []string {\n\tif maxWidth <= 0 {\n\t\treturn []string{s}\n\t}\n\tvar lines []string\n\tfor len(s) > maxWidth {\n\t\tlines = append(lines, s[:maxWidth])\n\t\ts = s[maxWidth:]\n\t}\n\tif len(s) > 0 {\n\t\tlines = append(lines, s)\n\t}\n\treturn lines\n}\n"
  },
  {
    "path": "internal/tui/styles.go",
    "content": "// Package tui provides a terminal-based management interface for CLIProxyAPI.\npackage tui\n\nimport \"github.com/charmbracelet/lipgloss\"\n\n// Color palette\nvar (\n\tcolorPrimary   = lipgloss.Color(\"#7C3AED\") // violet\n\tcolorSecondary = lipgloss.Color(\"#6366F1\") // indigo\n\tcolorSuccess   = lipgloss.Color(\"#22C55E\") // green\n\tcolorWarning   = lipgloss.Color(\"#EAB308\") // yellow\n\tcolorError     = lipgloss.Color(\"#EF4444\") // red\n\tcolorInfo      = lipgloss.Color(\"#3B82F6\") // blue\n\tcolorMuted     = lipgloss.Color(\"#6B7280\") // gray\n\tcolorBg        = lipgloss.Color(\"#1E1E2E\") // dark bg\n\tcolorSurface   = lipgloss.Color(\"#313244\") // slightly lighter\n\tcolorText      = lipgloss.Color(\"#CDD6F4\") // light text\n\tcolorSubtext   = lipgloss.Color(\"#A6ADC8\") // dimmer text\n\tcolorBorder    = lipgloss.Color(\"#45475A\") // border\n\tcolorHighlight = lipgloss.Color(\"#F5C2E7\") // pink highlight\n)\n\n// Tab bar styles\nvar (\n\ttabActiveStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(lipgloss.Color(\"#FFFFFF\")).\n\t\t\tBackground(colorPrimary).\n\t\t\tPadding(0, 2)\n\n\ttabInactiveStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(colorSubtext).\n\t\t\t\tBackground(colorSurface).\n\t\t\t\tPadding(0, 2)\n\n\ttabBarStyle = lipgloss.NewStyle().\n\t\t\tBackground(colorSurface).\n\t\t\tPaddingLeft(1).\n\t\t\tPaddingBottom(0)\n)\n\n// Content styles\nvar (\n\ttitleStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(colorHighlight).\n\t\t\tMarginBottom(1)\n\n\tsubtitleStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorSubtext).\n\t\t\tItalic(true)\n\n\tlabelStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorInfo).\n\t\t\tBold(true).\n\t\t\tWidth(24)\n\n\tvalueStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorText)\n\n\tsectionStyle = lipgloss.NewStyle().\n\t\t\tBorder(lipgloss.RoundedBorder()).\n\t\t\tBorderForeground(colorBorder).\n\t\t\tPadding(1, 2)\n\n\terrorStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorError).\n\t\t\tBold(true)\n\n\tsuccessStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorSuccess)\n\n\twarningStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorWarning)\n\n\tstatusBarStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorSubtext).\n\t\t\tBackground(colorSurface).\n\t\t\tPaddingLeft(1).\n\t\t\tPaddingRight(1)\n\n\thelpStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorMuted)\n)\n\n// Log level styles\nvar (\n\tlogDebugStyle = lipgloss.NewStyle().Foreground(colorMuted)\n\tlogInfoStyle  = lipgloss.NewStyle().Foreground(colorInfo)\n\tlogWarnStyle  = lipgloss.NewStyle().Foreground(colorWarning)\n\tlogErrorStyle = lipgloss.NewStyle().Foreground(colorError)\n)\n\n// Table styles\nvar (\n\ttableHeaderStyle = lipgloss.NewStyle().\n\t\t\t\tBold(true).\n\t\t\t\tForeground(colorHighlight).\n\t\t\t\tBorderBottom(true).\n\t\t\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\t\t\tBorderForeground(colorBorder)\n\n\ttableCellStyle = lipgloss.NewStyle().\n\t\t\tForeground(colorText).\n\t\t\tPaddingRight(2)\n\n\ttableSelectedStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(lipgloss.Color(\"#FFFFFF\")).\n\t\t\t\tBackground(colorPrimary).\n\t\t\t\tBold(true)\n)\n\nfunc logLevelStyle(level string) lipgloss.Style {\n\tswitch level {\n\tcase \"debug\":\n\t\treturn logDebugStyle\n\tcase \"info\":\n\t\treturn logInfoStyle\n\tcase \"warn\", \"warning\":\n\t\treturn logWarnStyle\n\tcase \"error\", \"fatal\", \"panic\":\n\t\treturn logErrorStyle\n\tdefault:\n\t\treturn logInfoStyle\n\t}\n}\n"
  },
  {
    "path": "internal/tui/usage_tab.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// usageTabModel displays usage statistics with charts and breakdowns.\ntype usageTabModel struct {\n\tclient   *Client\n\tviewport viewport.Model\n\tusage    map[string]any\n\terr      error\n\twidth    int\n\theight   int\n\tready    bool\n}\n\ntype usageDataMsg struct {\n\tusage map[string]any\n\terr   error\n}\n\nfunc newUsageTabModel(client *Client) usageTabModel {\n\treturn usageTabModel{\n\t\tclient: client,\n\t}\n}\n\nfunc (m usageTabModel) Init() tea.Cmd {\n\treturn m.fetchData\n}\n\nfunc (m usageTabModel) fetchData() tea.Msg {\n\tusage, err := m.client.GetUsage()\n\treturn usageDataMsg{usage: usage, err: err}\n}\n\nfunc (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase localeChangedMsg:\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\tcase usageDataMsg:\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t} else {\n\t\t\tm.err = nil\n\t\t\tm.usage = msg.usage\n\t\t}\n\t\tm.viewport.SetContent(m.renderContent())\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\tif msg.String() == \"r\" {\n\t\t\treturn m, m.fetchData\n\t\t}\n\t\tvar cmd tea.Cmd\n\t\tm.viewport, cmd = m.viewport.Update(msg)\n\t\treturn m, cmd\n\t}\n\n\tvar cmd tea.Cmd\n\tm.viewport, cmd = m.viewport.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m *usageTabModel) SetSize(w, h int) {\n\tm.width = w\n\tm.height = h\n\tif !m.ready {\n\t\tm.viewport = viewport.New(w, h)\n\t\tm.viewport.SetContent(m.renderContent())\n\t\tm.ready = true\n\t} else {\n\t\tm.viewport.Width = w\n\t\tm.viewport.Height = h\n\t}\n}\n\nfunc (m usageTabModel) View() string {\n\tif !m.ready {\n\t\treturn T(\"loading\")\n\t}\n\treturn m.viewport.View()\n}\n\nfunc (m usageTabModel) renderContent() string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(titleStyle.Render(T(\"usage_title\")))\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(helpStyle.Render(T(\"usage_help\")))\n\tsb.WriteString(\"\\n\\n\")\n\n\tif m.err != nil {\n\t\tsb.WriteString(errorStyle.Render(\"⚠ Error: \" + m.err.Error()))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\tif m.usage == nil {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"usage_no_data\")))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\tusageMap, _ := m.usage[\"usage\"].(map[string]any)\n\tif usageMap == nil {\n\t\tsb.WriteString(subtitleStyle.Render(T(\"usage_no_data\")))\n\t\tsb.WriteString(\"\\n\")\n\t\treturn sb.String()\n\t}\n\n\ttotalReqs := int64(getFloat(usageMap, \"total_requests\"))\n\tsuccessCnt := int64(getFloat(usageMap, \"success_count\"))\n\tfailureCnt := int64(getFloat(usageMap, \"failure_count\"))\n\ttotalTokens := int64(getFloat(usageMap, \"total_tokens\"))\n\n\t// ━━━ Overview Cards ━━━\n\tcardWidth := 20\n\tif m.width > 0 {\n\t\tcardWidth = (m.width - 6) / 4\n\t\tif cardWidth < 16 {\n\t\t\tcardWidth = 16\n\t\t}\n\t}\n\tcardStyle := lipgloss.NewStyle().\n\t\tBorder(lipgloss.RoundedBorder()).\n\t\tBorderForeground(lipgloss.Color(\"240\")).\n\t\tPadding(0, 1).\n\t\tWidth(cardWidth).\n\t\tHeight(3)\n\n\t// Total Requests\n\tcard1 := cardStyle.Copy().BorderForeground(lipgloss.Color(\"111\")).Render(fmt.Sprintf(\n\t\t\"%s\\n%s\\n%s\",\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"usage_total_reqs\")),\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"111\")).Render(fmt.Sprintf(\"%d\", totalReqs)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"● %s: %d  ● %s: %d\", T(\"usage_success\"), successCnt, T(\"usage_failure\"), failureCnt)),\n\t))\n\n\t// Total Tokens\n\tcard2 := cardStyle.Copy().BorderForeground(lipgloss.Color(\"214\")).Render(fmt.Sprintf(\n\t\t\"%s\\n%s\\n%s\",\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"usage_total_tokens\")),\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"214\")).Render(formatLargeNumber(totalTokens)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%s: %s\", T(\"usage_total_token_l\"), formatLargeNumber(totalTokens))),\n\t))\n\n\t// RPM\n\trpm := float64(0)\n\tif totalReqs > 0 {\n\t\tif rByH, ok := usageMap[\"requests_by_hour\"].(map[string]any); ok && len(rByH) > 0 {\n\t\t\trpm = float64(totalReqs) / float64(len(rByH)) / 60.0\n\t\t}\n\t}\n\tcard3 := cardStyle.Copy().BorderForeground(lipgloss.Color(\"76\")).Render(fmt.Sprintf(\n\t\t\"%s\\n%s\\n%s\",\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"usage_rpm\")),\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"76\")).Render(fmt.Sprintf(\"%.2f\", rpm)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%s: %d\", T(\"usage_total_reqs\"), totalReqs)),\n\t))\n\n\t// TPM\n\ttpm := float64(0)\n\tif totalTokens > 0 {\n\t\tif tByH, ok := usageMap[\"tokens_by_hour\"].(map[string]any); ok && len(tByH) > 0 {\n\t\t\ttpm = float64(totalTokens) / float64(len(tByH)) / 60.0\n\t\t}\n\t}\n\tcard4 := cardStyle.Copy().BorderForeground(lipgloss.Color(\"170\")).Render(fmt.Sprintf(\n\t\t\"%s\\n%s\\n%s\",\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(T(\"usage_tpm\")),\n\t\tlipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"170\")).Render(fmt.Sprintf(\"%.2f\", tpm)),\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%s: %s\", T(\"usage_total_tokens\"), formatLargeNumber(totalTokens))),\n\t))\n\n\tsb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, \" \", card2, \" \", card3, \" \", card4))\n\tsb.WriteString(\"\\n\\n\")\n\n\t// ━━━ Requests by Hour (ASCII bar chart) ━━━\n\tif rByH, ok := usageMap[\"requests_by_hour\"].(map[string]any); ok && len(rByH) > 0 {\n\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"usage_req_by_hour\")))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 60)))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color(\"111\")))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\t// ━━━ Tokens by Hour ━━━\n\tif tByH, ok := usageMap[\"tokens_by_hour\"].(map[string]any); ok && len(tByH) > 0 {\n\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"usage_tok_by_hour\")))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 60)))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color(\"214\")))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\t// ━━━ Requests by Day ━━━\n\tif rByD, ok := usageMap[\"requests_by_day\"].(map[string]any); ok && len(rByD) > 0 {\n\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"usage_req_by_day\")))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 60)))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color(\"76\")))\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\t// ━━━ API Detail Stats ━━━\n\tif apis, ok := usageMap[\"apis\"].(map[string]any); ok && len(apis) > 0 {\n\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T(\"usage_api_detail\")))\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(strings.Repeat(\"─\", minInt(m.width, 80)))\n\t\tsb.WriteString(\"\\n\")\n\n\t\theader := fmt.Sprintf(\"  %-30s %10s %12s\", \"API\", T(\"requests\"), T(\"tokens\"))\n\t\tsb.WriteString(tableHeaderStyle.Render(header))\n\t\tsb.WriteString(\"\\n\")\n\n\t\tfor apiName, apiSnap := range apis {\n\t\t\tif apiMap, ok := apiSnap.(map[string]any); ok {\n\t\t\t\tapiReqs := int64(getFloat(apiMap, \"total_requests\"))\n\t\t\t\tapiToks := int64(getFloat(apiMap, \"total_tokens\"))\n\n\t\t\t\trow := fmt.Sprintf(\"  %-30s %10d %12s\",\n\t\t\t\t\ttruncate(maskKey(apiName), 30), apiReqs, formatLargeNumber(apiToks))\n\t\t\t\tsb.WriteString(lipgloss.NewStyle().Bold(true).Render(row))\n\t\t\t\tsb.WriteString(\"\\n\")\n\n\t\t\t\t// Per-model breakdown\n\t\t\t\tif models, ok := apiMap[\"models\"].(map[string]any); ok {\n\t\t\t\t\tfor model, v := range models {\n\t\t\t\t\t\tif stats, ok := v.(map[string]any); ok {\n\t\t\t\t\t\t\tmReqs := int64(getFloat(stats, \"total_requests\"))\n\t\t\t\t\t\t\tmToks := int64(getFloat(stats, \"total_tokens\"))\n\t\t\t\t\t\t\tmRow := fmt.Sprintf(\"    ├─ %-28s %10d %12s\",\n\t\t\t\t\t\t\t\ttruncate(model, 28), mReqs, formatLargeNumber(mToks))\n\t\t\t\t\t\t\tsb.WriteString(tableCellStyle.Render(mRow))\n\t\t\t\t\t\t\tsb.WriteString(\"\\n\")\n\n\t\t\t\t\t\t\t// Token type breakdown from details\n\t\t\t\t\t\t\tsb.WriteString(m.renderTokenBreakdown(stats))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tsb.WriteString(\"\\n\")\n\treturn sb.String()\n}\n\n// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details.\nfunc (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string {\n\tdetails, ok := modelStats[\"details\"]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tdetailList, ok := details.([]any)\n\tif !ok || len(detailList) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar inputTotal, outputTotal, cachedTotal, reasoningTotal int64\n\tfor _, d := range detailList {\n\t\tdm, ok := d.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\ttokens, ok := dm[\"tokens\"].(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tinputTotal += int64(getFloat(tokens, \"input_tokens\"))\n\t\toutputTotal += int64(getFloat(tokens, \"output_tokens\"))\n\t\tcachedTotal += int64(getFloat(tokens, \"cached_tokens\"))\n\t\treasoningTotal += int64(getFloat(tokens, \"reasoning_tokens\"))\n\t}\n\n\tif inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 {\n\t\treturn \"\"\n\t}\n\n\tparts := []string{}\n\tif inputTotal > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%s:%s\", T(\"usage_input\"), formatLargeNumber(inputTotal)))\n\t}\n\tif outputTotal > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%s:%s\", T(\"usage_output\"), formatLargeNumber(outputTotal)))\n\t}\n\tif cachedTotal > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%s:%s\", T(\"usage_cached\"), formatLargeNumber(cachedTotal)))\n\t}\n\tif reasoningTotal > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%s:%s\", T(\"usage_reasoning\"), formatLargeNumber(reasoningTotal)))\n\t}\n\n\treturn fmt.Sprintf(\"    │  %s\\n\",\n\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, \"  \")))\n}\n\n// renderBarChart renders a simple ASCII horizontal bar chart.\nfunc renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string {\n\tif maxBarWidth < 10 {\n\t\tmaxBarWidth = 10\n\t}\n\n\t// Sort keys\n\tkeys := make([]string, 0, len(data))\n\tfor k := range data {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\n\t// Find max value\n\tmaxVal := float64(0)\n\tfor _, k := range keys {\n\t\tv := getFloat(data, k)\n\t\tif v > maxVal {\n\t\t\tmaxVal = v\n\t\t}\n\t}\n\tif maxVal == 0 {\n\t\treturn \"\"\n\t}\n\n\tbarStyle := lipgloss.NewStyle().Foreground(barColor)\n\tvar sb strings.Builder\n\n\tlabelWidth := 12\n\tbarAvail := maxBarWidth - labelWidth - 12\n\tif barAvail < 5 {\n\t\tbarAvail = 5\n\t}\n\n\tfor _, k := range keys {\n\t\tv := getFloat(data, k)\n\t\tbarLen := int(v / maxVal * float64(barAvail))\n\t\tif barLen < 1 && v > 0 {\n\t\t\tbarLen = 1\n\t\t}\n\t\tbar := strings.Repeat(\"█\", barLen)\n\t\tlabel := k\n\t\tif len(label) > labelWidth {\n\t\t\tlabel = label[:labelWidth]\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"  %-*s %s %s\\n\",\n\t\t\tlabelWidth, label,\n\t\t\tbarStyle.Render(bar),\n\t\t\tlipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf(\"%.0f\", v)),\n\t\t))\n\t}\n\n\treturn sb.String()\n}\n"
  },
  {
    "path": "internal/usage/logger_plugin.go",
    "content": "// Package usage provides usage tracking and logging functionality for the CLI Proxy API server.\n// It includes plugins for monitoring API usage, token consumption, and other metrics\n// to help with observability and billing purposes.\npackage usage\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\tcoreusage \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage\"\n)\n\nvar statisticsEnabled atomic.Bool\n\nfunc init() {\n\tstatisticsEnabled.Store(true)\n\tcoreusage.RegisterPlugin(NewLoggerPlugin())\n}\n\n// LoggerPlugin collects in-memory request statistics for usage analysis.\n// It implements coreusage.Plugin to receive usage records emitted by the runtime.\ntype LoggerPlugin struct {\n\tstats *RequestStatistics\n}\n\n// NewLoggerPlugin constructs a new logger plugin instance.\n//\n// Returns:\n//   - *LoggerPlugin: A new logger plugin instance wired to the shared statistics store.\nfunc NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultRequestStatistics} }\n\n// HandleUsage implements coreusage.Plugin.\n// It updates the in-memory statistics store whenever a usage record is received.\n//\n// Parameters:\n//   - ctx: The context for the usage record\n//   - record: The usage record to aggregate\nfunc (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) {\n\tif !statisticsEnabled.Load() {\n\t\treturn\n\t}\n\tif p == nil || p.stats == nil {\n\t\treturn\n\t}\n\tp.stats.Record(ctx, record)\n}\n\n// SetStatisticsEnabled toggles whether in-memory statistics are recorded.\nfunc SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) }\n\n// StatisticsEnabled reports the current recording state.\nfunc StatisticsEnabled() bool { return statisticsEnabled.Load() }\n\n// RequestStatistics maintains aggregated request metrics in memory.\ntype RequestStatistics struct {\n\tmu sync.RWMutex\n\n\ttotalRequests int64\n\tsuccessCount  int64\n\tfailureCount  int64\n\ttotalTokens   int64\n\n\tapis map[string]*apiStats\n\n\trequestsByDay  map[string]int64\n\trequestsByHour map[int]int64\n\ttokensByDay    map[string]int64\n\ttokensByHour   map[int]int64\n}\n\n// apiStats holds aggregated metrics for a single API key.\ntype apiStats struct {\n\tTotalRequests int64\n\tTotalTokens   int64\n\tModels        map[string]*modelStats\n}\n\n// modelStats holds aggregated metrics for a specific model within an API.\ntype modelStats struct {\n\tTotalRequests int64\n\tTotalTokens   int64\n\tDetails       []RequestDetail\n}\n\n// RequestDetail stores the timestamp and token usage for a single request.\ntype RequestDetail struct {\n\tTimestamp time.Time  `json:\"timestamp\"`\n\tSource    string     `json:\"source\"`\n\tAuthIndex string     `json:\"auth_index\"`\n\tTokens    TokenStats `json:\"tokens\"`\n\tFailed    bool       `json:\"failed\"`\n}\n\n// TokenStats captures the token usage breakdown for a request.\ntype TokenStats struct {\n\tInputTokens     int64 `json:\"input_tokens\"`\n\tOutputTokens    int64 `json:\"output_tokens\"`\n\tReasoningTokens int64 `json:\"reasoning_tokens\"`\n\tCachedTokens    int64 `json:\"cached_tokens\"`\n\tTotalTokens     int64 `json:\"total_tokens\"`\n}\n\n// StatisticsSnapshot represents an immutable view of the aggregated metrics.\ntype StatisticsSnapshot struct {\n\tTotalRequests int64 `json:\"total_requests\"`\n\tSuccessCount  int64 `json:\"success_count\"`\n\tFailureCount  int64 `json:\"failure_count\"`\n\tTotalTokens   int64 `json:\"total_tokens\"`\n\n\tAPIs map[string]APISnapshot `json:\"apis\"`\n\n\tRequestsByDay  map[string]int64 `json:\"requests_by_day\"`\n\tRequestsByHour map[string]int64 `json:\"requests_by_hour\"`\n\tTokensByDay    map[string]int64 `json:\"tokens_by_day\"`\n\tTokensByHour   map[string]int64 `json:\"tokens_by_hour\"`\n}\n\n// APISnapshot summarises metrics for a single API key.\ntype APISnapshot struct {\n\tTotalRequests int64                    `json:\"total_requests\"`\n\tTotalTokens   int64                    `json:\"total_tokens\"`\n\tModels        map[string]ModelSnapshot `json:\"models\"`\n}\n\n// ModelSnapshot summarises metrics for a specific model.\ntype ModelSnapshot struct {\n\tTotalRequests int64           `json:\"total_requests\"`\n\tTotalTokens   int64           `json:\"total_tokens\"`\n\tDetails       []RequestDetail `json:\"details\"`\n}\n\nvar defaultRequestStatistics = NewRequestStatistics()\n\n// GetRequestStatistics returns the shared statistics store.\nfunc GetRequestStatistics() *RequestStatistics { return defaultRequestStatistics }\n\n// NewRequestStatistics constructs an empty statistics store.\nfunc NewRequestStatistics() *RequestStatistics {\n\treturn &RequestStatistics{\n\t\tapis:           make(map[string]*apiStats),\n\t\trequestsByDay:  make(map[string]int64),\n\t\trequestsByHour: make(map[int]int64),\n\t\ttokensByDay:    make(map[string]int64),\n\t\ttokensByHour:   make(map[int]int64),\n\t}\n}\n\n// Record ingests a new usage record and updates the aggregates.\nfunc (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) {\n\tif s == nil {\n\t\treturn\n\t}\n\tif !statisticsEnabled.Load() {\n\t\treturn\n\t}\n\ttimestamp := record.RequestedAt\n\tif timestamp.IsZero() {\n\t\ttimestamp = time.Now()\n\t}\n\tdetail := normaliseDetail(record.Detail)\n\ttotalTokens := detail.TotalTokens\n\tstatsKey := record.APIKey\n\tif statsKey == \"\" {\n\t\tstatsKey = resolveAPIIdentifier(ctx, record)\n\t}\n\tfailed := record.Failed\n\tif !failed {\n\t\tfailed = !resolveSuccess(ctx)\n\t}\n\tsuccess := !failed\n\tmodelName := record.Model\n\tif modelName == \"\" {\n\t\tmodelName = \"unknown\"\n\t}\n\tdayKey := timestamp.Format(\"2006-01-02\")\n\thourKey := timestamp.Hour()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.totalRequests++\n\tif success {\n\t\ts.successCount++\n\t} else {\n\t\ts.failureCount++\n\t}\n\ts.totalTokens += totalTokens\n\n\tstats, ok := s.apis[statsKey]\n\tif !ok {\n\t\tstats = &apiStats{Models: make(map[string]*modelStats)}\n\t\ts.apis[statsKey] = stats\n\t}\n\ts.updateAPIStats(stats, modelName, RequestDetail{\n\t\tTimestamp: timestamp,\n\t\tSource:    record.Source,\n\t\tAuthIndex: record.AuthIndex,\n\t\tTokens:    detail,\n\t\tFailed:    failed,\n\t})\n\n\ts.requestsByDay[dayKey]++\n\ts.requestsByHour[hourKey]++\n\ts.tokensByDay[dayKey] += totalTokens\n\ts.tokensByHour[hourKey] += totalTokens\n}\n\nfunc (s *RequestStatistics) updateAPIStats(stats *apiStats, model string, detail RequestDetail) {\n\tstats.TotalRequests++\n\tstats.TotalTokens += detail.Tokens.TotalTokens\n\tmodelStatsValue, ok := stats.Models[model]\n\tif !ok {\n\t\tmodelStatsValue = &modelStats{}\n\t\tstats.Models[model] = modelStatsValue\n\t}\n\tmodelStatsValue.TotalRequests++\n\tmodelStatsValue.TotalTokens += detail.Tokens.TotalTokens\n\tmodelStatsValue.Details = append(modelStatsValue.Details, detail)\n}\n\n// Snapshot returns a copy of the aggregated metrics for external consumption.\nfunc (s *RequestStatistics) Snapshot() StatisticsSnapshot {\n\tresult := StatisticsSnapshot{}\n\tif s == nil {\n\t\treturn result\n\t}\n\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tresult.TotalRequests = s.totalRequests\n\tresult.SuccessCount = s.successCount\n\tresult.FailureCount = s.failureCount\n\tresult.TotalTokens = s.totalTokens\n\n\tresult.APIs = make(map[string]APISnapshot, len(s.apis))\n\tfor apiName, stats := range s.apis {\n\t\tapiSnapshot := APISnapshot{\n\t\t\tTotalRequests: stats.TotalRequests,\n\t\t\tTotalTokens:   stats.TotalTokens,\n\t\t\tModels:        make(map[string]ModelSnapshot, len(stats.Models)),\n\t\t}\n\t\tfor modelName, modelStatsValue := range stats.Models {\n\t\t\trequestDetails := make([]RequestDetail, len(modelStatsValue.Details))\n\t\t\tcopy(requestDetails, modelStatsValue.Details)\n\t\t\tapiSnapshot.Models[modelName] = ModelSnapshot{\n\t\t\t\tTotalRequests: modelStatsValue.TotalRequests,\n\t\t\t\tTotalTokens:   modelStatsValue.TotalTokens,\n\t\t\t\tDetails:       requestDetails,\n\t\t\t}\n\t\t}\n\t\tresult.APIs[apiName] = apiSnapshot\n\t}\n\n\tresult.RequestsByDay = make(map[string]int64, len(s.requestsByDay))\n\tfor k, v := range s.requestsByDay {\n\t\tresult.RequestsByDay[k] = v\n\t}\n\n\tresult.RequestsByHour = make(map[string]int64, len(s.requestsByHour))\n\tfor hour, v := range s.requestsByHour {\n\t\tkey := formatHour(hour)\n\t\tresult.RequestsByHour[key] = v\n\t}\n\n\tresult.TokensByDay = make(map[string]int64, len(s.tokensByDay))\n\tfor k, v := range s.tokensByDay {\n\t\tresult.TokensByDay[k] = v\n\t}\n\n\tresult.TokensByHour = make(map[string]int64, len(s.tokensByHour))\n\tfor hour, v := range s.tokensByHour {\n\t\tkey := formatHour(hour)\n\t\tresult.TokensByHour[key] = v\n\t}\n\n\treturn result\n}\n\ntype MergeResult struct {\n\tAdded   int64 `json:\"added\"`\n\tSkipped int64 `json:\"skipped\"`\n}\n\n// MergeSnapshot merges an exported statistics snapshot into the current store.\n// Existing data is preserved and duplicate request details are skipped.\nfunc (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot) MergeResult {\n\tresult := MergeResult{}\n\tif s == nil {\n\t\treturn result\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tseen := make(map[string]struct{})\n\tfor apiName, stats := range s.apis {\n\t\tif stats == nil {\n\t\t\tcontinue\n\t\t}\n\t\tfor modelName, modelStatsValue := range stats.Models {\n\t\t\tif modelStatsValue == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, detail := range modelStatsValue.Details {\n\t\t\t\tseen[dedupKey(apiName, modelName, detail)] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor apiName, apiSnapshot := range snapshot.APIs {\n\t\tapiName = strings.TrimSpace(apiName)\n\t\tif apiName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tstats, ok := s.apis[apiName]\n\t\tif !ok || stats == nil {\n\t\t\tstats = &apiStats{Models: make(map[string]*modelStats)}\n\t\t\ts.apis[apiName] = stats\n\t\t} else if stats.Models == nil {\n\t\t\tstats.Models = make(map[string]*modelStats)\n\t\t}\n\t\tfor modelName, modelSnapshot := range apiSnapshot.Models {\n\t\t\tmodelName = strings.TrimSpace(modelName)\n\t\t\tif modelName == \"\" {\n\t\t\t\tmodelName = \"unknown\"\n\t\t\t}\n\t\t\tfor _, detail := range modelSnapshot.Details {\n\t\t\t\tdetail.Tokens = normaliseTokenStats(detail.Tokens)\n\t\t\t\tif detail.Timestamp.IsZero() {\n\t\t\t\t\tdetail.Timestamp = time.Now()\n\t\t\t\t}\n\t\t\t\tkey := dedupKey(apiName, modelName, detail)\n\t\t\t\tif _, exists := seen[key]; exists {\n\t\t\t\t\tresult.Skipped++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tseen[key] = struct{}{}\n\t\t\t\ts.recordImported(apiName, modelName, stats, detail)\n\t\t\t\tresult.Added++\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (s *RequestStatistics) recordImported(apiName, modelName string, stats *apiStats, detail RequestDetail) {\n\ttotalTokens := detail.Tokens.TotalTokens\n\tif totalTokens < 0 {\n\t\ttotalTokens = 0\n\t}\n\n\ts.totalRequests++\n\tif detail.Failed {\n\t\ts.failureCount++\n\t} else {\n\t\ts.successCount++\n\t}\n\ts.totalTokens += totalTokens\n\n\ts.updateAPIStats(stats, modelName, detail)\n\n\tdayKey := detail.Timestamp.Format(\"2006-01-02\")\n\thourKey := detail.Timestamp.Hour()\n\n\ts.requestsByDay[dayKey]++\n\ts.requestsByHour[hourKey]++\n\ts.tokensByDay[dayKey] += totalTokens\n\ts.tokensByHour[hourKey] += totalTokens\n}\n\nfunc dedupKey(apiName, modelName string, detail RequestDetail) string {\n\ttimestamp := detail.Timestamp.UTC().Format(time.RFC3339Nano)\n\ttokens := normaliseTokenStats(detail.Tokens)\n\treturn fmt.Sprintf(\n\t\t\"%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d\",\n\t\tapiName,\n\t\tmodelName,\n\t\ttimestamp,\n\t\tdetail.Source,\n\t\tdetail.AuthIndex,\n\t\tdetail.Failed,\n\t\ttokens.InputTokens,\n\t\ttokens.OutputTokens,\n\t\ttokens.ReasoningTokens,\n\t\ttokens.CachedTokens,\n\t\ttokens.TotalTokens,\n\t)\n}\n\nfunc resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string {\n\tif ctx != nil {\n\t\tif ginCtx, ok := ctx.Value(\"gin\").(*gin.Context); ok && ginCtx != nil {\n\t\t\tpath := ginCtx.FullPath()\n\t\t\tif path == \"\" && ginCtx.Request != nil {\n\t\t\t\tpath = ginCtx.Request.URL.Path\n\t\t\t}\n\t\t\tmethod := \"\"\n\t\t\tif ginCtx.Request != nil {\n\t\t\t\tmethod = ginCtx.Request.Method\n\t\t\t}\n\t\t\tif path != \"\" {\n\t\t\t\tif method != \"\" {\n\t\t\t\t\treturn method + \" \" + path\n\t\t\t\t}\n\t\t\t\treturn path\n\t\t\t}\n\t\t}\n\t}\n\tif record.Provider != \"\" {\n\t\treturn record.Provider\n\t}\n\treturn \"unknown\"\n}\n\nfunc resolveSuccess(ctx context.Context) bool {\n\tif ctx == nil {\n\t\treturn true\n\t}\n\tginCtx, ok := ctx.Value(\"gin\").(*gin.Context)\n\tif !ok || ginCtx == nil {\n\t\treturn true\n\t}\n\tstatus := ginCtx.Writer.Status()\n\tif status == 0 {\n\t\treturn true\n\t}\n\treturn status < httpStatusBadRequest\n}\n\nconst httpStatusBadRequest = 400\n\nfunc normaliseDetail(detail coreusage.Detail) TokenStats {\n\ttokens := TokenStats{\n\t\tInputTokens:     detail.InputTokens,\n\t\tOutputTokens:    detail.OutputTokens,\n\t\tReasoningTokens: detail.ReasoningTokens,\n\t\tCachedTokens:    detail.CachedTokens,\n\t\tTotalTokens:     detail.TotalTokens,\n\t}\n\tif tokens.TotalTokens == 0 {\n\t\ttokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens\n\t}\n\tif tokens.TotalTokens == 0 {\n\t\ttokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens + detail.CachedTokens\n\t}\n\treturn tokens\n}\n\nfunc normaliseTokenStats(tokens TokenStats) TokenStats {\n\tif tokens.TotalTokens == 0 {\n\t\ttokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens\n\t}\n\tif tokens.TotalTokens == 0 {\n\t\ttokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens\n\t}\n\treturn tokens\n}\n\nfunc formatHour(hour int) string {\n\tif hour < 0 {\n\t\thour = 0\n\t}\n\thour = hour % 24\n\treturn fmt.Sprintf(\"%02d\", hour)\n}\n"
  },
  {
    "path": "internal/util/claude_model.go",
    "content": "package util\n\nimport \"strings\"\n\n// IsClaudeThinkingModel checks if the model is a Claude thinking model\n// that requires the interleaved-thinking beta header.\nfunc IsClaudeThinkingModel(model string) bool {\n\tlower := strings.ToLower(model)\n\treturn strings.Contains(lower, \"claude\") && strings.Contains(lower, \"thinking\")\n}\n"
  },
  {
    "path": "internal/util/claude_model_test.go",
    "content": "package util\n\nimport \"testing\"\n\nfunc TestIsClaudeThinkingModel(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tmodel    string\n\t\texpected bool\n\t}{\n\t\t// Claude thinking models - should return true\n\t\t{\"claude-sonnet-4-5-thinking\", \"claude-sonnet-4-5-thinking\", true},\n\t\t{\"claude-opus-4-5-thinking\", \"claude-opus-4-5-thinking\", true},\n\t\t{\"claude-opus-4-6-thinking\", \"claude-opus-4-6-thinking\", true},\n\t\t{\"Claude-Sonnet-Thinking uppercase\", \"Claude-Sonnet-4-5-Thinking\", true},\n\t\t{\"claude thinking mixed case\", \"Claude-THINKING-Model\", true},\n\n\t\t// Non-thinking Claude models - should return false\n\t\t{\"claude-sonnet-4-5 (no thinking)\", \"claude-sonnet-4-5\", false},\n\t\t{\"claude-opus-4-5 (no thinking)\", \"claude-opus-4-5\", false},\n\t\t{\"claude-3-5-sonnet\", \"claude-3-5-sonnet-20240620\", false},\n\n\t\t// Non-Claude models - should return false\n\t\t{\"gemini-3-pro-preview\", \"gemini-3-pro-preview\", false},\n\t\t{\"gemini-thinking model\", \"gemini-3-pro-thinking\", false}, // not Claude\n\t\t{\"gpt-4o\", \"gpt-4o\", false},\n\t\t{\"empty string\", \"\", false},\n\n\t\t// Edge cases\n\t\t{\"thinking without claude\", \"thinking-model\", false},\n\t\t{\"claude without thinking\", \"claude-model\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := IsClaudeThinkingModel(tt.model)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsClaudeThinkingModel(%q) = %v, expected %v\", tt.model, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/util/claude_tool_id.go",
    "content": "package util\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\nvar (\n\tclaudeToolUseIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`)\n\tclaudeToolUseIDCounter   uint64\n)\n\n// SanitizeClaudeToolID ensures the given id conforms to Claude's\n// tool_use.id regex ^[a-zA-Z0-9_-]+$.  Non-conforming characters are\n// replaced with '_'; an empty result gets a generated fallback.\nfunc SanitizeClaudeToolID(id string) string {\n\ts := claudeToolUseIDSanitizer.ReplaceAllString(id, \"_\")\n\tif s == \"\" {\n\t\ts = fmt.Sprintf(\"toolu_%d_%d\", time.Now().UnixNano(), atomic.AddUint64(&claudeToolUseIDCounter, 1))\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "internal/util/gemini_schema.go",
    "content": "// Package util provides utility functions for the CLI Proxy API server.\npackage util\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nvar gjsonPathKeyReplacer = strings.NewReplacer(\".\", \"\\\\.\", \"*\", \"\\\\*\", \"?\", \"\\\\?\")\n\nconst placeholderReasonDescription = \"Brief explanation of why you are calling this tool\"\n\n// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API.\n// It handles unsupported keywords, type flattening, and schema simplification while preserving\n// semantic information as description hints.\nfunc CleanJSONSchemaForAntigravity(jsonStr string) string {\n\treturn cleanJSONSchema(jsonStr, true)\n}\n\n// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling.\n// It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders.\nfunc CleanJSONSchemaForGemini(jsonStr string) string {\n\treturn cleanJSONSchema(jsonStr, false)\n}\n\n// cleanJSONSchema performs the core cleaning operations on the JSON schema.\nfunc cleanJSONSchema(jsonStr string, addPlaceholder bool) string {\n\t// Phase 1: Convert and add hints\n\tjsonStr = convertRefsToHints(jsonStr)\n\tjsonStr = convertConstToEnum(jsonStr)\n\tjsonStr = convertEnumValuesToStrings(jsonStr)\n\tjsonStr = addEnumHints(jsonStr)\n\tjsonStr = addAdditionalPropertiesHints(jsonStr)\n\tjsonStr = moveConstraintsToDescription(jsonStr)\n\n\t// Phase 2: Flatten complex structures\n\tjsonStr = mergeAllOf(jsonStr)\n\tjsonStr = flattenAnyOfOneOf(jsonStr)\n\tjsonStr = flattenTypeArrays(jsonStr)\n\n\t// Phase 3: Cleanup\n\tjsonStr = removeUnsupportedKeywords(jsonStr)\n\tif !addPlaceholder {\n\t\t// Gemini schema cleanup: remove nullable/title and placeholder-only fields.\n\t\tjsonStr = removeKeywords(jsonStr, []string{\"nullable\", \"title\"})\n\t\tjsonStr = removePlaceholderFields(jsonStr)\n\t}\n\tjsonStr = cleanupRequiredFields(jsonStr)\n\t// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)\n\tif addPlaceholder {\n\t\tjsonStr = addEmptySchemaPlaceholder(jsonStr)\n\t}\n\n\treturn jsonStr\n}\n\n// removeKeywords removes all occurrences of specified keywords from the JSON schema.\nfunc removeKeywords(jsonStr string, keywords []string) string {\n\tdeletePaths := make([]string, 0)\n\tpathsByField := findPathsByFields(jsonStr, keywords)\n\tfor _, key := range keywords {\n\t\tfor _, p := range pathsByField[key] {\n\t\t\tif isPropertyDefinition(trimSuffix(p, \".\"+key)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletePaths = append(deletePaths, p)\n\t\t}\n\t}\n\tsortByDepth(deletePaths)\n\tfor _, p := range deletePaths {\n\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t}\n\treturn jsonStr\n}\n\n// removePlaceholderFields removes placeholder-only properties (\"_\" and \"reason\") and their required entries.\nfunc removePlaceholderFields(jsonStr string) string {\n\t// Remove \"_\" placeholder properties.\n\tpaths := findPaths(jsonStr, \"_\")\n\tsortByDepth(paths)\n\tfor _, p := range paths {\n\t\tif !strings.HasSuffix(p, \".properties._\") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t\tparentPath := trimSuffix(p, \".properties._\")\n\t\treqPath := joinPath(parentPath, \"required\")\n\t\treq := gjson.Get(jsonStr, reqPath)\n\t\tif req.IsArray() {\n\t\t\tvar filtered []string\n\t\t\tfor _, r := range req.Array() {\n\t\t\t\tif r.String() != \"_\" {\n\t\t\t\t\tfiltered = append(filtered, r.String())\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(filtered) == 0 {\n\t\t\t\tjsonStr, _ = sjson.Delete(jsonStr, reqPath)\n\t\t\t} else {\n\t\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Remove placeholder-only \"reason\" objects.\n\treasonPaths := findPaths(jsonStr, \"reason\")\n\tsortByDepth(reasonPaths)\n\tfor _, p := range reasonPaths {\n\t\tif !strings.HasSuffix(p, \".properties.reason\") {\n\t\t\tcontinue\n\t\t}\n\t\tparentPath := trimSuffix(p, \".properties.reason\")\n\t\tprops := gjson.Get(jsonStr, joinPath(parentPath, \"properties\"))\n\t\tif !props.IsObject() || len(props.Map()) != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tdesc := gjson.Get(jsonStr, p+\".description\").String()\n\t\tif desc != placeholderReasonDescription {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t\treqPath := joinPath(parentPath, \"required\")\n\t\treq := gjson.Get(jsonStr, reqPath)\n\t\tif req.IsArray() {\n\t\t\tvar filtered []string\n\t\t\tfor _, r := range req.Array() {\n\t\t\t\tif r.String() != \"reason\" {\n\t\t\t\t\tfiltered = append(filtered, r.String())\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(filtered) == 0 {\n\t\t\t\tjsonStr, _ = sjson.Delete(jsonStr, reqPath)\n\t\t\t} else {\n\t\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn jsonStr\n}\n\n// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).\nfunc convertRefsToHints(jsonStr string) string {\n\tpaths := findPaths(jsonStr, \"$ref\")\n\tsortByDepth(paths)\n\n\tfor _, p := range paths {\n\t\trefVal := gjson.Get(jsonStr, p).String()\n\t\tdefName := refVal\n\t\tif idx := strings.LastIndex(refVal, \"/\"); idx >= 0 {\n\t\t\tdefName = refVal[idx+1:]\n\t\t}\n\n\t\tparentPath := trimSuffix(p, \".$ref\")\n\t\thint := fmt.Sprintf(\"See: %s\", defName)\n\t\tif existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != \"\" {\n\t\t\thint = fmt.Sprintf(\"%s (%s)\", existing, hint)\n\t\t}\n\n\t\treplacement := `{\"type\":\"object\",\"description\":\"\"}`\n\t\treplacement, _ = sjson.Set(replacement, \"description\", hint)\n\t\tjsonStr = setRawAt(jsonStr, parentPath, replacement)\n\t}\n\treturn jsonStr\n}\n\nfunc convertConstToEnum(jsonStr string) string {\n\tfor _, p := range findPaths(jsonStr, \"const\") {\n\t\tval := gjson.Get(jsonStr, p)\n\t\tif !val.Exists() {\n\t\t\tcontinue\n\t\t}\n\t\tenumPath := trimSuffix(p, \".const\") + \".enum\"\n\t\tif !gjson.Get(jsonStr, enumPath).Exists() {\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, enumPath, []interface{}{val.Value()})\n\t\t}\n\t}\n\treturn jsonStr\n}\n\n// convertEnumValuesToStrings ensures all enum values are strings and the schema type is set to string.\n// Gemini API requires enum values to be of type string, not numbers or booleans.\nfunc convertEnumValuesToStrings(jsonStr string) string {\n\tfor _, p := range findPaths(jsonStr, \"enum\") {\n\t\tarr := gjson.Get(jsonStr, p)\n\t\tif !arr.IsArray() {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar stringVals []string\n\t\tfor _, item := range arr.Array() {\n\t\t\tstringVals = append(stringVals, item.String())\n\t\t}\n\n\t\t// Always update enum values to strings and set type to \"string\"\n\t\t// This ensures compatibility with Antigravity Gemini which only allows enum for STRING type\n\t\tjsonStr, _ = sjson.Set(jsonStr, p, stringVals)\n\t\tparentPath := trimSuffix(p, \".enum\")\n\t\tjsonStr, _ = sjson.Set(jsonStr, joinPath(parentPath, \"type\"), \"string\")\n\t}\n\treturn jsonStr\n}\n\nfunc addEnumHints(jsonStr string) string {\n\tfor _, p := range findPaths(jsonStr, \"enum\") {\n\t\tarr := gjson.Get(jsonStr, p)\n\t\tif !arr.IsArray() {\n\t\t\tcontinue\n\t\t}\n\t\titems := arr.Array()\n\t\tif len(items) <= 1 || len(items) > 10 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar vals []string\n\t\tfor _, item := range items {\n\t\t\tvals = append(vals, item.String())\n\t\t}\n\t\tjsonStr = appendHint(jsonStr, trimSuffix(p, \".enum\"), \"Allowed: \"+strings.Join(vals, \", \"))\n\t}\n\treturn jsonStr\n}\n\nfunc addAdditionalPropertiesHints(jsonStr string) string {\n\tfor _, p := range findPaths(jsonStr, \"additionalProperties\") {\n\t\tif gjson.Get(jsonStr, p).Type == gjson.False {\n\t\t\tjsonStr = appendHint(jsonStr, trimSuffix(p, \".additionalProperties\"), \"No extra properties allowed\")\n\t\t}\n\t}\n\treturn jsonStr\n}\n\nvar unsupportedConstraints = []string{\n\t\"minLength\", \"maxLength\", \"exclusiveMinimum\", \"exclusiveMaximum\",\n\t\"pattern\", \"minItems\", \"maxItems\", \"format\",\n\t\"default\", \"examples\", // Claude rejects these in VALIDATED mode\n}\n\nfunc moveConstraintsToDescription(jsonStr string) string {\n\tpathsByField := findPathsByFields(jsonStr, unsupportedConstraints)\n\tfor _, key := range unsupportedConstraints {\n\t\tfor _, p := range pathsByField[key] {\n\t\t\tval := gjson.Get(jsonStr, p)\n\t\t\tif !val.Exists() || val.IsObject() || val.IsArray() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparentPath := trimSuffix(p, \".\"+key)\n\t\t\tif isPropertyDefinition(parentPath) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tjsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf(\"%s: %s\", key, val.String()))\n\t\t}\n\t}\n\treturn jsonStr\n}\n\nfunc mergeAllOf(jsonStr string) string {\n\tpaths := findPaths(jsonStr, \"allOf\")\n\tsortByDepth(paths)\n\n\tfor _, p := range paths {\n\t\tallOf := gjson.Get(jsonStr, p)\n\t\tif !allOf.IsArray() {\n\t\t\tcontinue\n\t\t}\n\t\tparentPath := trimSuffix(p, \".allOf\")\n\n\t\tfor _, item := range allOf.Array() {\n\t\t\tif props := item.Get(\"properties\"); props.IsObject() {\n\t\t\t\tprops.ForEach(func(key, value gjson.Result) bool {\n\t\t\t\t\tdestPath := joinPath(parentPath, \"properties.\"+escapeGJSONPathKey(key.String()))\n\t\t\t\t\tjsonStr, _ = sjson.SetRaw(jsonStr, destPath, value.Raw)\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\tif req := item.Get(\"required\"); req.IsArray() {\n\t\t\t\treqPath := joinPath(parentPath, \"required\")\n\t\t\t\tcurrent := getStrings(jsonStr, reqPath)\n\t\t\t\tfor _, r := range req.Array() {\n\t\t\t\t\tif s := r.String(); !contains(current, s) {\n\t\t\t\t\t\tcurrent = append(current, s)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, current)\n\t\t\t}\n\t\t}\n\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t}\n\treturn jsonStr\n}\n\nfunc flattenAnyOfOneOf(jsonStr string) string {\n\tfor _, key := range []string{\"anyOf\", \"oneOf\"} {\n\t\tpaths := findPaths(jsonStr, key)\n\t\tsortByDepth(paths)\n\n\t\tfor _, p := range paths {\n\t\t\tarr := gjson.Get(jsonStr, p)\n\t\t\tif !arr.IsArray() || len(arr.Array()) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparentPath := trimSuffix(p, \".\"+key)\n\t\t\tparentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()\n\n\t\t\titems := arr.Array()\n\t\t\tbestIdx, allTypes := selectBest(items)\n\t\t\tselected := items[bestIdx].Raw\n\n\t\t\tif parentDesc != \"\" {\n\t\t\t\tselected = mergeDescriptionRaw(selected, parentDesc)\n\t\t\t}\n\n\t\t\tif len(allTypes) > 1 {\n\t\t\t\thint := \"Accepts: \" + strings.Join(allTypes, \" | \")\n\t\t\t\tselected = appendHintRaw(selected, hint)\n\t\t\t}\n\n\t\t\tjsonStr = setRawAt(jsonStr, parentPath, selected)\n\t\t}\n\t}\n\treturn jsonStr\n}\n\nfunc selectBest(items []gjson.Result) (bestIdx int, types []string) {\n\tbestScore := -1\n\tfor i, item := range items {\n\t\tt := item.Get(\"type\").String()\n\t\tscore := 0\n\n\t\tswitch {\n\t\tcase t == \"object\" || item.Get(\"properties\").Exists():\n\t\t\tscore, t = 3, orDefault(t, \"object\")\n\t\tcase t == \"array\" || item.Get(\"items\").Exists():\n\t\t\tscore, t = 2, orDefault(t, \"array\")\n\t\tcase t != \"\" && t != \"null\":\n\t\t\tscore = 1\n\t\tdefault:\n\t\t\tt = orDefault(t, \"null\")\n\t\t}\n\n\t\tif t != \"\" {\n\t\t\ttypes = append(types, t)\n\t\t}\n\t\tif score > bestScore {\n\t\t\tbestScore, bestIdx = score, i\n\t\t}\n\t}\n\treturn\n}\n\nfunc flattenTypeArrays(jsonStr string) string {\n\tpaths := findPaths(jsonStr, \"type\")\n\tsortByDepth(paths)\n\n\tnullableFields := make(map[string][]string)\n\n\tfor _, p := range paths {\n\t\tres := gjson.Get(jsonStr, p)\n\t\tif !res.IsArray() || len(res.Array()) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\thasNull := false\n\t\tvar nonNullTypes []string\n\t\tfor _, item := range res.Array() {\n\t\t\ts := item.String()\n\t\t\tif s == \"null\" {\n\t\t\t\thasNull = true\n\t\t\t} else if s != \"\" {\n\t\t\t\tnonNullTypes = append(nonNullTypes, s)\n\t\t\t}\n\t\t}\n\n\t\tfirstType := \"string\"\n\t\tif len(nonNullTypes) > 0 {\n\t\t\tfirstType = nonNullTypes[0]\n\t\t}\n\n\t\tjsonStr, _ = sjson.Set(jsonStr, p, firstType)\n\n\t\tparentPath := trimSuffix(p, \".type\")\n\t\tif len(nonNullTypes) > 1 {\n\t\t\thint := \"Accepts: \" + strings.Join(nonNullTypes, \" | \")\n\t\t\tjsonStr = appendHint(jsonStr, parentPath, hint)\n\t\t}\n\n\t\tif hasNull {\n\t\t\tparts := splitGJSONPath(p)\n\t\t\tif len(parts) >= 3 && parts[len(parts)-3] == \"properties\" {\n\t\t\t\tfieldNameEscaped := parts[len(parts)-2]\n\t\t\t\tfieldName := unescapeGJSONPathKey(fieldNameEscaped)\n\t\t\t\tobjectPath := strings.Join(parts[:len(parts)-3], \".\")\n\t\t\t\tnullableFields[objectPath] = append(nullableFields[objectPath], fieldName)\n\n\t\t\t\tpropPath := joinPath(objectPath, \"properties.\"+fieldNameEscaped)\n\t\t\t\tjsonStr = appendHint(jsonStr, propPath, \"(nullable)\")\n\t\t\t}\n\t\t}\n\t}\n\n\tfor objectPath, fields := range nullableFields {\n\t\treqPath := joinPath(objectPath, \"required\")\n\t\treq := gjson.Get(jsonStr, reqPath)\n\t\tif !req.IsArray() {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar filtered []string\n\t\tfor _, r := range req.Array() {\n\t\t\tif !contains(fields, r.String()) {\n\t\t\t\tfiltered = append(filtered, r.String())\n\t\t\t}\n\t\t}\n\n\t\tif len(filtered) == 0 {\n\t\t\tjsonStr, _ = sjson.Delete(jsonStr, reqPath)\n\t\t} else {\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)\n\t\t}\n\t}\n\treturn jsonStr\n}\n\nfunc removeUnsupportedKeywords(jsonStr string) string {\n\tkeywords := append(unsupportedConstraints,\n\t\t\"$schema\", \"$defs\", \"definitions\", \"const\", \"$ref\", \"$id\", \"additionalProperties\",\n\t\t\"propertyNames\", \"patternProperties\", // Gemini doesn't support these schema keywords\n\t\t\"enumTitles\", \"prefill\", \"deprecated\", // Schema metadata fields unsupported by Gemini\n\t)\n\n\tdeletePaths := make([]string, 0)\n\tpathsByField := findPathsByFields(jsonStr, keywords)\n\tfor _, key := range keywords {\n\t\tfor _, p := range pathsByField[key] {\n\t\t\tif isPropertyDefinition(trimSuffix(p, \".\"+key)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletePaths = append(deletePaths, p)\n\t\t}\n\t}\n\tsortByDepth(deletePaths)\n\tfor _, p := range deletePaths {\n\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t}\n\t// Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API\n\tjsonStr = removeExtensionFields(jsonStr)\n\treturn jsonStr\n}\n\n// removeExtensionFields removes all x-* extension fields from the JSON schema.\n// These are OpenAPI/JSON Schema extension fields that Google APIs don't recognize.\nfunc removeExtensionFields(jsonStr string) string {\n\tvar paths []string\n\twalkForExtensions(gjson.Parse(jsonStr), \"\", &paths)\n\t// walkForExtensions returns paths in a way that deeper paths are added before their ancestors\n\t// when they are not deleted wholesale, but since we skip children of deleted x-* nodes,\n\t// any collected path is safe to delete. We still use DeleteBytes for efficiency.\n\n\tb := []byte(jsonStr)\n\tfor _, p := range paths {\n\t\tb, _ = sjson.DeleteBytes(b, p)\n\t}\n\treturn string(b)\n}\n\nfunc walkForExtensions(value gjson.Result, path string, paths *[]string) {\n\tif value.IsArray() {\n\t\tarr := value.Array()\n\t\tfor i := len(arr) - 1; i >= 0; i-- {\n\t\t\twalkForExtensions(arr[i], joinPath(path, strconv.Itoa(i)), paths)\n\t\t}\n\t\treturn\n\t}\n\n\tif value.IsObject() {\n\t\tvalue.ForEach(func(key, val gjson.Result) bool {\n\t\t\tkeyStr := key.String()\n\t\t\tsafeKey := escapeGJSONPathKey(keyStr)\n\t\t\tchildPath := joinPath(path, safeKey)\n\n\t\t\t// If it's an extension field, we delete it and don't need to look at its children.\n\t\t\tif strings.HasPrefix(keyStr, \"x-\") && !isPropertyDefinition(path) {\n\t\t\t\t*paths = append(*paths, childPath)\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\twalkForExtensions(val, childPath, paths)\n\t\t\treturn true\n\t\t})\n\t}\n}\n\nfunc cleanupRequiredFields(jsonStr string) string {\n\tfor _, p := range findPaths(jsonStr, \"required\") {\n\t\tparentPath := trimSuffix(p, \".required\")\n\t\tpropsPath := joinPath(parentPath, \"properties\")\n\n\t\treq := gjson.Get(jsonStr, p)\n\t\tprops := gjson.Get(jsonStr, propsPath)\n\t\tif !req.IsArray() || !props.IsObject() {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar valid []string\n\t\tfor _, r := range req.Array() {\n\t\t\tkey := r.String()\n\t\t\tif props.Get(escapeGJSONPathKey(key)).Exists() {\n\t\t\t\tvalid = append(valid, key)\n\t\t\t}\n\t\t}\n\n\t\tif len(valid) != len(req.Array()) {\n\t\t\tif len(valid) == 0 {\n\t\t\t\tjsonStr, _ = sjson.Delete(jsonStr, p)\n\t\t\t} else {\n\t\t\t\tjsonStr, _ = sjson.Set(jsonStr, p, valid)\n\t\t\t}\n\t\t}\n\t}\n\treturn jsonStr\n}\n\n// addEmptySchemaPlaceholder adds a placeholder \"reason\" property to empty object schemas.\n// Claude VALIDATED mode requires at least one required property in tool schemas.\nfunc addEmptySchemaPlaceholder(jsonStr string) string {\n\t// Find all \"type\" fields\n\tpaths := findPaths(jsonStr, \"type\")\n\n\t// Process from deepest to shallowest (to handle nested objects properly)\n\tsortByDepth(paths)\n\n\tfor _, p := range paths {\n\t\ttypeVal := gjson.Get(jsonStr, p)\n\t\tif typeVal.String() != \"object\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Get the parent path (the object containing \"type\")\n\t\tparentPath := trimSuffix(p, \".type\")\n\n\t\t// Check if properties exists and is empty or missing\n\t\tpropsPath := joinPath(parentPath, \"properties\")\n\t\tpropsVal := gjson.Get(jsonStr, propsPath)\n\t\treqPath := joinPath(parentPath, \"required\")\n\t\treqVal := gjson.Get(jsonStr, reqPath)\n\t\thasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0\n\n\t\tneedsPlaceholder := false\n\t\tif !propsVal.Exists() {\n\t\t\t// No properties field at all\n\t\t\tneedsPlaceholder = true\n\t\t} else if propsVal.IsObject() && len(propsVal.Map()) == 0 {\n\t\t\t// Empty properties object\n\t\t\tneedsPlaceholder = true\n\t\t}\n\n\t\tif needsPlaceholder {\n\t\t\t// Add placeholder \"reason\" property\n\t\t\treasonPath := joinPath(propsPath, \"reason\")\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, reasonPath+\".type\", \"string\")\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, reasonPath+\".description\", placeholderReasonDescription)\n\n\t\t\t// Add to required array\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, []string{\"reason\"})\n\t\t\tcontinue\n\t\t}\n\n\t\t// If schema has properties but none are required, add a minimal placeholder.\n\t\tif propsVal.IsObject() && !hasRequiredProperties {\n\t\t\t// DO NOT add placeholder if it's a top-level schema (parentPath is empty)\n\t\t\t// or if we've already added a placeholder reason above.\n\t\t\tif parentPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tplaceholderPath := joinPath(propsPath, \"_\")\n\t\t\tif !gjson.Get(jsonStr, placeholderPath).Exists() {\n\t\t\t\tjsonStr, _ = sjson.Set(jsonStr, placeholderPath+\".type\", \"boolean\")\n\t\t\t}\n\t\t\tjsonStr, _ = sjson.Set(jsonStr, reqPath, []string{\"_\"})\n\t\t}\n\t}\n\n\treturn jsonStr\n}\n\n// --- Helpers ---\n\nfunc findPaths(jsonStr, field string) []string {\n\tvar paths []string\n\tWalk(gjson.Parse(jsonStr), \"\", field, &paths)\n\treturn paths\n}\n\nfunc findPathsByFields(jsonStr string, fields []string) map[string][]string {\n\tset := make(map[string]struct{}, len(fields))\n\tfor _, field := range fields {\n\t\tset[field] = struct{}{}\n\t}\n\tpaths := make(map[string][]string, len(set))\n\twalkForFields(gjson.Parse(jsonStr), \"\", set, paths)\n\treturn paths\n}\n\nfunc walkForFields(value gjson.Result, path string, fields map[string]struct{}, paths map[string][]string) {\n\tswitch value.Type {\n\tcase gjson.JSON:\n\t\tvalue.ForEach(func(key, val gjson.Result) bool {\n\t\t\tkeyStr := key.String()\n\t\t\tsafeKey := escapeGJSONPathKey(keyStr)\n\n\t\t\tvar childPath string\n\t\t\tif path == \"\" {\n\t\t\t\tchildPath = safeKey\n\t\t\t} else {\n\t\t\t\tchildPath = path + \".\" + safeKey\n\t\t\t}\n\n\t\t\tif _, ok := fields[keyStr]; ok {\n\t\t\t\tpaths[keyStr] = append(paths[keyStr], childPath)\n\t\t\t}\n\n\t\t\twalkForFields(val, childPath, fields, paths)\n\t\t\treturn true\n\t\t})\n\tcase gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:\n\t\t// Terminal types - no further traversal needed\n\t}\n}\n\nfunc sortByDepth(paths []string) {\n\tsort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })\n}\n\nfunc trimSuffix(path, suffix string) string {\n\tif path == strings.TrimPrefix(suffix, \".\") {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSuffix(path, suffix)\n}\n\nfunc joinPath(base, suffix string) string {\n\tif base == \"\" {\n\t\treturn suffix\n\t}\n\treturn base + \".\" + suffix\n}\n\nfunc setRawAt(jsonStr, path, value string) string {\n\tif path == \"\" {\n\t\treturn value\n\t}\n\tresult, _ := sjson.SetRaw(jsonStr, path, value)\n\treturn result\n}\n\nfunc isPropertyDefinition(path string) bool {\n\treturn path == \"properties\" || strings.HasSuffix(path, \".properties\")\n}\n\nfunc descriptionPath(parentPath string) string {\n\tif parentPath == \"\" || parentPath == \"@this\" {\n\t\treturn \"description\"\n\t}\n\treturn parentPath + \".description\"\n}\n\nfunc appendHint(jsonStr, parentPath, hint string) string {\n\tdescPath := parentPath + \".description\"\n\tif parentPath == \"\" || parentPath == \"@this\" {\n\t\tdescPath = \"description\"\n\t}\n\texisting := gjson.Get(jsonStr, descPath).String()\n\tif existing != \"\" {\n\t\thint = fmt.Sprintf(\"%s (%s)\", existing, hint)\n\t}\n\tjsonStr, _ = sjson.Set(jsonStr, descPath, hint)\n\treturn jsonStr\n}\n\nfunc appendHintRaw(jsonRaw, hint string) string {\n\texisting := gjson.Get(jsonRaw, \"description\").String()\n\tif existing != \"\" {\n\t\thint = fmt.Sprintf(\"%s (%s)\", existing, hint)\n\t}\n\tjsonRaw, _ = sjson.Set(jsonRaw, \"description\", hint)\n\treturn jsonRaw\n}\n\nfunc getStrings(jsonStr, path string) []string {\n\tvar result []string\n\tif arr := gjson.Get(jsonStr, path); arr.IsArray() {\n\t\tfor _, r := range arr.Array() {\n\t\t\tresult = append(result, r.String())\n\t\t}\n\t}\n\treturn result\n}\n\nfunc contains(slice []string, item string) bool {\n\tfor _, s := range slice {\n\t\tif s == item {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc orDefault(val, def string) string {\n\tif val == \"\" {\n\t\treturn def\n\t}\n\treturn val\n}\n\nfunc escapeGJSONPathKey(key string) string {\n\tif strings.IndexAny(key, \".*?\") == -1 {\n\t\treturn key\n\t}\n\treturn gjsonPathKeyReplacer.Replace(key)\n}\n\nfunc unescapeGJSONPathKey(key string) string {\n\tif !strings.Contains(key, \"\\\\\") {\n\t\treturn key\n\t}\n\tvar b strings.Builder\n\tb.Grow(len(key))\n\tfor i := 0; i < len(key); i++ {\n\t\tif key[i] == '\\\\' && i+1 < len(key) {\n\t\t\ti++\n\t\t\tb.WriteByte(key[i])\n\t\t\tcontinue\n\t\t}\n\t\tb.WriteByte(key[i])\n\t}\n\treturn b.String()\n}\n\nfunc splitGJSONPath(path string) []string {\n\tif path == \"\" {\n\t\treturn nil\n\t}\n\n\tparts := make([]string, 0, strings.Count(path, \".\")+1)\n\tvar b strings.Builder\n\tb.Grow(len(path))\n\n\tfor i := 0; i < len(path); i++ {\n\t\tc := path[i]\n\t\tif c == '\\\\' && i+1 < len(path) {\n\t\t\tb.WriteByte('\\\\')\n\t\t\ti++\n\t\t\tb.WriteByte(path[i])\n\t\t\tcontinue\n\t\t}\n\t\tif c == '.' {\n\t\t\tparts = append(parts, b.String())\n\t\t\tb.Reset()\n\t\t\tcontinue\n\t\t}\n\t\tb.WriteByte(c)\n\t}\n\tparts = append(parts, b.String())\n\treturn parts\n}\n\nfunc mergeDescriptionRaw(schemaRaw, parentDesc string) string {\n\tchildDesc := gjson.Get(schemaRaw, \"description\").String()\n\tswitch {\n\tcase childDesc == \"\":\n\t\tschemaRaw, _ = sjson.Set(schemaRaw, \"description\", parentDesc)\n\t\treturn schemaRaw\n\tcase childDesc == parentDesc:\n\t\treturn schemaRaw\n\tdefault:\n\t\tcombined := fmt.Sprintf(\"%s (%s)\", parentDesc, childDesc)\n\t\tschemaRaw, _ = sjson.Set(schemaRaw, \"description\", combined)\n\t\treturn schemaRaw\n\t}\n}\n"
  },
  {
    "path": "internal/util/gemini_schema_test.go",
    "content": "package util\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"kind\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"const\": \"InsightVizNode\"\n\t\t\t}\n\t\t}\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"kind\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"enum\": [\"InsightVizNode\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"name\": {\n\t\t\t\t\"type\": [\"string\", \"null\"]\n\t\t\t},\n\t\t\t\"other\": {\n\t\t\t\t\"type\": \"string\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"name\", \"other\"]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"name\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"(nullable)\"\n\t\t\t},\n\t\t\t\"other\": {\n\t\t\t\t\"type\": \"string\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"other\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"tags\": {\n\t\t\t\t\"type\": \"array\",\n\t\t\t\t\"description\": \"List of tags\",\n\t\t\t\t\"minItems\": 1\n\t\t\t},\n\t\t\t\"name\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"User name\",\n\t\t\t\t\"minLength\": 3\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// minItems should be REMOVED and moved to description\n\tif strings.Contains(result, `\"minItems\"`) {\n\t\tt.Errorf(\"minItems keyword should be removed\")\n\t}\n\tif !strings.Contains(result, \"minItems: 1\") {\n\t\tt.Errorf(\"minItems hint missing in description\")\n\t}\n\n\t// minLength should be moved to description\n\tif !strings.Contains(result, \"minLength: 3\") {\n\t\tt.Errorf(\"minLength hint missing in description\")\n\t}\n\tif strings.Contains(result, `\"minLength\":`) || strings.Contains(result, `\"minLength\" :`) {\n\t\tt.Errorf(\"minLength keyword should be removed\")\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"query\": {\n\t\t\t\t\"anyOf\": [\n\t\t\t\t\t{ \"type\": \"null\" },\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\"kind\": { \"type\": \"string\" }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"query\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"description\": \"Accepts: null | object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"_\": { \"type\": \"boolean\" },\n\t\t\t\t\t\"kind\": { \"type\": \"string\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"_\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"config\": {\n\t\t\t\t\"oneOf\": [\n\t\t\t\t\t{ \"type\": \"string\" },\n\t\t\t\t\t{ \"type\": \"integer\" }\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"config\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"Accepts: string | integer\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"allOf\": [\n\t\t\t{\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"a\": { \"type\": \"string\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"a\"]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"b\": { \"type\": \"integer\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"b\"]\n\t\t\t}\n\t\t]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"a\": { \"type\": \"string\" },\n\t\t\t\"b\": { \"type\": \"integer\" }\n\t\t},\n\t\t\"required\": [\"a\", \"b\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {\n\tinput := `{\n\t\t\"definitions\": {\n\t\t\t\"User\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"name\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"customer\": { \"$ref\": \"#/definitions/User\" }\n\t\t}\n\t}`\n\n\t// After $ref is converted to placeholder object, empty schema placeholder is also added\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"customer\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"description\": \"See: User\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"reason\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Brief explanation of why you are calling this tool\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"required\": [\"reason\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) {\n\tinput := `{\n\t\t\"definitions\": {\n\t\t\t\"User\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"name\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"customer\": {\n\t\t\t\t\"description\": \"He said \\\"hi\\\"\\\\nsecond line\",\n\t\t\t\t\"$ref\": \"#/definitions/User\"\n\t\t\t}\n\t\t}\n\t}`\n\n\t// After $ref is converted, empty schema placeholder is also added\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"customer\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"description\": \"He said \\\"hi\\\"\\\\nsecond line (See: User)\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"reason\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Brief explanation of why you are calling this tool\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"required\": [\"reason\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {\n\tinput := `{\n\t\t\"definitions\": {\n\t\t\t\"Node\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"child\": { \"$ref\": \"#/definitions/Node\" }\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"$ref\": \"#/definitions/Node\"\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tvar resMap map[string]interface{}\n\tjson.Unmarshal([]byte(result), &resMap)\n\n\tif resMap[\"type\"] != \"object\" {\n\t\tt.Errorf(\"Expected type: object, got: %v\", resMap[\"type\"])\n\t}\n\n\tdesc, ok := resMap[\"description\"].(string)\n\tif !ok || !strings.Contains(desc, \"Node\") {\n\t\tt.Errorf(\"Expected description hint containing 'Node', got: %v\", resMap[\"description\"])\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_RequiredCleanup(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"a\": {\"type\": \"string\"},\n\t\t\t\"b\": {\"type\": \"string\"}\n\t\t},\n\t\t\"required\": [\"a\", \"b\", \"c\"]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"a\": {\"type\": \"string\"},\n\t\t\t\"b\": {\"type\": \"string\"}\n\t\t},\n\t\t\"required\": [\"a\", \"b\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"allOf\": [\n\t\t\t{\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"my.param\": { \"type\": \"string\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"my.param\"]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"b\": { \"type\": \"integer\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"b\"]\n\t\t\t}\n\t\t]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"my.param\": { \"type\": \"string\" },\n\t\t\t\"b\": { \"type\": \"integer\" }\n\t\t},\n\t\t\"required\": [\"my.param\", \"b\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_PropertyNameCollision(t *testing.T) {\n\t// A tool has an argument named \"pattern\" - should NOT be treated as a constraint\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"pattern\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"The regex pattern\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"pattern\"]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"pattern\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"The regex pattern\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"pattern\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n\n\tvar resMap map[string]interface{}\n\tjson.Unmarshal([]byte(result), &resMap)\n\tprops, _ := resMap[\"properties\"].(map[string]interface{})\n\tif _, ok := props[\"description\"]; ok {\n\t\tt.Errorf(\"Invalid 'description' property injected into properties map\")\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_DotKeys(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"my.param\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"$ref\": \"#/definitions/MyType\"\n\t\t\t}\n\t\t},\n\t\t\"definitions\": {\n\t\t\t\"MyType\": { \"type\": \"string\" }\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tvar resMap map[string]interface{}\n\tif err := json.Unmarshal([]byte(result), &resMap); err != nil {\n\t\tt.Fatalf(\"Failed to unmarshal result: %v\", err)\n\t}\n\n\tprops, ok := resMap[\"properties\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"properties missing\")\n\t}\n\n\tif val, ok := props[\"my.param\"]; !ok {\n\t\tt.Fatalf(\"Key 'my.param' is missing. Result: %s\", result)\n\t} else {\n\t\tvalMap, _ := val.(map[string]interface{})\n\t\tif _, hasRef := valMap[\"$ref\"]; hasRef {\n\t\t\tt.Errorf(\"Key 'my.param' still contains $ref\")\n\t\t}\n\t\tif _, ok := props[\"my\"]; ok {\n\t\t\tt.Errorf(\"Artifact key 'my' created by sjson splitting\")\n\t\t}\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"value\": {\n\t\t\t\t\"anyOf\": [\n\t\t\t\t\t{ \"type\": \"string\" },\n\t\t\t\t\t{ \"type\": \"integer\" },\n\t\t\t\t\t{ \"type\": \"null\" }\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif !strings.Contains(result, \"Accepts:\") {\n\t\tt.Errorf(\"Expected alternative types hint, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"string\") || !strings.Contains(result, \"integer\") {\n\t\tt.Errorf(\"Expected all alternative types in hint, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"name\": {\n\t\t\t\t\"type\": [\"string\", \"null\"],\n\t\t\t\t\"description\": \"User name\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"name\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif !strings.Contains(result, \"(nullable)\") {\n\t\tt.Errorf(\"Expected nullable hint, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"User name\") {\n\t\tt.Errorf(\"Expected original description to be preserved, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"my.param\": {\n\t\t\t\t\"type\": [\"string\", \"null\"]\n\t\t\t},\n\t\t\t\"other\": {\n\t\t\t\t\"type\": \"string\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"my.param\", \"other\"]\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"my.param\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"(nullable)\"\n\t\t\t},\n\t\t\t\"other\": {\n\t\t\t\t\"type\": \"string\"\n\t\t\t}\n\t\t},\n\t\t\"required\": [\"other\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"status\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"enum\": [\"active\", \"inactive\", \"pending\"],\n\t\t\t\t\"description\": \"Current status\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif !strings.Contains(result, \"Allowed:\") {\n\t\tt.Errorf(\"Expected enum values hint, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"active\") || !strings.Contains(result, \"inactive\") {\n\t\tt.Errorf(\"Expected enum values in hint, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"name\": { \"type\": \"string\" }\n\t\t},\n\t\t\"additionalProperties\": false\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif !strings.Contains(result, \"No extra properties allowed\") {\n\t\tt.Errorf(\"Expected additionalProperties hint, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"config\": {\n\t\t\t\t\"description\": \"Parent desc\",\n\t\t\t\t\"anyOf\": [\n\t\t\t\t\t{ \"type\": \"string\", \"description\": \"Child desc\" },\n\t\t\t\t\t{ \"type\": \"integer\" }\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"config\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"Parent desc (Child desc) (Accepts: string | integer)\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"kind\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"enum\": [\"fixed\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif strings.Contains(result, \"Allowed:\") {\n\t\tt.Errorf(\"Single value enum should not add Allowed hint, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_MultipleNonNullTypes(t *testing.T) {\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"value\": {\n\t\t\t\t\"type\": [\"string\", \"integer\", \"boolean\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\tif !strings.Contains(result, \"Accepts:\") {\n\t\tt.Errorf(\"Expected multiple types hint, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"string\") || !strings.Contains(result, \"integer\") || !strings.Contains(result, \"boolean\") {\n\t\tt.Errorf(\"Expected all types in hint, got: %s\", result)\n\t}\n}\n\nfunc compareJSON(t *testing.T, expectedJSON, actualJSON string) {\n\tvar expMap, actMap map[string]interface{}\n\terrExp := json.Unmarshal([]byte(expectedJSON), &expMap)\n\terrAct := json.Unmarshal([]byte(actualJSON), &actMap)\n\n\tif errExp != nil || errAct != nil {\n\t\tt.Fatalf(\"JSON Unmarshal error. Exp: %v, Act: %v\", errExp, errAct)\n\t}\n\n\tif !reflect.DeepEqual(expMap, actMap) {\n\t\texpBytes, _ := json.MarshalIndent(expMap, \"\", \"  \")\n\t\tactBytes, _ := json.MarshalIndent(actMap, \"\", \"  \")\n\t\tt.Errorf(\"JSON mismatch:\\nExpected:\\n%s\\n\\nActual:\\n%s\", string(expBytes), string(actBytes))\n\t}\n}\n\n// ============================================================================\n// Empty Schema Placeholder Tests\n// ============================================================================\n\nfunc TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) {\n\t// Empty object schema with no properties should get a placeholder\n\tinput := `{\n\t\t\"type\": \"object\"\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Should have placeholder property added\n\tif !strings.Contains(result, `\"reason\"`) {\n\t\tt.Errorf(\"Empty schema should have 'reason' placeholder property, got: %s\", result)\n\t}\n\tif !strings.Contains(result, `\"required\"`) {\n\t\tt.Errorf(\"Empty schema should have 'required' with 'reason', got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_EmptyPropertiesPlaceholder(t *testing.T) {\n\t// Object with empty properties object\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Should have placeholder property added\n\tif !strings.Contains(result, `\"reason\"`) {\n\t\tt.Errorf(\"Empty properties should have 'reason' placeholder, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_NonEmptySchemaUnchanged(t *testing.T) {\n\t// Schema with properties should NOT get placeholder\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"name\": {\"type\": \"string\"}\n\t\t},\n\t\t\"required\": [\"name\"]\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Should NOT have placeholder property\n\tif strings.Contains(result, `\"reason\"`) {\n\t\tt.Errorf(\"Non-empty schema should NOT have 'reason' placeholder, got: %s\", result)\n\t}\n\t// Original properties should be preserved\n\tif !strings.Contains(result, `\"name\"`) {\n\t\tt.Errorf(\"Original property 'name' should be preserved, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) {\n\t// Nested empty object in items should also get placeholder\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"items\": {\n\t\t\t\t\"type\": \"array\",\n\t\t\t\t\"items\": {\n\t\t\t\t\t\"type\": \"object\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Nested empty object should also get placeholder\n\t// Check that the nested object has a reason property\n\tparsed := gjson.Parse(result)\n\tnestedProps := parsed.Get(\"properties.items.items.properties\")\n\tif !nestedProps.Exists() || !nestedProps.Get(\"reason\").Exists() {\n\t\tt.Errorf(\"Nested empty object should have 'reason' placeholder, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) {\n\t// Empty schema with description should preserve description and add placeholder\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"description\": \"An empty object\"\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Should have both description and placeholder\n\tif !strings.Contains(result, `\"An empty object\"`) {\n\t\tt.Errorf(\"Description should be preserved, got: %s\", result)\n\t}\n\tif !strings.Contains(result, `\"reason\"`) {\n\t\tt.Errorf(\"Empty schema should have 'reason' placeholder, got: %s\", result)\n\t}\n}\n\n// ============================================================================\n// Format field handling (ad-hoc patch removal)\n// ============================================================================\n\nfunc TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) {\n\t// format:\"uri\" should be removed and added as hint\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"url\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"format\": \"uri\",\n\t\t\t\t\"description\": \"A URL\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// format should be removed\n\tif strings.Contains(result, `\"format\"`) {\n\t\tt.Errorf(\"format field should be removed, got: %s\", result)\n\t}\n\t// hint should be added to description\n\tif !strings.Contains(result, \"format: uri\") {\n\t\tt.Errorf(\"format hint should be added to description, got: %s\", result)\n\t}\n\t// original description should be preserved\n\tif !strings.Contains(result, \"A URL\") {\n\t\tt.Errorf(\"Original description should be preserved, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_FormatFieldNoDescription(t *testing.T) {\n\t// format without description should create description with hint\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"email\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"format\": \"email\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// format should be removed\n\tif strings.Contains(result, `\"format\"`) {\n\t\tt.Errorf(\"format field should be removed, got: %s\", result)\n\t}\n\t// hint should be added\n\tif !strings.Contains(result, \"format: email\") {\n\t\tt.Errorf(\"format hint should be added, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) {\n\t// Multiple format fields should all be handled\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"url\": {\"type\": \"string\", \"format\": \"uri\"},\n\t\t\t\"email\": {\"type\": \"string\", \"format\": \"email\"},\n\t\t\t\"date\": {\"type\": \"string\", \"format\": \"date-time\"}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// All format fields should be removed\n\tif strings.Contains(result, `\"format\"`) {\n\t\tt.Errorf(\"All format fields should be removed, got: %s\", result)\n\t}\n\t// All hints should be added\n\tif !strings.Contains(result, \"format: uri\") {\n\t\tt.Errorf(\"uri format hint should be added, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"format: email\") {\n\t\tt.Errorf(\"email format hint should be added, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"format: date-time\") {\n\t\tt.Errorf(\"date-time format hint should be added, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {\n\t// Gemini API requires enum values to be strings, not numbers\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"priority\": {\"type\": \"integer\", \"enum\": [0, 1, 2]},\n\t\t\t\"level\": {\"type\": \"number\", \"enum\": [1.5, 2.5, 3.5]},\n\t\t\t\"status\": {\"type\": \"string\", \"enum\": [\"active\", \"inactive\"]}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Numeric enum values should be converted to strings\n\tif strings.Contains(result, `\"enum\":[0,1,2]`) {\n\t\tt.Errorf(\"Integer enum values should be converted to strings, got: %s\", result)\n\t}\n\tif strings.Contains(result, `\"enum\":[1.5,2.5,3.5]`) {\n\t\tt.Errorf(\"Float enum values should be converted to strings, got: %s\", result)\n\t}\n\t// Should contain string versions\n\tif !strings.Contains(result, `\"0\"`) || !strings.Contains(result, `\"1\"`) || !strings.Contains(result, `\"2\"`) {\n\t\tt.Errorf(\"Integer enum values should be converted to string format, got: %s\", result)\n\t}\n\t// String enum values should remain unchanged\n\tif !strings.Contains(result, `\"active\"`) || !strings.Contains(result, `\"inactive\"`) {\n\t\tt.Errorf(\"String enum values should remain unchanged, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {\n\t// Boolean enum values should also be converted to strings\n\tinput := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"enabled\": {\"type\": \"boolean\", \"enum\": [true, false]}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForAntigravity(input)\n\n\t// Boolean enum values should be converted to strings\n\tif strings.Contains(result, `\"enum\":[true,false]`) {\n\t\tt.Errorf(\"Boolean enum values should be converted to strings, got: %s\", result)\n\t}\n\t// Should contain string versions \"true\" and \"false\"\n\tif !strings.Contains(result, `\"true\"`) || !strings.Contains(result, `\"false\"`) {\n\t\tt.Errorf(\"Boolean enum values should be converted to string format, got: %s\", result)\n\t}\n}\n\nfunc TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) {\n\tinput := `{\n\t\t\"$schema\": \"http://json-schema.org/draft-07/schema#\",\n\t\t\"$id\": \"root-schema\",\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"payload\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"prefill\": \"hello\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"enum\": [\"a\", \"b\"],\n\t\t\t\t\t\t\"enumTitles\": [\"A\", \"B\"]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"patternProperties\": {\n\t\t\t\t\t\"^x-\": {\"type\": \"string\"}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"$id\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"property name should not be removed\"\n\t\t\t}\n\t\t}\n\t}`\n\n\texpected := `{\n\t\t\"type\": \"object\",\n\t\t\"properties\": {\n\t\t\t\"payload\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"enum\": [\"a\", \"b\"],\n\t\t\t\t\t\t\"description\": \"Allowed: a, b\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"$id\": {\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"property name should not be removed\"\n\t\t\t}\n\t\t}\n\t}`\n\n\tresult := CleanJSONSchemaForGemini(input)\n\tcompareJSON(t, expected, result)\n}\n\nfunc TestRemoveExtensionFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"removes x- fields at root\",\n\t\t\tinput: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"x-custom-meta\": \"value\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t},\n\t\t{\n\t\t\tname: \"removes x- fields in nested properties\",\n\t\t\tinput: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"x-internal-id\": 123\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": {\n\t\t\t\t\t\t\"type\": \"string\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}`,\n\t\t},\n\t\t{\n\t\t\tname: \"does NOT remove properties named x-\",\n\t\t\tinput: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"x-data\": { \"type\": \"string\" },\n\t\t\t\t\t\"normal\": { \"type\": \"number\", \"x-meta\": \"remove\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"x-data\"]\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"x-data\": { \"type\": \"string\" },\n\t\t\t\t\t\"normal\": { \"type\": \"number\" }\n\t\t\t\t},\n\t\t\t\t\"required\": [\"x-data\"]\n\t\t\t}`,\n\t\t},\n\t\t{\n\t\t\tname: \"does NOT remove $schema and other meta fields (as requested)\",\n\t\t\tinput: `{\n\t\t\t\t\"$schema\": \"http://json-schema.org/draft-07/schema#\",\n\t\t\t\t\"$id\": \"test\",\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"$schema\": \"http://json-schema.org/draft-07/schema#\",\n\t\t\t\t\"$id\": \"test\",\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t},\n\t\t{\n\t\t\tname: \"handles properties named $schema\",\n\t\t\tinput: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"$schema\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"$schema\": { \"type\": \"string\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t},\n\t\t{\n\t\t\tname: \"handles escaping in paths\",\n\t\t\tinput: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo.bar\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"x-meta\": \"remove\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"x-root.meta\": \"remove\"\n\t\t\t}`,\n\t\t\texpected: `{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"foo.bar\": {\n\t\t\t\t\t\t\"type\": \"string\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tactual := removeExtensionFields(tt.input)\n\t\t\tcompareJSON(t, tt.expected, actual)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/util/header_helpers.go",
    "content": "package util\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n)\n\n// ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.\n// Custom headers override built-in defaults when conflicts occur.\nfunc ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string) {\n\tif r == nil {\n\t\treturn\n\t}\n\tapplyCustomHeaders(r, extractCustomHeaders(attrs))\n}\n\nfunc extractCustomHeaders(attrs map[string]string) map[string]string {\n\tif len(attrs) == 0 {\n\t\treturn nil\n\t}\n\theaders := make(map[string]string)\n\tfor k, v := range attrs {\n\t\tif !strings.HasPrefix(k, \"header:\") {\n\t\t\tcontinue\n\t\t}\n\t\tname := strings.TrimSpace(strings.TrimPrefix(k, \"header:\"))\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tval := strings.TrimSpace(v)\n\t\tif val == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\theaders[name] = val\n\t}\n\tif len(headers) == 0 {\n\t\treturn nil\n\t}\n\treturn headers\n}\n\nfunc applyCustomHeaders(r *http.Request, headers map[string]string) {\n\tif r == nil || len(headers) == 0 {\n\t\treturn\n\t}\n\tfor k, v := range headers {\n\t\tif k == \"\" || v == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tr.Header.Set(k, v)\n\t}\n}\n"
  },
  {
    "path": "internal/util/image.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"image\"\n\t\"image/draw\"\n\t\"image/png\"\n)\n\nfunc CreateWhiteImageBase64(aspectRatio string) (string, error) {\n\twidth := 1024\n\theight := 1024\n\n\tswitch aspectRatio {\n\tcase \"1:1\":\n\t\twidth = 1024\n\t\theight = 1024\n\tcase \"2:3\":\n\t\twidth = 832\n\t\theight = 1248\n\tcase \"3:2\":\n\t\twidth = 1248\n\t\theight = 832\n\tcase \"3:4\":\n\t\twidth = 864\n\t\theight = 1184\n\tcase \"4:3\":\n\t\twidth = 1184\n\t\theight = 864\n\tcase \"4:5\":\n\t\twidth = 896\n\t\theight = 1152\n\tcase \"5:4\":\n\t\twidth = 1152\n\t\theight = 896\n\tcase \"9:16\":\n\t\twidth = 768\n\t\theight = 1344\n\tcase \"16:9\":\n\t\twidth = 1344\n\t\theight = 768\n\tcase \"21:9\":\n\t\twidth = 1536\n\t\theight = 672\n\t}\n\n\timg := image.NewRGBA(image.Rect(0, 0, width, height))\n\tdraw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)\n\n\tvar buf bytes.Buffer\n\n\tif err := png.Encode(&buf, img); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tbase64String := base64.StdEncoding.EncodeToString(buf.Bytes())\n\treturn base64String, nil\n}\n"
  },
  {
    "path": "internal/util/provider.go",
    "content": "// Package util provides utility functions used across the CLIProxyAPI application.\n// These functions handle common tasks such as determining AI service providers\n// from model names and managing HTTP proxies.\npackage util\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// GetProviderName determines all AI service providers capable of serving a registered model.\n// It first queries the global model registry to retrieve the providers backing the supplied model name.\n// When the model has not been registered yet, it falls back to legacy string heuristics to infer\n// potential providers.\n//\n// Supported providers include (but are not limited to):\n//   - \"gemini\" for Google's Gemini family\n//   - \"codex\" for OpenAI GPT-compatible providers\n//   - \"claude\" for Anthropic models\n//   - \"qwen\" for Alibaba's Qwen models\n//   - \"openai-compatibility\" for external OpenAI-compatible providers\n//\n// Parameters:\n//   - modelName: The name of the model to identify providers for.\n//   - cfg: The application configuration containing OpenAI compatibility settings.\n//\n// Returns:\n//   - []string: All provider identifiers capable of serving the model, ordered by preference.\nfunc GetProviderName(modelName string) []string {\n\tif modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tproviders := make([]string, 0, 4)\n\tseen := make(map[string]struct{})\n\n\tappendProvider := func(name string) {\n\t\tif name == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[name]; exists {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = struct{}{}\n\t\tproviders = append(providers, name)\n\t}\n\n\tfor _, provider := range registry.GetGlobalRegistry().GetModelProviders(modelName) {\n\t\tappendProvider(provider)\n\t}\n\n\tif len(providers) > 0 {\n\t\treturn providers\n\t}\n\n\treturn providers\n}\n\n// ResolveAutoModel resolves the \"auto\" model name to an actual available model.\n// It uses an empty handler type to get any available model from the registry.\n//\n// Parameters:\n//   - modelName: The model name to check (should be \"auto\")\n//\n// Returns:\n//   - string: The resolved model name, or the original if not \"auto\" or resolution fails\nfunc ResolveAutoModel(modelName string) string {\n\tif modelName != \"auto\" {\n\t\treturn modelName\n\t}\n\n\t// Use empty string as handler type to get any available model\n\tfirstModel, err := registry.GetGlobalRegistry().GetFirstAvailableModel(\"\")\n\tif err != nil {\n\t\tlog.Warnf(\"Failed to resolve 'auto' model: %v, falling back to original model name\", err)\n\t\treturn modelName\n\t}\n\n\tlog.Infof(\"Resolved 'auto' model to: %s\", firstModel)\n\treturn firstModel\n}\n\n// IsOpenAICompatibilityAlias checks if the given model name is an alias\n// configured for OpenAI compatibility routing.\n//\n// Parameters:\n//   - modelName: The model name to check\n//   - cfg: The application configuration containing OpenAI compatibility settings\n//\n// Returns:\n//   - bool: True if the model name is an OpenAI compatibility alias, false otherwise\nfunc IsOpenAICompatibilityAlias(modelName string, cfg *config.Config) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\n\tfor _, compat := range cfg.OpenAICompatibility {\n\t\tfor _, model := range compat.Models {\n\t\t\tif model.Alias == modelName {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// GetOpenAICompatibilityConfig returns the OpenAI compatibility configuration\n// and model details for the given alias.\n//\n// Parameters:\n//   - alias: The model alias to find configuration for\n//   - cfg: The application configuration containing OpenAI compatibility settings\n//\n// Returns:\n//   - *config.OpenAICompatibility: The matching compatibility configuration, or nil if not found\n//   - *config.OpenAICompatibilityModel: The matching model configuration, or nil if not found\nfunc GetOpenAICompatibilityConfig(alias string, cfg *config.Config) (*config.OpenAICompatibility, *config.OpenAICompatibilityModel) {\n\tif cfg == nil {\n\t\treturn nil, nil\n\t}\n\n\tfor _, compat := range cfg.OpenAICompatibility {\n\t\tfor _, model := range compat.Models {\n\t\t\tif model.Alias == alias {\n\t\t\t\treturn &compat, &model\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, nil\n}\n\n// InArray checks if a string exists in a slice of strings.\n// It iterates through the slice and returns true if the target string is found,\n// otherwise it returns false.\n//\n// Parameters:\n//   - hystack: The slice of strings to search in\n//   - needle: The string to search for\n//\n// Returns:\n//   - bool: True if the string is found, false otherwise\nfunc InArray(hystack []string, needle string) bool {\n\tfor _, item := range hystack {\n\t\tif needle == item {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// HideAPIKey obscures an API key for logging purposes, showing only the first and last few characters.\n//\n// Parameters:\n//   - apiKey: The API key to hide.\n//\n// Returns:\n//   - string: The obscured API key.\nfunc HideAPIKey(apiKey string) string {\n\tif len(apiKey) > 8 {\n\t\treturn apiKey[:4] + \"...\" + apiKey[len(apiKey)-4:]\n\t} else if len(apiKey) > 4 {\n\t\treturn apiKey[:2] + \"...\" + apiKey[len(apiKey)-2:]\n\t} else if len(apiKey) > 2 {\n\t\treturn apiKey[:1] + \"...\" + apiKey[len(apiKey)-1:]\n\t}\n\treturn apiKey\n}\n\n// maskAuthorizationHeader masks the Authorization header value while preserving the auth type prefix.\n// Common formats: \"Bearer <token>\", \"Basic <credentials>\", \"ApiKey <key>\", etc.\n// It preserves the prefix (e.g., \"Bearer \") and only masks the token/credential part.\n//\n// Parameters:\n//   - value: The Authorization header value\n//\n// Returns:\n//   - string: The masked Authorization value with prefix preserved\nfunc MaskAuthorizationHeader(value string) string {\n\tparts := strings.SplitN(strings.TrimSpace(value), \" \", 2)\n\tif len(parts) < 2 {\n\t\treturn HideAPIKey(value)\n\t}\n\treturn parts[0] + \" \" + HideAPIKey(parts[1])\n}\n\n// MaskSensitiveHeaderValue masks sensitive header values while preserving expected formats.\n//\n// Behavior by header key (case-insensitive):\n//   - \"Authorization\": Preserve the auth type prefix (e.g., \"Bearer \") and mask only the credential part.\n//   - Headers containing \"api-key\": Mask the entire value using HideAPIKey.\n//   - Others: Return the original value unchanged.\n//\n// Parameters:\n//   - key:   The HTTP header name to inspect (case-insensitive matching).\n//   - value: The header value to mask when sensitive.\n//\n// Returns:\n//   - string: The masked value according to the header type; unchanged if not sensitive.\nfunc MaskSensitiveHeaderValue(key, value string) string {\n\tlowerKey := strings.ToLower(strings.TrimSpace(key))\n\tswitch {\n\tcase strings.Contains(lowerKey, \"authorization\"):\n\t\treturn MaskAuthorizationHeader(value)\n\tcase strings.Contains(lowerKey, \"api-key\"),\n\t\tstrings.Contains(lowerKey, \"apikey\"),\n\t\tstrings.Contains(lowerKey, \"token\"),\n\t\tstrings.Contains(lowerKey, \"secret\"):\n\t\treturn HideAPIKey(value)\n\tdefault:\n\t\treturn value\n\t}\n}\n\n// MaskSensitiveQuery masks sensitive query parameters, e.g. auth_token, within the raw query string.\nfunc MaskSensitiveQuery(raw string) string {\n\tif raw == \"\" {\n\t\treturn \"\"\n\t}\n\tparts := strings.Split(raw, \"&\")\n\tchanged := false\n\tfor i, part := range parts {\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkeyPart := part\n\t\tvaluePart := \"\"\n\t\tif idx := strings.Index(part, \"=\"); idx >= 0 {\n\t\t\tkeyPart = part[:idx]\n\t\t\tvaluePart = part[idx+1:]\n\t\t}\n\t\tdecodedKey, err := url.QueryUnescape(keyPart)\n\t\tif err != nil {\n\t\t\tdecodedKey = keyPart\n\t\t}\n\t\tif !shouldMaskQueryParam(decodedKey) {\n\t\t\tcontinue\n\t\t}\n\t\tdecodedValue, err := url.QueryUnescape(valuePart)\n\t\tif err != nil {\n\t\t\tdecodedValue = valuePart\n\t\t}\n\t\tmasked := HideAPIKey(strings.TrimSpace(decodedValue))\n\t\tparts[i] = keyPart + \"=\" + url.QueryEscape(masked)\n\t\tchanged = true\n\t}\n\tif !changed {\n\t\treturn raw\n\t}\n\treturn strings.Join(parts, \"&\")\n}\n\nfunc shouldMaskQueryParam(key string) bool {\n\tkey = strings.ToLower(strings.TrimSpace(key))\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tkey = strings.TrimSuffix(key, \"[]\")\n\tif key == \"key\" || strings.Contains(key, \"api-key\") || strings.Contains(key, \"apikey\") || strings.Contains(key, \"api_key\") {\n\t\treturn true\n\t}\n\tif strings.Contains(key, \"token\") || strings.Contains(key, \"secret\") {\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/util/proxy.go",
    "content": "// Package util provides utility functions for the CLI Proxy API server.\n// It includes helper functions for proxy configuration, HTTP client setup,\n// log level management, and other common operations used across the application.\npackage util\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// SetProxy configures the provided HTTP client with proxy settings from the configuration.\n// It supports SOCKS5, HTTP, and HTTPS proxies. The function modifies the client's transport\n// to route requests through the configured proxy server.\nfunc SetProxy(cfg *config.SDKConfig, httpClient *http.Client) *http.Client {\n\tif cfg == nil || httpClient == nil {\n\t\treturn httpClient\n\t}\n\n\ttransport, _, errBuild := proxyutil.BuildHTTPTransport(cfg.ProxyURL)\n\tif errBuild != nil {\n\t\tlog.Errorf(\"%v\", errBuild)\n\t}\n\tif transport != nil {\n\t\thttpClient.Transport = transport\n\t}\n\treturn httpClient\n}\n"
  },
  {
    "path": "internal/util/sanitize_test.go",
    "content": "package util\n\nimport (\n\t\"testing\"\n)\n\nfunc TestSanitizeFunctionName(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"Normal\", \"valid_name\", \"valid_name\"},\n\t\t{\"With Dots\", \"name.with.dots\", \"name.with.dots\"},\n\t\t{\"With Colons\", \"name:with:colons\", \"name:with:colons\"},\n\t\t{\"With Dashes\", \"name-with-dashes\", \"name-with-dashes\"},\n\t\t{\"Mixed Allowed\", \"name.with_dots:colons-dashes\", \"name.with_dots:colons-dashes\"},\n\t\t{\"Invalid Characters\", \"name!with@invalid#chars\", \"name_with_invalid_chars\"},\n\t\t{\"Spaces\", \"name with spaces\", \"name_with_spaces\"},\n\t\t{\"Non-ASCII\", \"name_with_你好_chars\", \"name_with____chars\"},\n\t\t{\"Starts with digit\", \"123name\", \"_123name\"},\n\t\t{\"Starts with dot\", \".name\", \"_.name\"},\n\t\t{\"Starts with colon\", \":name\", \"_:name\"},\n\t\t{\"Starts with dash\", \"-name\", \"_-name\"},\n\t\t{\"Starts with invalid char\", \"!name\", \"_name\"},\n\t\t{\"Exactly 64 chars\", \"this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact\", \"this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact\"},\n\t\t{\"Too long (65 chars)\", \"this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX\", \"this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact\"},\n\t\t{\"Very long\", \"this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names\", \"this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l\"},\n\t\t{\"Starts with digit (64 chars total)\", \"1234567890123456789012345678901234567890123456789012345678901234\", \"_123456789012345678901234567890123456789012345678901234567890123\"},\n\t\t{\"Starts with invalid char (64 chars total)\", \"!234567890123456789012345678901234567890123456789012345678901234\", \"_234567890123456789012345678901234567890123456789012345678901234\"},\n\t\t{\"Empty\", \"\", \"\"},\n\t\t{\"Single character invalid\", \"@\", \"_\"},\n\t\t{\"Single character valid\", \"a\", \"a\"},\n\t\t{\"Single character digit\", \"1\", \"_1\"},\n\t\t{\"Single character underscore\", \"_\", \"_\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := SanitizeFunctionName(tt.input)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"SanitizeFunctionName(%q) = %v, want %v\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t\t// Verify Gemini compliance\n\t\t\tif len(got) > 64 {\n\t\t\t\tt.Errorf(\"SanitizeFunctionName(%q) result too long: %d\", tt.input, len(got))\n\t\t\t}\n\t\t\tif len(got) > 0 {\n\t\t\t\tfirst := got[0]\n\t\t\t\tif !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {\n\t\t\t\t\tt.Errorf(\"SanitizeFunctionName(%q) result starts with invalid char: %c\", tt.input, first)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/util/ssh_helper.go",
    "content": "// Package util provides helper functions for SSH tunnel instructions and network-related tasks.\n// This includes detecting the appropriate IP address and printing commands\n// to help users connect to the local server from a remote machine.\npackage util\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nvar ipServices = []string{\n\t\"https://api.ipify.org\",\n\t\"https://ifconfig.me/ip\",\n\t\"https://icanhazip.com\",\n\t\"https://ipinfo.io/ip\",\n}\n\n// getPublicIP attempts to retrieve the public IP address from a list of external services.\n// It iterates through the ipServices and returns the first successful response.\n//\n// Returns:\n//   - string: The public IP address as a string\n//   - error: An error if all services fail, nil otherwise\nfunc getPublicIP() (string, error) {\n\tfor _, service := range ipServices {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", service, nil)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Failed to create request to %s: %v\", service, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tresp, err := http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Failed to get public IP from %s: %v\", service, err)\n\t\t\tcontinue\n\t\t}\n\t\tdefer func() {\n\t\t\tif closeErr := resp.Body.Close(); closeErr != nil {\n\t\t\t\tlog.Warnf(\"Failed to close response body from %s: %v\", service, closeErr)\n\t\t\t}\n\t\t}()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tlog.Debugf(\"bad status code from %s: %d\", service, resp.StatusCode)\n\t\t\tcontinue\n\t\t}\n\n\t\tip, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Failed to read response body from %s: %v\", service, err)\n\t\t\tcontinue\n\t\t}\n\t\treturn strings.TrimSpace(string(ip)), nil\n\t}\n\treturn \"\", fmt.Errorf(\"all IP services failed\")\n}\n\n// getOutboundIP retrieves the preferred outbound IP address of this machine.\n// It uses a UDP connection to a public DNS server to determine the local IP\n// address that would be used for outbound traffic.\n//\n// Returns:\n//   - string: The outbound IP address as a string\n//   - error: An error if the IP address cannot be determined, nil otherwise\nfunc getOutboundIP() (string, error) {\n\tconn, err := net.Dial(\"udp\", \"8.8.8.8:80\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer func() {\n\t\tif closeErr := conn.Close(); closeErr != nil {\n\t\t\tlog.Warnf(\"Failed to close UDP connection: %v\", closeErr)\n\t\t}\n\t}()\n\n\tlocalAddr, ok := conn.LocalAddr().(*net.UDPAddr)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"could not assert UDP address type\")\n\t}\n\n\treturn localAddr.IP.String(), nil\n}\n\n// GetIPAddress attempts to find the best-available IP address.\n// It first tries to get the public IP address, and if that fails,\n// it falls back to getting the local outbound IP address.\n//\n// Returns:\n//   - string: The determined IP address (preferring public IPv4)\nfunc GetIPAddress() string {\n\tpublicIP, err := getPublicIP()\n\tif err == nil {\n\t\tlog.Debugf(\"Public IP detected: %s\", publicIP)\n\t\treturn publicIP\n\t}\n\tlog.Warnf(\"Failed to get public IP, falling back to outbound IP: %v\", err)\n\toutboundIP, err := getOutboundIP()\n\tif err == nil {\n\t\tlog.Debugf(\"Outbound IP detected: %s\", outboundIP)\n\t\treturn outboundIP\n\t}\n\tlog.Errorf(\"Failed to get any IP address: %v\", err)\n\treturn \"127.0.0.1\" // Fallback\n}\n\n// PrintSSHTunnelInstructions detects the IP address and prints SSH tunnel instructions\n// for the user to connect to the local OAuth callback server from a remote machine.\n//\n// Parameters:\n//   - port: The local port number for the SSH tunnel\nfunc PrintSSHTunnelInstructions(port int) {\n\tipAddress := GetIPAddress()\n\tborder := \"================================================================================\"\n\tfmt.Println(\"To authenticate from a remote machine, an SSH tunnel may be required.\")\n\tfmt.Println(border)\n\tfmt.Println(\"  Run one of the following commands on your local machine (NOT the server):\")\n\tfmt.Println()\n\tfmt.Printf(\"  # Standard SSH command (assumes SSH port 22):\\n\")\n\tfmt.Printf(\"  ssh -L %d:127.0.0.1:%d root@%s -p 22\\n\", port, port, ipAddress)\n\tfmt.Println()\n\tfmt.Printf(\"  # If using an SSH key (assumes SSH port 22):\\n\")\n\tfmt.Printf(\"  ssh -i <path_to_your_key> -L %d:127.0.0.1:%d root@%s -p 22\\n\", port, port, ipAddress)\n\tfmt.Println()\n\tfmt.Println(\"  NOTE: If your server's SSH port is not 22, please modify the '-p 22' part accordingly.\")\n\tfmt.Println(border)\n}\n"
  },
  {
    "path": "internal/util/translator.go",
    "content": "// Package util provides utility functions for the CLI Proxy API server.\n// It includes helper functions for JSON manipulation, proxy configuration,\n// and other common operations used across the application.\npackage util\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// Walk recursively traverses a JSON structure to find all occurrences of a specific field.\n// It builds paths to each occurrence and adds them to the provided paths slice.\n//\n// Parameters:\n//   - value: The gjson.Result object to traverse\n//   - path: The current path in the JSON structure (empty string for root)\n//   - field: The field name to search for\n//   - paths: Pointer to a slice where found paths will be stored\n//\n// The function works recursively, building dot-notation paths to each occurrence\n// of the specified field throughout the JSON structure.\nfunc Walk(value gjson.Result, path, field string, paths *[]string) {\n\tswitch value.Type {\n\tcase gjson.JSON:\n\t\t// For JSON objects and arrays, iterate through each child\n\t\tvalue.ForEach(func(key, val gjson.Result) bool {\n\t\t\tvar childPath string\n\t\t\t// Escape special characters for gjson/sjson path syntax\n\t\t\t// . -> \\.\n\t\t\t// * -> \\*\n\t\t\t// ? -> \\?\n\t\t\tkeyStr := key.String()\n\t\t\tsafeKey := escapeGJSONPathKey(keyStr)\n\n\t\t\tif path == \"\" {\n\t\t\t\tchildPath = safeKey\n\t\t\t} else {\n\t\t\t\tchildPath = path + \".\" + safeKey\n\t\t\t}\n\t\t\tif keyStr == field {\n\t\t\t\t*paths = append(*paths, childPath)\n\t\t\t}\n\t\t\tWalk(val, childPath, field, paths)\n\t\t\treturn true\n\t\t})\n\tcase gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:\n\t\t// Terminal types - no further traversal needed\n\t}\n}\n\n// RenameKey renames a key in a JSON string by moving its value to a new key path\n// and then deleting the old key path.\n//\n// Parameters:\n//   - jsonStr: The JSON string to modify\n//   - oldKeyPath: The dot-notation path to the key that should be renamed\n//   - newKeyPath: The dot-notation path where the value should be moved to\n//\n// Returns:\n//   - string: The modified JSON string with the key renamed\n//   - error: An error if the operation fails\n//\n// The function performs the rename in two steps:\n// 1. Sets the value at the new key path\n// 2. Deletes the old key path\nfunc RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {\n\tvalue := gjson.Get(jsonStr, oldKeyPath)\n\n\tif !value.Exists() {\n\t\treturn \"\", fmt.Errorf(\"old key '%s' does not exist\", oldKeyPath)\n\t}\n\n\tinterimJson, err := sjson.SetRaw(jsonStr, newKeyPath, value.Raw)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to set new key '%s': %w\", newKeyPath, err)\n\t}\n\n\tfinalJson, err := sjson.Delete(interimJson, oldKeyPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to delete old key '%s': %w\", oldKeyPath, err)\n\t}\n\n\treturn finalJson, nil\n}\n\n// FixJSON converts non-standard JSON that uses single quotes for strings into\n// RFC 8259-compliant JSON by converting those single-quoted strings to\n// double-quoted strings with proper escaping.\n//\n// Examples:\n//\n//\t{'a': 1, 'b': '2'}      => {\"a\": 1, \"b\": \"2\"}\n//\t{\"t\": 'He said \"hi\"'} => {\"t\": \"He said \\\"hi\\\"\"}\n//\n// Rules:\n//   - Existing double-quoted JSON strings are preserved as-is.\n//   - Single-quoted strings are converted to double-quoted strings.\n//   - Inside converted strings, any double quote is escaped (\\\").\n//   - Common backslash escapes (\\n, \\r, \\t, \\b, \\f, \\\\) are preserved.\n//   - \\' inside single-quoted strings becomes a literal ' in the output (no\n//     escaping needed inside double quotes).\n//   - Unicode escapes (\\uXXXX) inside single-quoted strings are forwarded.\n//   - The function does not attempt to fix other non-JSON features beyond quotes.\nfunc FixJSON(input string) string {\n\tvar out bytes.Buffer\n\n\tinDouble := false\n\tinSingle := false\n\tescaped := false // applies within the current string state\n\n\t// Helper to write a rune, escaping double quotes when inside a converted\n\t// single-quoted string (which becomes a double-quoted string in output).\n\twriteConverted := func(r rune) {\n\t\tif r == '\"' {\n\t\t\tout.WriteByte('\\\\')\n\t\t\tout.WriteByte('\"')\n\t\t\treturn\n\t\t}\n\t\tout.WriteRune(r)\n\t}\n\n\trunes := []rune(input)\n\tfor i := 0; i < len(runes); i++ {\n\t\tr := runes[i]\n\n\t\tif inDouble {\n\t\t\tout.WriteRune(r)\n\t\t\tif escaped {\n\t\t\t\t// end of escape sequence in a standard JSON string\n\t\t\t\tescaped = false\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif r == '\\\\' {\n\t\t\t\tescaped = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif r == '\"' {\n\t\t\t\tinDouble = false\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif inSingle {\n\t\t\tif escaped {\n\t\t\t\t// Handle common escape sequences after a backslash within a\n\t\t\t\t// single-quoted string\n\t\t\t\tescaped = false\n\t\t\t\tswitch r {\n\t\t\t\tcase 'n', 'r', 't', 'b', 'f', '/', '\"':\n\t\t\t\t\t// Keep the backslash and the character (except for '\"' which\n\t\t\t\t\t// rarely appears, but if it does, keep as \\\" to remain valid)\n\t\t\t\t\tout.WriteByte('\\\\')\n\t\t\t\t\tout.WriteRune(r)\n\t\t\t\tcase '\\\\':\n\t\t\t\t\tout.WriteByte('\\\\')\n\t\t\t\t\tout.WriteByte('\\\\')\n\t\t\t\tcase '\\'':\n\t\t\t\t\t// \\' inside single-quoted becomes a literal '\n\t\t\t\t\tout.WriteRune('\\'')\n\t\t\t\tcase 'u':\n\t\t\t\t\t// Forward \\uXXXX if possible\n\t\t\t\t\tout.WriteByte('\\\\')\n\t\t\t\t\tout.WriteByte('u')\n\t\t\t\t\t// Copy up to next 4 hex digits if present\n\t\t\t\t\tfor k := 0; k < 4 && i+1 < len(runes); k++ {\n\t\t\t\t\t\tpeek := runes[i+1]\n\t\t\t\t\t\t// simple hex check\n\t\t\t\t\t\tif (peek >= '0' && peek <= '9') || (peek >= 'a' && peek <= 'f') || (peek >= 'A' && peek <= 'F') {\n\t\t\t\t\t\t\tout.WriteRune(peek)\n\t\t\t\t\t\t\ti++\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\t// Unknown escape: preserve the backslash and the char\n\t\t\t\t\tout.WriteByte('\\\\')\n\t\t\t\t\tout.WriteRune(r)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif r == '\\\\' { // start escape sequence\n\t\t\t\tescaped = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif r == '\\'' { // end of single-quoted string\n\t\t\t\tout.WriteByte('\"')\n\t\t\t\tinSingle = false\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// regular char inside converted string; escape double quotes\n\t\t\twriteConverted(r)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Outside any string\n\t\tif r == '\"' {\n\t\t\tinDouble = true\n\t\t\tout.WriteRune(r)\n\t\t\tcontinue\n\t\t}\n\t\tif r == '\\'' { // start of non-standard single-quoted string\n\t\t\tinSingle = true\n\t\t\tout.WriteByte('\"')\n\t\t\tcontinue\n\t\t}\n\t\tout.WriteRune(r)\n\t}\n\n\t// If input ended while still inside a single-quoted string, close it to\n\t// produce the best-effort valid JSON.\n\tif inSingle {\n\t\tout.WriteByte('\"')\n\t}\n\n\treturn out.String()\n}\n\nfunc CanonicalToolName(name string) string {\n\tcanonical := strings.TrimSpace(name)\n\tcanonical = strings.TrimLeft(canonical, \"_\")\n\treturn strings.ToLower(canonical)\n}\n\n// ToolNameMapFromClaudeRequest returns a canonical-name -> original-name map extracted from a Claude request.\n// It is used to restore exact tool name casing for clients that require strict tool name matching (e.g. Claude Code).\nfunc ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string {\n\tif len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {\n\t\treturn nil\n\t}\n\n\ttools := gjson.GetBytes(rawJSON, \"tools\")\n\tif !tools.Exists() || !tools.IsArray() {\n\t\treturn nil\n\t}\n\n\ttoolResults := tools.Array()\n\tout := make(map[string]string, len(toolResults))\n\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\tname := strings.TrimSpace(tool.Get(\"name\").String())\n\t\tif name == \"\" {\n\t\t\treturn true\n\t\t}\n\t\tkey := CanonicalToolName(name)\n\t\tif key == \"\" {\n\t\t\treturn true\n\t\t}\n\t\tif _, exists := out[key]; !exists {\n\t\t\tout[key] = name\n\t\t}\n\t\treturn true\n\t})\n\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc MapToolName(toolNameMap map[string]string, name string) string {\n\tif name == \"\" || toolNameMap == nil {\n\t\treturn name\n\t}\n\tif mapped, ok := toolNameMap[CanonicalToolName(name)]; ok && mapped != \"\" {\n\t\treturn mapped\n\t}\n\treturn name\n}\n"
  },
  {
    "path": "internal/util/util.go",
    "content": "// Package util provides utility functions for the CLI Proxy API server.\n// It includes helper functions for logging configuration, file system operations,\n// and other common utilities used throughout the application.\npackage util\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nvar functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`)\n\n// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI.\n// It replaces invalid characters with underscores, ensures it starts with a letter or underscore,\n// and truncates it to 64 characters if necessary.\n// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _.\nfunc SanitizeFunctionName(name string) string {\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Replace invalid characters with underscore\n\tsanitized := functionNameSanitizer.ReplaceAllString(name, \"_\")\n\n\t// Ensure it starts with a letter or underscore\n\t// Re-reading requirements: Must start with a letter or an underscore.\n\tif len(sanitized) > 0 {\n\t\tfirst := sanitized[0]\n\t\tif !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {\n\t\t\t// If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash),\n\t\t\t// we must prepend an underscore.\n\n\t\t\t// To stay within the 64-character limit while prepending, we must truncate first.\n\t\t\tif len(sanitized) >= 64 {\n\t\t\t\tsanitized = sanitized[:63]\n\t\t\t}\n\t\t\tsanitized = \"_\" + sanitized\n\t\t}\n\t} else {\n\t\tsanitized = \"_\"\n\t}\n\n\t// Truncate to 64 characters\n\tif len(sanitized) > 64 {\n\t\tsanitized = sanitized[:64]\n\t}\n\treturn sanitized\n}\n\n// SetLogLevel configures the logrus log level based on the configuration.\n// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.\nfunc SetLogLevel(cfg *config.Config) {\n\tcurrentLevel := log.GetLevel()\n\tvar newLevel log.Level\n\tif cfg.Debug {\n\t\tnewLevel = log.DebugLevel\n\t} else {\n\t\tnewLevel = log.InfoLevel\n\t}\n\n\tif currentLevel != newLevel {\n\t\tlog.SetLevel(newLevel)\n\t\tlog.Infof(\"log level changed from %s to %s (debug=%t)\", currentLevel, newLevel, cfg.Debug)\n\t}\n}\n\n// ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app.\n// It expands a leading tilde (~) to the user's home directory and returns a cleaned path.\nfunc ResolveAuthDir(authDir string) (string, error) {\n\tif authDir == \"\" {\n\t\treturn \"\", nil\n\t}\n\tif strings.HasPrefix(authDir, \"~\") {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"resolve auth dir: %w\", err)\n\t\t}\n\t\tremainder := strings.TrimPrefix(authDir, \"~\")\n\t\tremainder = strings.TrimLeft(remainder, \"/\\\\\")\n\t\tif remainder == \"\" {\n\t\t\treturn filepath.Clean(home), nil\n\t\t}\n\t\tnormalized := strings.ReplaceAll(remainder, \"\\\\\", \"/\")\n\t\treturn filepath.Clean(filepath.Join(home, filepath.FromSlash(normalized))), nil\n\t}\n\treturn filepath.Clean(authDir), nil\n}\n\n// CountAuthFiles returns the number of auth records available through the provided Store.\n// For filesystem-backed stores, this reflects the number of JSON auth files under the configured directory.\nfunc CountAuthFiles[T any](ctx context.Context, store interface {\n\tList(context.Context) ([]T, error)\n}) int {\n\tif store == nil {\n\t\treturn 0\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tentries, err := store.List(ctx)\n\tif err != nil {\n\t\tlog.Debugf(\"countAuthFiles: failed to list auth records: %v\", err)\n\t\treturn 0\n\t}\n\treturn len(entries)\n}\n\n// WritablePath returns the cleaned WRITABLE_PATH environment variable when it is set.\n// It accepts both uppercase and lowercase variants for compatibility with existing conventions.\nfunc WritablePath() string {\n\tfor _, key := range []string{\"WRITABLE_PATH\", \"writable_path\"} {\n\t\tif value, ok := os.LookupEnv(key); ok {\n\t\t\ttrimmed := strings.TrimSpace(value)\n\t\t\tif trimmed != \"\" {\n\t\t\t\treturn filepath.Clean(trimmed)\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/watcher/clients.go",
    "content": "// clients.go implements watcher client lifecycle logic and persistence helpers.\n// It reloads clients, handles incremental auth file changes, and persists updates when supported.\npackage watcher\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nfunc (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string, forceAuthRefresh bool) {\n\tlog.Debugf(\"starting full client load process\")\n\n\tw.clientsMutex.RLock()\n\tcfg := w.config\n\tw.clientsMutex.RUnlock()\n\n\tif cfg == nil {\n\t\tlog.Error(\"config is nil, cannot reload clients\")\n\t\treturn\n\t}\n\n\tif len(affectedOAuthProviders) > 0 {\n\t\tw.clientsMutex.Lock()\n\t\tif w.currentAuths != nil {\n\t\t\tfiltered := make(map[string]*coreauth.Auth, len(w.currentAuths))\n\t\t\tfor id, auth := range w.currentAuths {\n\t\t\t\tif auth == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\t\t\t\tif _, match := matchProvider(provider, affectedOAuthProviders); match {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfiltered[id] = auth\n\t\t\t}\n\t\t\tw.currentAuths = filtered\n\t\t\tlog.Debugf(\"applying oauth-excluded-models to providers %v\", affectedOAuthProviders)\n\t\t} else {\n\t\t\tw.currentAuths = nil\n\t\t}\n\t\tw.clientsMutex.Unlock()\n\t}\n\n\tgeminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg)\n\ttotalAPIKeyClients := geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount\n\tlog.Debugf(\"loaded %d API key clients\", totalAPIKeyClients)\n\n\tvar authFileCount int\n\tif rescanAuth {\n\t\tauthFileCount = w.loadFileClients(cfg)\n\t\tlog.Debugf(\"loaded %d file-based clients\", authFileCount)\n\t} else {\n\t\tw.clientsMutex.RLock()\n\t\tauthFileCount = len(w.lastAuthHashes)\n\t\tw.clientsMutex.RUnlock()\n\t\tlog.Debugf(\"skipping auth directory rescan; retaining %d existing auth files\", authFileCount)\n\t}\n\n\tif rescanAuth {\n\t\tw.clientsMutex.Lock()\n\n\t\tw.lastAuthHashes = make(map[string]string)\n\t\tw.lastAuthContents = make(map[string]*coreauth.Auth)\n\t\tw.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)\n\t\tif resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {\n\t\t\tlog.Errorf(\"failed to resolve auth directory for hash cache: %v\", errResolveAuthDir)\n\t\t} else if resolvedAuthDir != \"\" {\n\t\t\t_ = filepath.Walk(resolvedAuthDir, func(path string, info fs.FileInfo, err error) error {\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), \".json\") {\n\t\t\t\t\tif data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 {\n\t\t\t\t\t\tsum := sha256.Sum256(data)\n\t\t\t\t\t\tnormalizedPath := w.normalizeAuthPath(path)\n\t\t\t\t\t\tw.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])\n\t\t\t\t\t\t// Parse and cache auth content for future diff comparisons\n\t\t\t\t\t\tvar auth coreauth.Auth\n\t\t\t\t\t\tif errParse := json.Unmarshal(data, &auth); errParse == nil {\n\t\t\t\t\t\t\tw.lastAuthContents[normalizedPath] = &auth\n\t\t\t\t\t\t}\n\t\t\t\t\t\tctx := &synthesizer.SynthesisContext{\n\t\t\t\t\t\t\tConfig:      cfg,\n\t\t\t\t\t\t\tAuthDir:     resolvedAuthDir,\n\t\t\t\t\t\t\tNow:         time.Now(),\n\t\t\t\t\t\t\tIDGenerator: synthesizer.NewStableIDGenerator(),\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif generated := synthesizer.SynthesizeAuthFile(ctx, path, data); len(generated) > 0 {\n\t\t\t\t\t\t\tif pathAuths := authSliceToMap(generated); len(pathAuths) > 0 {\n\t\t\t\t\t\t\t\tw.fileAuthsByPath[normalizedPath] = pathAuths\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}\n\t\tw.clientsMutex.Unlock()\n\t}\n\n\ttotalNewClients := authFileCount + geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount\n\n\tif w.reloadCallback != nil {\n\t\tlog.Debugf(\"triggering server update callback before auth refresh\")\n\t\tw.reloadCallback(cfg)\n\t}\n\n\tw.refreshAuthState(forceAuthRefresh)\n\n\tlog.Infof(\"full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\",\n\t\ttotalNewClients,\n\t\tauthFileCount,\n\t\tgeminiAPIKeyCount,\n\t\tvertexCompatAPIKeyCount,\n\t\tclaudeAPIKeyCount,\n\t\tcodexAPIKeyCount,\n\t\topenAICompatCount,\n\t)\n}\n\nfunc (w *Watcher) addOrUpdateClient(path string) {\n\tdata, errRead := os.ReadFile(path)\n\tif errRead != nil {\n\t\tlog.Errorf(\"failed to read auth file %s: %v\", filepath.Base(path), errRead)\n\t\treturn\n\t}\n\tif len(data) == 0 {\n\t\tlog.Debugf(\"ignoring empty auth file: %s\", filepath.Base(path))\n\t\treturn\n\t}\n\n\tsum := sha256.Sum256(data)\n\tcurHash := hex.EncodeToString(sum[:])\n\tnormalized := w.normalizeAuthPath(path)\n\n\t// Parse new auth content for diff comparison\n\tvar newAuth coreauth.Auth\n\tif errParse := json.Unmarshal(data, &newAuth); errParse != nil {\n\t\tlog.Errorf(\"failed to parse auth file %s: %v\", filepath.Base(path), errParse)\n\t\treturn\n\t}\n\n\tw.clientsMutex.Lock()\n\tif w.config == nil {\n\t\tlog.Error(\"config is nil, cannot add or update client\")\n\t\tw.clientsMutex.Unlock()\n\t\treturn\n\t}\n\tif w.fileAuthsByPath == nil {\n\t\tw.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)\n\t}\n\tif prev, ok := w.lastAuthHashes[normalized]; ok && prev == curHash {\n\t\tlog.Debugf(\"auth file unchanged (hash match), skipping reload: %s\", filepath.Base(path))\n\t\tw.clientsMutex.Unlock()\n\t\treturn\n\t}\n\n\t// Get old auth for diff comparison\n\tvar oldAuth *coreauth.Auth\n\tif w.lastAuthContents != nil {\n\t\toldAuth = w.lastAuthContents[normalized]\n\t}\n\n\t// Compute and log field changes\n\tif changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {\n\t\tlog.Debugf(\"auth field changes for %s:\", filepath.Base(path))\n\t\tfor _, c := range changes {\n\t\t\tlog.Debugf(\"  %s\", c)\n\t\t}\n\t}\n\n\t// Update caches\n\tw.lastAuthHashes[normalized] = curHash\n\tif w.lastAuthContents == nil {\n\t\tw.lastAuthContents = make(map[string]*coreauth.Auth)\n\t}\n\tw.lastAuthContents[normalized] = &newAuth\n\n\toldByID := make(map[string]*coreauth.Auth, len(w.fileAuthsByPath[normalized]))\n\tfor id, a := range w.fileAuthsByPath[normalized] {\n\t\toldByID[id] = a\n\t}\n\n\t// Build synthesized auth entries for this single file only.\n\tsctx := &synthesizer.SynthesisContext{\n\t\tConfig:      w.config,\n\t\tAuthDir:     w.authDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: synthesizer.NewStableIDGenerator(),\n\t}\n\tgenerated := synthesizer.SynthesizeAuthFile(sctx, path, data)\n\tnewByID := authSliceToMap(generated)\n\tif len(newByID) > 0 {\n\t\tw.fileAuthsByPath[normalized] = newByID\n\t} else {\n\t\tdelete(w.fileAuthsByPath, normalized)\n\t}\n\tupdates := w.computePerPathUpdatesLocked(oldByID, newByID)\n\tw.clientsMutex.Unlock()\n\n\tw.persistAuthAsync(fmt.Sprintf(\"Sync auth %s\", filepath.Base(path)), path)\n\tw.dispatchAuthUpdates(updates)\n}\n\nfunc (w *Watcher) removeClient(path string) {\n\tnormalized := w.normalizeAuthPath(path)\n\tw.clientsMutex.Lock()\n\toldByID := make(map[string]*coreauth.Auth, len(w.fileAuthsByPath[normalized]))\n\tfor id, a := range w.fileAuthsByPath[normalized] {\n\t\toldByID[id] = a\n\t}\n\tdelete(w.lastAuthHashes, normalized)\n\tdelete(w.lastAuthContents, normalized)\n\tdelete(w.fileAuthsByPath, normalized)\n\n\tupdates := w.computePerPathUpdatesLocked(oldByID, map[string]*coreauth.Auth{})\n\tw.clientsMutex.Unlock()\n\n\tw.persistAuthAsync(fmt.Sprintf(\"Remove auth %s\", filepath.Base(path)), path)\n\tw.dispatchAuthUpdates(updates)\n}\n\nfunc (w *Watcher) computePerPathUpdatesLocked(oldByID, newByID map[string]*coreauth.Auth) []AuthUpdate {\n\tif w.currentAuths == nil {\n\t\tw.currentAuths = make(map[string]*coreauth.Auth)\n\t}\n\tupdates := make([]AuthUpdate, 0, len(oldByID)+len(newByID))\n\tfor id, newAuth := range newByID {\n\t\texisting, ok := w.currentAuths[id]\n\t\tif !ok {\n\t\t\tw.currentAuths[id] = newAuth.Clone()\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: newAuth.Clone()})\n\t\t\tcontinue\n\t\t}\n\t\tif !authEqual(existing, newAuth) {\n\t\t\tw.currentAuths[id] = newAuth.Clone()\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: newAuth.Clone()})\n\t\t}\n\t}\n\tfor id := range oldByID {\n\t\tif _, stillExists := newByID[id]; stillExists {\n\t\t\tcontinue\n\t\t}\n\t\tdelete(w.currentAuths, id)\n\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionDelete, ID: id})\n\t}\n\treturn updates\n}\n\nfunc authSliceToMap(auths []*coreauth.Auth) map[string]*coreauth.Auth {\n\tbyID := make(map[string]*coreauth.Auth, len(auths))\n\tfor _, a := range auths {\n\t\tif a == nil || strings.TrimSpace(a.ID) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tbyID[a.ID] = a\n\t}\n\treturn byID\n}\n\nfunc (w *Watcher) loadFileClients(cfg *config.Config) int {\n\tauthFileCount := 0\n\tsuccessfulAuthCount := 0\n\n\tauthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir)\n\tif errResolveAuthDir != nil {\n\t\tlog.Errorf(\"failed to resolve auth directory: %v\", errResolveAuthDir)\n\t\treturn 0\n\t}\n\tif authDir == \"\" {\n\t\treturn 0\n\t}\n\n\terrWalk := filepath.Walk(authDir, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"error accessing path %s: %v\", path, err)\n\t\t\treturn err\n\t\t}\n\t\tif !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), \".json\") {\n\t\t\tauthFileCount++\n\t\t\tlog.Debugf(\"processing auth file %d: %s\", authFileCount, filepath.Base(path))\n\t\t\tif data, errCreate := os.ReadFile(path); errCreate == nil && len(data) > 0 {\n\t\t\t\tsuccessfulAuthCount++\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif errWalk != nil {\n\t\tlog.Errorf(\"error walking auth directory: %v\", errWalk)\n\t}\n\tlog.Debugf(\"auth directory scan complete - found %d .json files, %d readable\", authFileCount, successfulAuthCount)\n\treturn authFileCount\n}\n\nfunc BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) {\n\tgeminiAPIKeyCount := 0\n\tvertexCompatAPIKeyCount := 0\n\tclaudeAPIKeyCount := 0\n\tcodexAPIKeyCount := 0\n\topenAICompatCount := 0\n\n\tif len(cfg.GeminiKey) > 0 {\n\t\tgeminiAPIKeyCount += len(cfg.GeminiKey)\n\t}\n\tif len(cfg.VertexCompatAPIKey) > 0 {\n\t\tvertexCompatAPIKeyCount += len(cfg.VertexCompatAPIKey)\n\t}\n\tif len(cfg.ClaudeKey) > 0 {\n\t\tclaudeAPIKeyCount += len(cfg.ClaudeKey)\n\t}\n\tif len(cfg.CodexKey) > 0 {\n\t\tcodexAPIKeyCount += len(cfg.CodexKey)\n\t}\n\tif len(cfg.OpenAICompatibility) > 0 {\n\t\tfor _, compatConfig := range cfg.OpenAICompatibility {\n\t\t\topenAICompatCount += len(compatConfig.APIKeyEntries)\n\t\t}\n\t}\n\treturn geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount\n}\n\nfunc (w *Watcher) persistConfigAsync() {\n\tif w == nil || w.storePersister == nil {\n\t\treturn\n\t}\n\tgo func() {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tdefer cancel()\n\t\tif err := w.storePersister.PersistConfig(ctx); err != nil {\n\t\t\tlog.Errorf(\"failed to persist config change: %v\", err)\n\t\t}\n\t}()\n}\n\nfunc (w *Watcher) persistAuthAsync(message string, paths ...string) {\n\tif w == nil || w.storePersister == nil {\n\t\treturn\n\t}\n\tfiltered := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tif trimmed := strings.TrimSpace(p); trimmed != \"\" {\n\t\t\tfiltered = append(filtered, trimmed)\n\t\t}\n\t}\n\tif len(filtered) == 0 {\n\t\treturn\n\t}\n\tgo func() {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tdefer cancel()\n\t\tif err := w.storePersister.PersistAuthFiles(ctx, message, filtered...); err != nil {\n\t\t\tlog.Errorf(\"failed to persist auth changes: %v\", err)\n\t\t}\n\t}()\n}\n\nfunc (w *Watcher) stopServerUpdateTimer() {\n\tw.serverUpdateMu.Lock()\n\tdefer w.serverUpdateMu.Unlock()\n\tif w.serverUpdateTimer != nil {\n\t\tw.serverUpdateTimer.Stop()\n\t\tw.serverUpdateTimer = nil\n\t}\n\tw.serverUpdatePend = false\n}\n\nfunc (w *Watcher) triggerServerUpdate(cfg *config.Config) {\n\tif w == nil || w.reloadCallback == nil || cfg == nil {\n\t\treturn\n\t}\n\tif w.stopped.Load() {\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\n\tw.serverUpdateMu.Lock()\n\tif w.serverUpdateLast.IsZero() || now.Sub(w.serverUpdateLast) >= serverUpdateDebounce {\n\t\tw.serverUpdateLast = now\n\t\tif w.serverUpdateTimer != nil {\n\t\t\tw.serverUpdateTimer.Stop()\n\t\t\tw.serverUpdateTimer = nil\n\t\t}\n\t\tw.serverUpdatePend = false\n\t\tw.serverUpdateMu.Unlock()\n\t\tw.reloadCallback(cfg)\n\t\treturn\n\t}\n\n\tif w.serverUpdatePend {\n\t\tw.serverUpdateMu.Unlock()\n\t\treturn\n\t}\n\n\tdelay := serverUpdateDebounce - now.Sub(w.serverUpdateLast)\n\tif delay < 10*time.Millisecond {\n\t\tdelay = 10 * time.Millisecond\n\t}\n\tw.serverUpdatePend = true\n\tif w.serverUpdateTimer != nil {\n\t\tw.serverUpdateTimer.Stop()\n\t\tw.serverUpdateTimer = nil\n\t}\n\tvar timer *time.Timer\n\ttimer = time.AfterFunc(delay, func() {\n\t\tif w.stopped.Load() {\n\t\t\treturn\n\t\t}\n\t\tw.clientsMutex.RLock()\n\t\tlatestCfg := w.config\n\t\tw.clientsMutex.RUnlock()\n\n\t\tw.serverUpdateMu.Lock()\n\t\tif w.serverUpdateTimer != timer || !w.serverUpdatePend {\n\t\t\tw.serverUpdateMu.Unlock()\n\t\t\treturn\n\t\t}\n\t\tw.serverUpdateTimer = nil\n\t\tw.serverUpdatePend = false\n\t\tif latestCfg == nil || w.reloadCallback == nil || w.stopped.Load() {\n\t\t\tw.serverUpdateMu.Unlock()\n\t\t\treturn\n\t\t}\n\n\t\tw.serverUpdateLast = time.Now()\n\t\tw.serverUpdateMu.Unlock()\n\t\tw.reloadCallback(latestCfg)\n\t})\n\tw.serverUpdateTimer = timer\n\tw.serverUpdateMu.Unlock()\n}\n"
  },
  {
    "path": "internal/watcher/config_reload.go",
    "content": "// config_reload.go implements debounced configuration hot reload.\n// It detects material changes and reloads clients when the config changes.\npackage watcher\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"os\"\n\t\"reflect\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\t\"gopkg.in/yaml.v3\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nfunc (w *Watcher) stopConfigReloadTimer() {\n\tw.configReloadMu.Lock()\n\tif w.configReloadTimer != nil {\n\t\tw.configReloadTimer.Stop()\n\t\tw.configReloadTimer = nil\n\t}\n\tw.configReloadMu.Unlock()\n}\n\nfunc (w *Watcher) scheduleConfigReload() {\n\tw.configReloadMu.Lock()\n\tdefer w.configReloadMu.Unlock()\n\tif w.configReloadTimer != nil {\n\t\tw.configReloadTimer.Stop()\n\t}\n\tw.configReloadTimer = time.AfterFunc(configReloadDebounce, func() {\n\t\tw.configReloadMu.Lock()\n\t\tw.configReloadTimer = nil\n\t\tw.configReloadMu.Unlock()\n\t\tw.reloadConfigIfChanged()\n\t})\n}\n\nfunc (w *Watcher) reloadConfigIfChanged() {\n\tdata, err := os.ReadFile(w.configPath)\n\tif err != nil {\n\t\tlog.Errorf(\"failed to read config file for hash check: %v\", err)\n\t\treturn\n\t}\n\tif len(data) == 0 {\n\t\tlog.Debugf(\"ignoring empty config file write event\")\n\t\treturn\n\t}\n\tsum := sha256.Sum256(data)\n\tnewHash := hex.EncodeToString(sum[:])\n\n\tw.clientsMutex.RLock()\n\tcurrentHash := w.lastConfigHash\n\tw.clientsMutex.RUnlock()\n\n\tif currentHash != \"\" && currentHash == newHash {\n\t\tlog.Debugf(\"config file content unchanged (hash match), skipping reload\")\n\t\treturn\n\t}\n\tlog.Infof(\"config file changed, reloading: %s\", w.configPath)\n\tif w.reloadConfig() {\n\t\tfinalHash := newHash\n\t\tif updatedData, errRead := os.ReadFile(w.configPath); errRead == nil && len(updatedData) > 0 {\n\t\t\tsumUpdated := sha256.Sum256(updatedData)\n\t\t\tfinalHash = hex.EncodeToString(sumUpdated[:])\n\t\t} else if errRead != nil {\n\t\t\tlog.WithError(errRead).Debug(\"failed to compute updated config hash after reload\")\n\t\t}\n\t\tw.clientsMutex.Lock()\n\t\tw.lastConfigHash = finalHash\n\t\tw.clientsMutex.Unlock()\n\t\tw.persistConfigAsync()\n\t}\n}\n\nfunc (w *Watcher) reloadConfig() bool {\n\tlog.Debug(\"=========================== CONFIG RELOAD ============================\")\n\tlog.Debugf(\"starting config reload from: %s\", w.configPath)\n\n\tnewConfig, errLoadConfig := config.LoadConfig(w.configPath)\n\tif errLoadConfig != nil {\n\t\tlog.Errorf(\"failed to reload config: %v\", errLoadConfig)\n\t\treturn false\n\t}\n\n\tif w.mirroredAuthDir != \"\" {\n\t\tnewConfig.AuthDir = w.mirroredAuthDir\n\t} else {\n\t\tif resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(newConfig.AuthDir); errResolveAuthDir != nil {\n\t\t\tlog.Errorf(\"failed to resolve auth directory from config: %v\", errResolveAuthDir)\n\t\t} else {\n\t\t\tnewConfig.AuthDir = resolvedAuthDir\n\t\t}\n\t}\n\n\tw.clientsMutex.Lock()\n\tvar oldConfig *config.Config\n\t_ = yaml.Unmarshal(w.oldConfigYaml, &oldConfig)\n\tw.oldConfigYaml, _ = yaml.Marshal(newConfig)\n\tw.config = newConfig\n\tw.clientsMutex.Unlock()\n\n\tvar affectedOAuthProviders []string\n\tif oldConfig != nil {\n\t\t_, affectedOAuthProviders = diff.DiffOAuthExcludedModelChanges(oldConfig.OAuthExcludedModels, newConfig.OAuthExcludedModels)\n\t}\n\n\tutil.SetLogLevel(newConfig)\n\tif oldConfig != nil && oldConfig.Debug != newConfig.Debug {\n\t\tlog.Debugf(\"log level updated - debug mode changed from %t to %t\", oldConfig.Debug, newConfig.Debug)\n\t}\n\n\tif oldConfig != nil {\n\t\tdetails := diff.BuildConfigChangeDetails(oldConfig, newConfig)\n\t\tif len(details) > 0 {\n\t\t\tlog.Debugf(\"config changes detected:\")\n\t\t\tfor _, d := range details {\n\t\t\t\tlog.Debugf(\"  %s\", d)\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Debugf(\"no material config field changes detected\")\n\t\t}\n\t}\n\n\tauthDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir\n\tretryConfigChanged := oldConfig != nil && (oldConfig.RequestRetry != newConfig.RequestRetry || oldConfig.MaxRetryInterval != newConfig.MaxRetryInterval || oldConfig.MaxRetryCredentials != newConfig.MaxRetryCredentials)\n\tforceAuthRefresh := oldConfig != nil && (oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix || !reflect.DeepEqual(oldConfig.OAuthModelAlias, newConfig.OAuthModelAlias) || retryConfigChanged)\n\n\tlog.Infof(\"config successfully reloaded, triggering client reload\")\n\tw.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh)\n\treturn true\n}\n"
  },
  {
    "path": "internal/watcher/diff/auth_diff.go",
    "content": "// auth_diff.go computes human-readable diffs for auth file field changes.\npackage diff\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes.\n// Only prefix, proxy_url, and disabled fields are tracked; sensitive data is never printed.\nfunc BuildAuthChangeDetails(oldAuth, newAuth *coreauth.Auth) []string {\n\tchanges := make([]string, 0, 3)\n\n\t// Handle nil cases by using empty Auth as default\n\tif oldAuth == nil {\n\t\toldAuth = &coreauth.Auth{}\n\t}\n\tif newAuth == nil {\n\t\treturn changes\n\t}\n\n\t// Compare prefix\n\toldPrefix := strings.TrimSpace(oldAuth.Prefix)\n\tnewPrefix := strings.TrimSpace(newAuth.Prefix)\n\tif oldPrefix != newPrefix {\n\t\tchanges = append(changes, fmt.Sprintf(\"prefix: %s -> %s\", oldPrefix, newPrefix))\n\t}\n\n\t// Compare proxy_url (redacted)\n\toldProxy := strings.TrimSpace(oldAuth.ProxyURL)\n\tnewProxy := strings.TrimSpace(newAuth.ProxyURL)\n\tif oldProxy != newProxy {\n\t\tchanges = append(changes, fmt.Sprintf(\"proxy_url: %s -> %s\", formatProxyURL(oldProxy), formatProxyURL(newProxy)))\n\t}\n\n\t// Compare disabled\n\tif oldAuth.Disabled != newAuth.Disabled {\n\t\tchanges = append(changes, fmt.Sprintf(\"disabled: %t -> %t\", oldAuth.Disabled, newAuth.Disabled))\n\t}\n\n\treturn changes\n}\n"
  },
  {
    "path": "internal/watcher/diff/config_diff.go",
    "content": "package diff\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// BuildConfigChangeDetails computes a redacted, human-readable list of config changes.\n// Secrets are never printed; only structural or non-sensitive fields are surfaced.\nfunc BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {\n\tchanges := make([]string, 0, 16)\n\tif oldCfg == nil || newCfg == nil {\n\t\treturn changes\n\t}\n\n\t// Simple scalars\n\tif oldCfg.Port != newCfg.Port {\n\t\tchanges = append(changes, fmt.Sprintf(\"port: %d -> %d\", oldCfg.Port, newCfg.Port))\n\t}\n\tif oldCfg.AuthDir != newCfg.AuthDir {\n\t\tchanges = append(changes, fmt.Sprintf(\"auth-dir: %s -> %s\", oldCfg.AuthDir, newCfg.AuthDir))\n\t}\n\tif oldCfg.Debug != newCfg.Debug {\n\t\tchanges = append(changes, fmt.Sprintf(\"debug: %t -> %t\", oldCfg.Debug, newCfg.Debug))\n\t}\n\tif oldCfg.Pprof.Enable != newCfg.Pprof.Enable {\n\t\tchanges = append(changes, fmt.Sprintf(\"pprof.enable: %t -> %t\", oldCfg.Pprof.Enable, newCfg.Pprof.Enable))\n\t}\n\tif strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) {\n\t\tchanges = append(changes, fmt.Sprintf(\"pprof.addr: %s -> %s\", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr)))\n\t}\n\tif oldCfg.LoggingToFile != newCfg.LoggingToFile {\n\t\tchanges = append(changes, fmt.Sprintf(\"logging-to-file: %t -> %t\", oldCfg.LoggingToFile, newCfg.LoggingToFile))\n\t}\n\tif oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled {\n\t\tchanges = append(changes, fmt.Sprintf(\"usage-statistics-enabled: %t -> %t\", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled))\n\t}\n\tif oldCfg.DisableCooling != newCfg.DisableCooling {\n\t\tchanges = append(changes, fmt.Sprintf(\"disable-cooling: %t -> %t\", oldCfg.DisableCooling, newCfg.DisableCooling))\n\t}\n\tif oldCfg.RequestLog != newCfg.RequestLog {\n\t\tchanges = append(changes, fmt.Sprintf(\"request-log: %t -> %t\", oldCfg.RequestLog, newCfg.RequestLog))\n\t}\n\tif oldCfg.LogsMaxTotalSizeMB != newCfg.LogsMaxTotalSizeMB {\n\t\tchanges = append(changes, fmt.Sprintf(\"logs-max-total-size-mb: %d -> %d\", oldCfg.LogsMaxTotalSizeMB, newCfg.LogsMaxTotalSizeMB))\n\t}\n\tif oldCfg.ErrorLogsMaxFiles != newCfg.ErrorLogsMaxFiles {\n\t\tchanges = append(changes, fmt.Sprintf(\"error-logs-max-files: %d -> %d\", oldCfg.ErrorLogsMaxFiles, newCfg.ErrorLogsMaxFiles))\n\t}\n\tif oldCfg.RequestRetry != newCfg.RequestRetry {\n\t\tchanges = append(changes, fmt.Sprintf(\"request-retry: %d -> %d\", oldCfg.RequestRetry, newCfg.RequestRetry))\n\t}\n\tif oldCfg.MaxRetryCredentials != newCfg.MaxRetryCredentials {\n\t\tchanges = append(changes, fmt.Sprintf(\"max-retry-credentials: %d -> %d\", oldCfg.MaxRetryCredentials, newCfg.MaxRetryCredentials))\n\t}\n\tif oldCfg.MaxRetryInterval != newCfg.MaxRetryInterval {\n\t\tchanges = append(changes, fmt.Sprintf(\"max-retry-interval: %d -> %d\", oldCfg.MaxRetryInterval, newCfg.MaxRetryInterval))\n\t}\n\tif oldCfg.ProxyURL != newCfg.ProxyURL {\n\t\tchanges = append(changes, fmt.Sprintf(\"proxy-url: %s -> %s\", formatProxyURL(oldCfg.ProxyURL), formatProxyURL(newCfg.ProxyURL)))\n\t}\n\tif oldCfg.WebsocketAuth != newCfg.WebsocketAuth {\n\t\tchanges = append(changes, fmt.Sprintf(\"ws-auth: %t -> %t\", oldCfg.WebsocketAuth, newCfg.WebsocketAuth))\n\t}\n\tif oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix {\n\t\tchanges = append(changes, fmt.Sprintf(\"force-model-prefix: %t -> %t\", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix))\n\t}\n\tif oldCfg.NonStreamKeepAliveInterval != newCfg.NonStreamKeepAliveInterval {\n\t\tchanges = append(changes, fmt.Sprintf(\"nonstream-keepalive-interval: %d -> %d\", oldCfg.NonStreamKeepAliveInterval, newCfg.NonStreamKeepAliveInterval))\n\t}\n\n\t// Quota-exceeded behavior\n\tif oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {\n\t\tchanges = append(changes, fmt.Sprintf(\"quota-exceeded.switch-project: %t -> %t\", oldCfg.QuotaExceeded.SwitchProject, newCfg.QuotaExceeded.SwitchProject))\n\t}\n\tif oldCfg.QuotaExceeded.SwitchPreviewModel != newCfg.QuotaExceeded.SwitchPreviewModel {\n\t\tchanges = append(changes, fmt.Sprintf(\"quota-exceeded.switch-preview-model: %t -> %t\", oldCfg.QuotaExceeded.SwitchPreviewModel, newCfg.QuotaExceeded.SwitchPreviewModel))\n\t}\n\n\tif oldCfg.Routing.Strategy != newCfg.Routing.Strategy {\n\t\tchanges = append(changes, fmt.Sprintf(\"routing.strategy: %s -> %s\", oldCfg.Routing.Strategy, newCfg.Routing.Strategy))\n\t}\n\n\t// API keys (redacted) and counts\n\tif len(oldCfg.APIKeys) != len(newCfg.APIKeys) {\n\t\tchanges = append(changes, fmt.Sprintf(\"api-keys count: %d -> %d\", len(oldCfg.APIKeys), len(newCfg.APIKeys)))\n\t} else if !reflect.DeepEqual(trimStrings(oldCfg.APIKeys), trimStrings(newCfg.APIKeys)) {\n\t\tchanges = append(changes, \"api-keys: values updated (count unchanged, redacted)\")\n\t}\n\tif len(oldCfg.GeminiKey) != len(newCfg.GeminiKey) {\n\t\tchanges = append(changes, fmt.Sprintf(\"gemini-api-key count: %d -> %d\", len(oldCfg.GeminiKey), len(newCfg.GeminiKey)))\n\t} else {\n\t\tfor i := range oldCfg.GeminiKey {\n\t\t\to := oldCfg.GeminiKey[i]\n\t\t\tn := newCfg.GeminiKey[i]\n\t\t\tif strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].base-url: %s -> %s\", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].proxy-url: %s -> %s\", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].prefix: %s -> %s\", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].api-key: updated\", i))\n\t\t\t}\n\t\t\tif !equalStringMap(o.Headers, n.Headers) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].headers: updated\", i))\n\t\t\t}\n\t\t\toldModels := SummarizeGeminiModels(o.Models)\n\t\t\tnewModels := SummarizeGeminiModels(n.Models)\n\t\t\tif oldModels.hash != newModels.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].models: updated (%d -> %d entries)\", i, oldModels.count, newModels.count))\n\t\t\t}\n\t\t\toldExcluded := SummarizeExcludedModels(o.ExcludedModels)\n\t\t\tnewExcluded := SummarizeExcludedModels(n.ExcludedModels)\n\t\t\tif oldExcluded.hash != newExcluded.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"gemini[%d].excluded-models: updated (%d -> %d entries)\", i, oldExcluded.count, newExcluded.count))\n\t\t\t}\n\t\t}\n\t}\n\n\t// Claude keys (do not print key material)\n\tif len(oldCfg.ClaudeKey) != len(newCfg.ClaudeKey) {\n\t\tchanges = append(changes, fmt.Sprintf(\"claude-api-key count: %d -> %d\", len(oldCfg.ClaudeKey), len(newCfg.ClaudeKey)))\n\t} else {\n\t\tfor i := range oldCfg.ClaudeKey {\n\t\t\to := oldCfg.ClaudeKey[i]\n\t\t\tn := newCfg.ClaudeKey[i]\n\t\t\tif strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].base-url: %s -> %s\", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].proxy-url: %s -> %s\", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].prefix: %s -> %s\", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].api-key: updated\", i))\n\t\t\t}\n\t\t\tif !equalStringMap(o.Headers, n.Headers) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].headers: updated\", i))\n\t\t\t}\n\t\t\toldModels := SummarizeClaudeModels(o.Models)\n\t\t\tnewModels := SummarizeClaudeModels(n.Models)\n\t\t\tif oldModels.hash != newModels.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].models: updated (%d -> %d entries)\", i, oldModels.count, newModels.count))\n\t\t\t}\n\t\t\toldExcluded := SummarizeExcludedModels(o.ExcludedModels)\n\t\t\tnewExcluded := SummarizeExcludedModels(n.ExcludedModels)\n\t\t\tif oldExcluded.hash != newExcluded.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].excluded-models: updated (%d -> %d entries)\", i, oldExcluded.count, newExcluded.count))\n\t\t\t}\n\t\t\tif o.Cloak != nil && n.Cloak != nil {\n\t\t\t\tif strings.TrimSpace(o.Cloak.Mode) != strings.TrimSpace(n.Cloak.Mode) {\n\t\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].cloak.mode: %s -> %s\", i, o.Cloak.Mode, n.Cloak.Mode))\n\t\t\t\t}\n\t\t\t\tif o.Cloak.StrictMode != n.Cloak.StrictMode {\n\t\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].cloak.strict-mode: %t -> %t\", i, o.Cloak.StrictMode, n.Cloak.StrictMode))\n\t\t\t\t}\n\t\t\t\tif len(o.Cloak.SensitiveWords) != len(n.Cloak.SensitiveWords) {\n\t\t\t\t\tchanges = append(changes, fmt.Sprintf(\"claude[%d].cloak.sensitive-words: %d -> %d\", i, len(o.Cloak.SensitiveWords), len(n.Cloak.SensitiveWords)))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Codex keys (do not print key material)\n\tif len(oldCfg.CodexKey) != len(newCfg.CodexKey) {\n\t\tchanges = append(changes, fmt.Sprintf(\"codex-api-key count: %d -> %d\", len(oldCfg.CodexKey), len(newCfg.CodexKey)))\n\t} else {\n\t\tfor i := range oldCfg.CodexKey {\n\t\t\to := oldCfg.CodexKey[i]\n\t\t\tn := newCfg.CodexKey[i]\n\t\t\tif strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].base-url: %s -> %s\", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].proxy-url: %s -> %s\", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].prefix: %s -> %s\", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))\n\t\t\t}\n\t\t\tif o.Websockets != n.Websockets {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].websockets: %t -> %t\", i, o.Websockets, n.Websockets))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].api-key: updated\", i))\n\t\t\t}\n\t\t\tif !equalStringMap(o.Headers, n.Headers) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].headers: updated\", i))\n\t\t\t}\n\t\t\toldModels := SummarizeCodexModels(o.Models)\n\t\t\tnewModels := SummarizeCodexModels(n.Models)\n\t\t\tif oldModels.hash != newModels.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].models: updated (%d -> %d entries)\", i, oldModels.count, newModels.count))\n\t\t\t}\n\t\t\toldExcluded := SummarizeExcludedModels(o.ExcludedModels)\n\t\t\tnewExcluded := SummarizeExcludedModels(n.ExcludedModels)\n\t\t\tif oldExcluded.hash != newExcluded.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"codex[%d].excluded-models: updated (%d -> %d entries)\", i, oldExcluded.count, newExcluded.count))\n\t\t\t}\n\t\t}\n\t}\n\n\t// AmpCode settings (redacted where needed)\n\toldAmpURL := strings.TrimSpace(oldCfg.AmpCode.UpstreamURL)\n\tnewAmpURL := strings.TrimSpace(newCfg.AmpCode.UpstreamURL)\n\tif oldAmpURL != newAmpURL {\n\t\tchanges = append(changes, fmt.Sprintf(\"ampcode.upstream-url: %s -> %s\", oldAmpURL, newAmpURL))\n\t}\n\toldAmpKey := strings.TrimSpace(oldCfg.AmpCode.UpstreamAPIKey)\n\tnewAmpKey := strings.TrimSpace(newCfg.AmpCode.UpstreamAPIKey)\n\tswitch {\n\tcase oldAmpKey == \"\" && newAmpKey != \"\":\n\t\tchanges = append(changes, \"ampcode.upstream-api-key: added\")\n\tcase oldAmpKey != \"\" && newAmpKey == \"\":\n\t\tchanges = append(changes, \"ampcode.upstream-api-key: removed\")\n\tcase oldAmpKey != newAmpKey:\n\t\tchanges = append(changes, \"ampcode.upstream-api-key: updated\")\n\t}\n\tif oldCfg.AmpCode.RestrictManagementToLocalhost != newCfg.AmpCode.RestrictManagementToLocalhost {\n\t\tchanges = append(changes, fmt.Sprintf(\"ampcode.restrict-management-to-localhost: %t -> %t\", oldCfg.AmpCode.RestrictManagementToLocalhost, newCfg.AmpCode.RestrictManagementToLocalhost))\n\t}\n\toldMappings := SummarizeAmpModelMappings(oldCfg.AmpCode.ModelMappings)\n\tnewMappings := SummarizeAmpModelMappings(newCfg.AmpCode.ModelMappings)\n\tif oldMappings.hash != newMappings.hash {\n\t\tchanges = append(changes, fmt.Sprintf(\"ampcode.model-mappings: updated (%d -> %d entries)\", oldMappings.count, newMappings.count))\n\t}\n\tif oldCfg.AmpCode.ForceModelMappings != newCfg.AmpCode.ForceModelMappings {\n\t\tchanges = append(changes, fmt.Sprintf(\"ampcode.force-model-mappings: %t -> %t\", oldCfg.AmpCode.ForceModelMappings, newCfg.AmpCode.ForceModelMappings))\n\t}\n\toldUpstreamAPIKeysCount := len(oldCfg.AmpCode.UpstreamAPIKeys)\n\tnewUpstreamAPIKeysCount := len(newCfg.AmpCode.UpstreamAPIKeys)\n\tif !equalUpstreamAPIKeys(oldCfg.AmpCode.UpstreamAPIKeys, newCfg.AmpCode.UpstreamAPIKeys) {\n\t\tchanges = append(changes, fmt.Sprintf(\"ampcode.upstream-api-keys: updated (%d -> %d entries)\", oldUpstreamAPIKeysCount, newUpstreamAPIKeysCount))\n\t}\n\n\tif entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 {\n\t\tchanges = append(changes, entries...)\n\t}\n\tif entries, _ := DiffOAuthModelAliasChanges(oldCfg.OAuthModelAlias, newCfg.OAuthModelAlias); len(entries) > 0 {\n\t\tchanges = append(changes, entries...)\n\t}\n\n\t// Remote management (never print the key)\n\tif oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {\n\t\tchanges = append(changes, fmt.Sprintf(\"remote-management.allow-remote: %t -> %t\", oldCfg.RemoteManagement.AllowRemote, newCfg.RemoteManagement.AllowRemote))\n\t}\n\tif oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel {\n\t\tchanges = append(changes, fmt.Sprintf(\"remote-management.disable-control-panel: %t -> %t\", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel))\n\t}\n\toldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository)\n\tnewPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository)\n\tif oldPanelRepo != newPanelRepo {\n\t\tchanges = append(changes, fmt.Sprintf(\"remote-management.panel-github-repository: %s -> %s\", oldPanelRepo, newPanelRepo))\n\t}\n\tif oldCfg.RemoteManagement.SecretKey != newCfg.RemoteManagement.SecretKey {\n\t\tswitch {\n\t\tcase oldCfg.RemoteManagement.SecretKey == \"\" && newCfg.RemoteManagement.SecretKey != \"\":\n\t\t\tchanges = append(changes, \"remote-management.secret-key: created\")\n\t\tcase oldCfg.RemoteManagement.SecretKey != \"\" && newCfg.RemoteManagement.SecretKey == \"\":\n\t\t\tchanges = append(changes, \"remote-management.secret-key: deleted\")\n\t\tdefault:\n\t\t\tchanges = append(changes, \"remote-management.secret-key: updated\")\n\t\t}\n\t}\n\n\t// OpenAI compatibility providers (summarized)\n\tif compat := DiffOpenAICompatibility(oldCfg.OpenAICompatibility, newCfg.OpenAICompatibility); len(compat) > 0 {\n\t\tchanges = append(changes, \"openai-compatibility:\")\n\t\tfor _, c := range compat {\n\t\t\tchanges = append(changes, \"  \"+c)\n\t\t}\n\t}\n\n\t// Vertex-compatible API keys\n\tif len(oldCfg.VertexCompatAPIKey) != len(newCfg.VertexCompatAPIKey) {\n\t\tchanges = append(changes, fmt.Sprintf(\"vertex-api-key count: %d -> %d\", len(oldCfg.VertexCompatAPIKey), len(newCfg.VertexCompatAPIKey)))\n\t} else {\n\t\tfor i := range oldCfg.VertexCompatAPIKey {\n\t\t\to := oldCfg.VertexCompatAPIKey[i]\n\t\t\tn := newCfg.VertexCompatAPIKey[i]\n\t\t\tif strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].base-url: %s -> %s\", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].proxy-url: %s -> %s\", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].prefix: %s -> %s\", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))\n\t\t\t}\n\t\t\tif strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].api-key: updated\", i))\n\t\t\t}\n\t\t\toldModels := SummarizeVertexModels(o.Models)\n\t\t\tnewModels := SummarizeVertexModels(n.Models)\n\t\t\tif oldModels.hash != newModels.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].models: updated (%d -> %d entries)\", i, oldModels.count, newModels.count))\n\t\t\t}\n\t\t\toldExcluded := SummarizeExcludedModels(o.ExcludedModels)\n\t\t\tnewExcluded := SummarizeExcludedModels(n.ExcludedModels)\n\t\t\tif oldExcluded.hash != newExcluded.hash {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].excluded-models: updated (%d -> %d entries)\", i, oldExcluded.count, newExcluded.count))\n\t\t\t}\n\t\t\tif !equalStringMap(o.Headers, n.Headers) {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"vertex[%d].headers: updated\", i))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn changes\n}\n\nfunc trimStrings(in []string) []string {\n\tout := make([]string, len(in))\n\tfor i := range in {\n\t\tout[i] = strings.TrimSpace(in[i])\n\t}\n\treturn out\n}\n\nfunc equalStringMap(a, b map[string]string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor k, v := range a {\n\t\tif b[k] != v {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc formatProxyURL(raw string) string {\n\ttrimmed := strings.TrimSpace(raw)\n\tif trimmed == \"\" {\n\t\treturn \"<none>\"\n\t}\n\tparsed, err := url.Parse(trimmed)\n\tif err != nil {\n\t\treturn \"<redacted>\"\n\t}\n\thost := strings.TrimSpace(parsed.Host)\n\tscheme := strings.TrimSpace(parsed.Scheme)\n\tif host == \"\" {\n\t\t// Allow host:port style without scheme.\n\t\tparsed2, err2 := url.Parse(\"http://\" + trimmed)\n\t\tif err2 == nil {\n\t\t\thost = strings.TrimSpace(parsed2.Host)\n\t\t}\n\t\tscheme = \"\"\n\t}\n\tif host == \"\" {\n\t\treturn \"<redacted>\"\n\t}\n\tif scheme == \"\" {\n\t\treturn host\n\t}\n\treturn scheme + \"://\" + host\n}\n\nfunc equalStringSet(a, b []string) bool {\n\tif len(a) == 0 && len(b) == 0 {\n\t\treturn true\n\t}\n\taSet := make(map[string]struct{}, len(a))\n\tfor _, k := range a {\n\t\taSet[strings.TrimSpace(k)] = struct{}{}\n\t}\n\tbSet := make(map[string]struct{}, len(b))\n\tfor _, k := range b {\n\t\tbSet[strings.TrimSpace(k)] = struct{}{}\n\t}\n\tif len(aSet) != len(bSet) {\n\t\treturn false\n\t}\n\tfor k := range aSet {\n\t\tif _, ok := bSet[k]; !ok {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// equalUpstreamAPIKeys compares two slices of AmpUpstreamAPIKeyEntry for equality.\n// Comparison is done by count and content (upstream key and client keys).\nfunc equalUpstreamAPIKeys(a, b []config.AmpUpstreamAPIKeyEntry) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif strings.TrimSpace(a[i].UpstreamAPIKey) != strings.TrimSpace(b[i].UpstreamAPIKey) {\n\t\t\treturn false\n\t\t}\n\t\tif !equalStringSet(a[i].APIKeys, b[i].APIKeys) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "internal/watcher/diff/config_diff_test.go",
    "content": "package diff\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestBuildConfigChangeDetails(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tPort:    8080,\n\t\tAuthDir: \"/tmp/auth-old\",\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"old\", BaseURL: \"http://old\", ExcludedModels: []string{\"old-model\"}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:                   \"http://old-upstream\",\n\t\t\tModelMappings:                 []config.AmpModelMapping{{From: \"from-old\", To: \"to-old\"}},\n\t\t\tRestrictManagementToLocalhost: false,\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tAllowRemote:           false,\n\t\t\tSecretKey:             \"old\",\n\t\t\tDisableControlPanel:   false,\n\t\t\tPanelGitHubRepository: \"repo-old\",\n\t\t},\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"providerA\": {\"m1\"},\n\t\t},\n\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t{\n\t\t\t\tName: \"compat-a\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t{APIKey: \"k1\"},\n\t\t\t\t},\n\t\t\t\tModels: []config.OpenAICompatibilityModel{{Name: \"m1\"}},\n\t\t\t},\n\t\t},\n\t}\n\n\tnewCfg := &config.Config{\n\t\tPort:    9090,\n\t\tAuthDir: \"/tmp/auth-new\",\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"old\", BaseURL: \"http://old\", ExcludedModels: []string{\"old-model\", \"extra\"}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:                   \"http://new-upstream\",\n\t\t\tRestrictManagementToLocalhost: true,\n\t\t\tModelMappings: []config.AmpModelMapping{\n\t\t\t\t{From: \"from-old\", To: \"to-old\"},\n\t\t\t\t{From: \"from-new\", To: \"to-new\"},\n\t\t\t},\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tAllowRemote:           true,\n\t\t\tSecretKey:             \"new\",\n\t\t\tDisableControlPanel:   true,\n\t\t\tPanelGitHubRepository: \"repo-new\",\n\t\t},\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"providerA\": {\"m1\", \"m2\"},\n\t\t\t\"providerB\": {\"x\"},\n\t\t},\n\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t{\n\t\t\t\tName: \"compat-a\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t{APIKey: \"k1\"},\n\t\t\t\t},\n\t\t\t\tModels: []config.OpenAICompatibilityModel{{Name: \"m1\"}, {Name: \"m2\"}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"compat-b\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t{APIKey: \"k2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdetails := BuildConfigChangeDetails(oldCfg, newCfg)\n\n\texpectContains(t, details, \"port: 8080 -> 9090\")\n\texpectContains(t, details, \"auth-dir: /tmp/auth-old -> /tmp/auth-new\")\n\texpectContains(t, details, \"gemini[0].excluded-models: updated (1 -> 2 entries)\")\n\texpectContains(t, details, \"ampcode.upstream-url: http://old-upstream -> http://new-upstream\")\n\texpectContains(t, details, \"ampcode.model-mappings: updated (1 -> 2 entries)\")\n\texpectContains(t, details, \"remote-management.allow-remote: false -> true\")\n\texpectContains(t, details, \"remote-management.secret-key: updated\")\n\texpectContains(t, details, \"oauth-excluded-models[providera]: updated (1 -> 2 entries)\")\n\texpectContains(t, details, \"oauth-excluded-models[providerb]: added (1 entries)\")\n\texpectContains(t, details, \"openai-compatibility:\")\n\texpectContains(t, details, \"  provider added: compat-b (api-keys=1, models=0)\")\n\texpectContains(t, details, \"  provider updated: compat-a (models 1 -> 2)\")\n}\n\nfunc TestBuildConfigChangeDetails_NoChanges(t *testing.T) {\n\tcfg := &config.Config{\n\t\tPort: 8080,\n\t}\n\tif details := BuildConfigChangeDetails(cfg, cfg); len(details) != 0 {\n\t\tt.Fatalf(\"expected no change entries, got %v\", details)\n\t}\n}\n\nfunc TestBuildConfigChangeDetails_GeminiVertexHeadersAndForceMappings(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g1\", Headers: map[string]string{\"H\": \"1\"}, ExcludedModels: []string{\"a\"}},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v1\", BaseURL: \"http://v-old\", Models: []config.VertexCompatModel{{Name: \"m1\"}}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tModelMappings:      []config.AmpModelMapping{{From: \"a\", To: \"b\"}},\n\t\t\tForceModelMappings: false,\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g1\", Headers: map[string]string{\"H\": \"2\"}, ExcludedModels: []string{\"a\", \"b\"}},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v1\", BaseURL: \"http://v-new\", Models: []config.VertexCompatModel{{Name: \"m1\"}, {Name: \"m2\"}}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tModelMappings:      []config.AmpModelMapping{{From: \"a\", To: \"c\"}},\n\t\t\tForceModelMappings: true,\n\t\t},\n\t}\n\n\tdetails := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, details, \"gemini[0].headers: updated\")\n\texpectContains(t, details, \"gemini[0].excluded-models: updated (1 -> 2 entries)\")\n\texpectContains(t, details, \"ampcode.model-mappings: updated (1 -> 1 entries)\")\n\texpectContains(t, details, \"ampcode.force-model-mappings: false -> true\")\n}\n\nfunc TestBuildConfigChangeDetails_ModelPrefixes(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g1\", Prefix: \"old-g\", BaseURL: \"http://g\", ProxyURL: \"http://gp\"},\n\t\t},\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{APIKey: \"c1\", Prefix: \"old-c\", BaseURL: \"http://c\", ProxyURL: \"http://cp\"},\n\t\t},\n\t\tCodexKey: []config.CodexKey{\n\t\t\t{APIKey: \"x1\", Prefix: \"old-x\", BaseURL: \"http://x\", ProxyURL: \"http://xp\"},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v1\", Prefix: \"old-v\", BaseURL: \"http://v\", ProxyURL: \"http://vp\"},\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g1\", Prefix: \"new-g\", BaseURL: \"http://g\", ProxyURL: \"http://gp\"},\n\t\t},\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{APIKey: \"c1\", Prefix: \"new-c\", BaseURL: \"http://c\", ProxyURL: \"http://cp\"},\n\t\t},\n\t\tCodexKey: []config.CodexKey{\n\t\t\t{APIKey: \"x1\", Prefix: \"new-x\", BaseURL: \"http://x\", ProxyURL: \"http://xp\"},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v1\", Prefix: \"new-v\", BaseURL: \"http://v\", ProxyURL: \"http://vp\"},\n\t\t},\n\t}\n\n\tchanges := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, changes, \"gemini[0].prefix: old-g -> new-g\")\n\texpectContains(t, changes, \"claude[0].prefix: old-c -> new-c\")\n\texpectContains(t, changes, \"codex[0].prefix: old-x -> new-x\")\n\texpectContains(t, changes, \"vertex[0].prefix: old-v -> new-v\")\n}\n\nfunc TestBuildConfigChangeDetails_NilSafe(t *testing.T) {\n\tif details := BuildConfigChangeDetails(nil, &config.Config{}); len(details) != 0 {\n\t\tt.Fatalf(\"expected empty change list when old nil, got %v\", details)\n\t}\n\tif details := BuildConfigChangeDetails(&config.Config{}, nil); len(details) != 0 {\n\t\tt.Fatalf(\"expected empty change list when new nil, got %v\", details)\n\t}\n}\n\nfunc TestBuildConfigChangeDetails_SecretsAndCounts(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tAPIKeys: []string{\"a\"},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamAPIKey: \"\",\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tSecretKey: \"\",\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tAPIKeys: []string{\"a\", \"b\", \"c\"},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamAPIKey: \"new-key\",\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tSecretKey: \"new-secret\",\n\t\t},\n\t}\n\n\tdetails := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, details, \"api-keys count: 1 -> 3\")\n\texpectContains(t, details, \"ampcode.upstream-api-key: added\")\n\texpectContains(t, details, \"remote-management.secret-key: created\")\n}\n\nfunc TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tPort:                   1000,\n\t\tAuthDir:                \"/old\",\n\t\tDebug:                  false,\n\t\tLoggingToFile:          false,\n\t\tUsageStatisticsEnabled: false,\n\t\tDisableCooling:         false,\n\t\tRequestRetry:           1,\n\t\tMaxRetryCredentials:    1,\n\t\tMaxRetryInterval:       1,\n\t\tWebsocketAuth:          false,\n\t\tQuotaExceeded:          config.QuotaExceeded{SwitchProject: false, SwitchPreviewModel: false},\n\t\tClaudeKey:              []config.ClaudeKey{{APIKey: \"c1\"}},\n\t\tCodexKey:               []config.CodexKey{{APIKey: \"x1\"}},\n\t\tAmpCode:                config.AmpCode{UpstreamAPIKey: \"keep\", RestrictManagementToLocalhost: false},\n\t\tRemoteManagement:       config.RemoteManagement{DisableControlPanel: false, PanelGitHubRepository: \"old/repo\", SecretKey: \"keep\"},\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tRequestLog:                 false,\n\t\t\tProxyURL:                   \"http://old-proxy\",\n\t\t\tAPIKeys:                    []string{\"key-1\"},\n\t\t\tForceModelPrefix:           false,\n\t\t\tNonStreamKeepAliveInterval: 0,\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tPort:                   2000,\n\t\tAuthDir:                \"/new\",\n\t\tDebug:                  true,\n\t\tLoggingToFile:          true,\n\t\tUsageStatisticsEnabled: true,\n\t\tDisableCooling:         true,\n\t\tRequestRetry:           2,\n\t\tMaxRetryCredentials:    3,\n\t\tMaxRetryInterval:       3,\n\t\tWebsocketAuth:          true,\n\t\tQuotaExceeded:          config.QuotaExceeded{SwitchProject: true, SwitchPreviewModel: true},\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{APIKey: \"c1\", BaseURL: \"http://new\", ProxyURL: \"http://p\", Headers: map[string]string{\"H\": \"1\"}, ExcludedModels: []string{\"a\"}},\n\t\t\t{APIKey: \"c2\"},\n\t\t},\n\t\tCodexKey: []config.CodexKey{\n\t\t\t{APIKey: \"x1\", BaseURL: \"http://x\", ProxyURL: \"http://px\", Headers: map[string]string{\"H\": \"2\"}, ExcludedModels: []string{\"b\"}},\n\t\t\t{APIKey: \"x2\"},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamAPIKey:                \"\",\n\t\t\tRestrictManagementToLocalhost: true,\n\t\t\tModelMappings:                 []config.AmpModelMapping{{From: \"a\", To: \"b\"}},\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tDisableControlPanel:   true,\n\t\t\tPanelGitHubRepository: \"new/repo\",\n\t\t\tSecretKey:             \"\",\n\t\t},\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tRequestLog:                 true,\n\t\t\tProxyURL:                   \"http://new-proxy\",\n\t\t\tAPIKeys:                    []string{\" key-1 \", \"key-2\"},\n\t\t\tForceModelPrefix:           true,\n\t\t\tNonStreamKeepAliveInterval: 5,\n\t\t},\n\t}\n\n\tdetails := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, details, \"debug: false -> true\")\n\texpectContains(t, details, \"logging-to-file: false -> true\")\n\texpectContains(t, details, \"usage-statistics-enabled: false -> true\")\n\texpectContains(t, details, \"disable-cooling: false -> true\")\n\texpectContains(t, details, \"request-log: false -> true\")\n\texpectContains(t, details, \"request-retry: 1 -> 2\")\n\texpectContains(t, details, \"max-retry-credentials: 1 -> 3\")\n\texpectContains(t, details, \"max-retry-interval: 1 -> 3\")\n\texpectContains(t, details, \"proxy-url: http://old-proxy -> http://new-proxy\")\n\texpectContains(t, details, \"ws-auth: false -> true\")\n\texpectContains(t, details, \"force-model-prefix: false -> true\")\n\texpectContains(t, details, \"nonstream-keepalive-interval: 0 -> 5\")\n\texpectContains(t, details, \"quota-exceeded.switch-project: false -> true\")\n\texpectContains(t, details, \"quota-exceeded.switch-preview-model: false -> true\")\n\texpectContains(t, details, \"api-keys count: 1 -> 2\")\n\texpectContains(t, details, \"claude-api-key count: 1 -> 2\")\n\texpectContains(t, details, \"codex-api-key count: 1 -> 2\")\n\texpectContains(t, details, \"ampcode.restrict-management-to-localhost: false -> true\")\n\texpectContains(t, details, \"ampcode.upstream-api-key: removed\")\n\texpectContains(t, details, \"remote-management.disable-control-panel: false -> true\")\n\texpectContains(t, details, \"remote-management.panel-github-repository: old/repo -> new/repo\")\n\texpectContains(t, details, \"remote-management.secret-key: deleted\")\n}\n\nfunc TestBuildConfigChangeDetails_AllBranches(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tPort:                   1,\n\t\tAuthDir:                \"/a\",\n\t\tDebug:                  false,\n\t\tLoggingToFile:          false,\n\t\tUsageStatisticsEnabled: false,\n\t\tDisableCooling:         false,\n\t\tRequestRetry:           1,\n\t\tMaxRetryCredentials:    1,\n\t\tMaxRetryInterval:       1,\n\t\tWebsocketAuth:          false,\n\t\tQuotaExceeded:          config.QuotaExceeded{SwitchProject: false, SwitchPreviewModel: false},\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g-old\", BaseURL: \"http://g-old\", ProxyURL: \"http://gp-old\", Headers: map[string]string{\"A\": \"1\"}},\n\t\t},\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{APIKey: \"c-old\", BaseURL: \"http://c-old\", ProxyURL: \"http://cp-old\", Headers: map[string]string{\"H\": \"1\"}, ExcludedModels: []string{\"x\"}},\n\t\t},\n\t\tCodexKey: []config.CodexKey{\n\t\t\t{APIKey: \"x-old\", BaseURL: \"http://x-old\", ProxyURL: \"http://xp-old\", Headers: map[string]string{\"H\": \"1\"}, ExcludedModels: []string{\"x\"}},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v-old\", BaseURL: \"http://v-old\", ProxyURL: \"http://vp-old\", Headers: map[string]string{\"H\": \"1\"}, Models: []config.VertexCompatModel{{Name: \"m1\"}}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:                   \"http://amp-old\",\n\t\t\tUpstreamAPIKey:                \"old-key\",\n\t\t\tRestrictManagementToLocalhost: false,\n\t\t\tModelMappings:                 []config.AmpModelMapping{{From: \"a\", To: \"b\"}},\n\t\t\tForceModelMappings:            false,\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tAllowRemote:           false,\n\t\t\tDisableControlPanel:   false,\n\t\t\tPanelGitHubRepository: \"old/repo\",\n\t\t\tSecretKey:             \"old\",\n\t\t},\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tRequestLog: false,\n\t\t\tProxyURL:   \"http://old-proxy\",\n\t\t\tAPIKeys:    []string{\" keyA \"},\n\t\t},\n\t\tOAuthExcludedModels: map[string][]string{\"p1\": {\"a\"}},\n\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t{\n\t\t\t\tName: \"prov-old\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t{APIKey: \"k1\"},\n\t\t\t\t},\n\t\t\t\tModels: []config.OpenAICompatibilityModel{{Name: \"m1\"}},\n\t\t\t},\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tPort:                   2,\n\t\tAuthDir:                \"/b\",\n\t\tDebug:                  true,\n\t\tLoggingToFile:          true,\n\t\tUsageStatisticsEnabled: true,\n\t\tDisableCooling:         true,\n\t\tRequestRetry:           2,\n\t\tMaxRetryCredentials:    3,\n\t\tMaxRetryInterval:       3,\n\t\tWebsocketAuth:          true,\n\t\tQuotaExceeded:          config.QuotaExceeded{SwitchProject: true, SwitchPreviewModel: true},\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"g-new\", BaseURL: \"http://g-new\", ProxyURL: \"http://gp-new\", Headers: map[string]string{\"A\": \"2\"}, ExcludedModels: []string{\"x\", \"y\"}},\n\t\t},\n\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t{APIKey: \"c-new\", BaseURL: \"http://c-new\", ProxyURL: \"http://cp-new\", Headers: map[string]string{\"H\": \"2\"}, ExcludedModels: []string{\"x\", \"y\"}},\n\t\t},\n\t\tCodexKey: []config.CodexKey{\n\t\t\t{APIKey: \"x-new\", BaseURL: \"http://x-new\", ProxyURL: \"http://xp-new\", Headers: map[string]string{\"H\": \"2\"}, ExcludedModels: []string{\"x\", \"y\"}},\n\t\t},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v-new\", BaseURL: \"http://v-new\", ProxyURL: \"http://vp-new\", Headers: map[string]string{\"H\": \"2\"}, Models: []config.VertexCompatModel{{Name: \"m1\"}, {Name: \"m2\"}}},\n\t\t},\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:                   \"http://amp-new\",\n\t\t\tUpstreamAPIKey:                \"\",\n\t\t\tRestrictManagementToLocalhost: true,\n\t\t\tModelMappings:                 []config.AmpModelMapping{{From: \"a\", To: \"c\"}},\n\t\t\tForceModelMappings:            true,\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tAllowRemote:           true,\n\t\t\tDisableControlPanel:   true,\n\t\t\tPanelGitHubRepository: \"new/repo\",\n\t\t\tSecretKey:             \"\",\n\t\t},\n\t\tSDKConfig: sdkconfig.SDKConfig{\n\t\t\tRequestLog: true,\n\t\t\tProxyURL:   \"http://new-proxy\",\n\t\t\tAPIKeys:    []string{\"keyB\"},\n\t\t},\n\t\tOAuthExcludedModels: map[string][]string{\"p1\": {\"b\", \"c\"}, \"p2\": {\"d\"}},\n\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t{\n\t\t\t\tName: \"prov-old\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t{APIKey: \"k1\"},\n\t\t\t\t\t{APIKey: \"k2\"},\n\t\t\t\t},\n\t\t\t\tModels: []config.OpenAICompatibilityModel{{Name: \"m1\"}, {Name: \"m2\"}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:          \"prov-new\",\n\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"k3\"}},\n\t\t\t},\n\t\t},\n\t}\n\n\tchanges := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, changes, \"port: 1 -> 2\")\n\texpectContains(t, changes, \"auth-dir: /a -> /b\")\n\texpectContains(t, changes, \"debug: false -> true\")\n\texpectContains(t, changes, \"logging-to-file: false -> true\")\n\texpectContains(t, changes, \"usage-statistics-enabled: false -> true\")\n\texpectContains(t, changes, \"disable-cooling: false -> true\")\n\texpectContains(t, changes, \"request-retry: 1 -> 2\")\n\texpectContains(t, changes, \"max-retry-credentials: 1 -> 3\")\n\texpectContains(t, changes, \"max-retry-interval: 1 -> 3\")\n\texpectContains(t, changes, \"proxy-url: http://old-proxy -> http://new-proxy\")\n\texpectContains(t, changes, \"ws-auth: false -> true\")\n\texpectContains(t, changes, \"quota-exceeded.switch-project: false -> true\")\n\texpectContains(t, changes, \"quota-exceeded.switch-preview-model: false -> true\")\n\texpectContains(t, changes, \"api-keys: values updated (count unchanged, redacted)\")\n\texpectContains(t, changes, \"gemini[0].base-url: http://g-old -> http://g-new\")\n\texpectContains(t, changes, \"gemini[0].proxy-url: http://gp-old -> http://gp-new\")\n\texpectContains(t, changes, \"gemini[0].api-key: updated\")\n\texpectContains(t, changes, \"gemini[0].headers: updated\")\n\texpectContains(t, changes, \"gemini[0].excluded-models: updated (0 -> 2 entries)\")\n\texpectContains(t, changes, \"claude[0].base-url: http://c-old -> http://c-new\")\n\texpectContains(t, changes, \"claude[0].proxy-url: http://cp-old -> http://cp-new\")\n\texpectContains(t, changes, \"claude[0].api-key: updated\")\n\texpectContains(t, changes, \"claude[0].headers: updated\")\n\texpectContains(t, changes, \"claude[0].excluded-models: updated (1 -> 2 entries)\")\n\texpectContains(t, changes, \"codex[0].base-url: http://x-old -> http://x-new\")\n\texpectContains(t, changes, \"codex[0].proxy-url: http://xp-old -> http://xp-new\")\n\texpectContains(t, changes, \"codex[0].api-key: updated\")\n\texpectContains(t, changes, \"codex[0].headers: updated\")\n\texpectContains(t, changes, \"codex[0].excluded-models: updated (1 -> 2 entries)\")\n\texpectContains(t, changes, \"vertex[0].base-url: http://v-old -> http://v-new\")\n\texpectContains(t, changes, \"vertex[0].proxy-url: http://vp-old -> http://vp-new\")\n\texpectContains(t, changes, \"vertex[0].api-key: updated\")\n\texpectContains(t, changes, \"vertex[0].models: updated (1 -> 2 entries)\")\n\texpectContains(t, changes, \"vertex[0].headers: updated\")\n\texpectContains(t, changes, \"ampcode.upstream-url: http://amp-old -> http://amp-new\")\n\texpectContains(t, changes, \"ampcode.upstream-api-key: removed\")\n\texpectContains(t, changes, \"ampcode.restrict-management-to-localhost: false -> true\")\n\texpectContains(t, changes, \"ampcode.model-mappings: updated (1 -> 1 entries)\")\n\texpectContains(t, changes, \"ampcode.force-model-mappings: false -> true\")\n\texpectContains(t, changes, \"oauth-excluded-models[p1]: updated (1 -> 2 entries)\")\n\texpectContains(t, changes, \"oauth-excluded-models[p2]: added (1 entries)\")\n\texpectContains(t, changes, \"remote-management.allow-remote: false -> true\")\n\texpectContains(t, changes, \"remote-management.disable-control-panel: false -> true\")\n\texpectContains(t, changes, \"remote-management.panel-github-repository: old/repo -> new/repo\")\n\texpectContains(t, changes, \"remote-management.secret-key: deleted\")\n\texpectContains(t, changes, \"openai-compatibility:\")\n}\n\nfunc TestFormatProxyURL(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tin   string\n\t\twant string\n\t}{\n\t\t{name: \"empty\", in: \"\", want: \"<none>\"},\n\t\t{name: \"invalid\", in: \"http://[::1\", want: \"<redacted>\"},\n\t\t{name: \"fullURLRedactsUserinfoAndPath\", in: \"http://user:pass@example.com:8080/path?x=1#frag\", want: \"http://example.com:8080\"},\n\t\t{name: \"socks5RedactsUserinfoAndPath\", in: \"socks5://user:pass@192.168.1.1:1080/path?x=1\", want: \"socks5://192.168.1.1:1080\"},\n\t\t{name: \"socks5HostPort\", in: \"socks5://proxy.example.com:1080/\", want: \"socks5://proxy.example.com:1080\"},\n\t\t{name: \"hostPortNoScheme\", in: \"example.com:1234/path?x=1\", want: \"example.com:1234\"},\n\t\t{name: \"relativePathRedacted\", in: \"/just/path\", want: \"<redacted>\"},\n\t\t{name: \"schemeAndHost\", in: \"https://example.com\", want: \"https://example.com\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := formatProxyURL(tt.in); got != tt.want {\n\t\t\t\tt.Fatalf(\"expected %q, got %q\", tt.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildConfigChangeDetails_SecretAndUpstreamUpdates(t *testing.T) {\n\toldCfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamAPIKey: \"old\",\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tSecretKey: \"old\",\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamAPIKey: \"new\",\n\t\t},\n\t\tRemoteManagement: config.RemoteManagement{\n\t\t\tSecretKey: \"new\",\n\t\t},\n\t}\n\n\tchanges := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, changes, \"ampcode.upstream-api-key: updated\")\n\texpectContains(t, changes, \"remote-management.secret-key: updated\")\n}\n\nfunc TestBuildConfigChangeDetails_CountBranches(t *testing.T) {\n\toldCfg := &config.Config{}\n\tnewCfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{{APIKey: \"g\"}},\n\t\tClaudeKey: []config.ClaudeKey{{APIKey: \"c\"}},\n\t\tCodexKey:  []config.CodexKey{{APIKey: \"x\"}},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v\", BaseURL: \"http://v\"},\n\t\t},\n\t}\n\n\tchanges := BuildConfigChangeDetails(oldCfg, newCfg)\n\texpectContains(t, changes, \"gemini-api-key count: 0 -> 1\")\n\texpectContains(t, changes, \"claude-api-key count: 0 -> 1\")\n\texpectContains(t, changes, \"codex-api-key count: 0 -> 1\")\n\texpectContains(t, changes, \"vertex-api-key count: 0 -> 1\")\n}\n\nfunc TestTrimStrings(t *testing.T) {\n\tout := trimStrings([]string{\" a \", \"b\", \"  c\"})\n\tif len(out) != 3 || out[0] != \"a\" || out[1] != \"b\" || out[2] != \"c\" {\n\t\tt.Fatalf(\"unexpected trimmed strings: %v\", out)\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/diff/model_hash.go",
    "content": "package diff\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models.\n// Used to detect model list changes during hot reload.\nfunc ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string {\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn hashJoined(keys)\n}\n\n// ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models.\nfunc ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string {\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn hashJoined(keys)\n}\n\n// ComputeClaudeModelsHash returns a stable hash for Claude model aliases.\nfunc ComputeClaudeModelsHash(models []config.ClaudeModel) string {\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn hashJoined(keys)\n}\n\n// ComputeCodexModelsHash returns a stable hash for Codex model aliases.\nfunc ComputeCodexModelsHash(models []config.CodexModel) string {\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn hashJoined(keys)\n}\n\n// ComputeGeminiModelsHash returns a stable hash for Gemini model aliases.\nfunc ComputeGeminiModelsHash(models []config.GeminiModel) string {\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn hashJoined(keys)\n}\n\n// ComputeExcludedModelsHash returns a normalized hash for excluded model lists.\nfunc ComputeExcludedModelsHash(excluded []string) string {\n\tif len(excluded) == 0 {\n\t\treturn \"\"\n\t}\n\tnormalized := make([]string, 0, len(excluded))\n\tfor _, entry := range excluded {\n\t\tif trimmed := strings.TrimSpace(entry); trimmed != \"\" {\n\t\t\tnormalized = append(normalized, strings.ToLower(trimmed))\n\t\t}\n\t}\n\tif len(normalized) == 0 {\n\t\treturn \"\"\n\t}\n\tsort.Strings(normalized)\n\tdata, _ := json.Marshal(normalized)\n\tsum := sha256.Sum256(data)\n\treturn hex.EncodeToString(sum[:])\n}\n\nfunc normalizeModelPairs(collect func(out func(key string))) []string {\n\tseen := make(map[string]struct{})\n\tkeys := make([]string, 0)\n\tcollect(func(key string) {\n\t\tif _, exists := seen[key]; exists {\n\t\t\treturn\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tkeys = append(keys, key)\n\t})\n\tif len(keys) == 0 {\n\t\treturn nil\n\t}\n\tsort.Strings(keys)\n\treturn keys\n}\n\nfunc hashJoined(keys []string) string {\n\tif len(keys) == 0 {\n\t\treturn \"\"\n\t}\n\tsum := sha256.Sum256([]byte(strings.Join(keys, \"\\n\")))\n\treturn hex.EncodeToString(sum[:])\n}\n"
  },
  {
    "path": "internal/watcher/diff/model_hash_test.go",
    "content": "package diff\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc TestComputeOpenAICompatModelsHash_Deterministic(t *testing.T) {\n\tmodels := []config.OpenAICompatibilityModel{\n\t\t{Name: \"gpt-4\", Alias: \"gpt4\"},\n\t\t{Name: \"gpt-3.5-turbo\"},\n\t}\n\thash1 := ComputeOpenAICompatModelsHash(models)\n\thash2 := ComputeOpenAICompatModelsHash(models)\n\tif hash1 == \"\" {\n\t\tt.Fatal(\"hash should not be empty\")\n\t}\n\tif hash1 != hash2 {\n\t\tt.Fatalf(\"hash should be deterministic, got %s vs %s\", hash1, hash2)\n\t}\n\tchanged := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: \"gpt-4\"}, {Name: \"gpt-4.1\"}})\n\tif hash1 == changed {\n\t\tt.Fatal(\"hash should change when model list changes\")\n\t}\n}\n\nfunc TestComputeOpenAICompatModelsHash_NormalizesAndDedups(t *testing.T) {\n\ta := []config.OpenAICompatibilityModel{\n\t\t{Name: \"gpt-4\", Alias: \"gpt4\"},\n\t\t{Name: \" \"},\n\t\t{Name: \"GPT-4\", Alias: \"GPT4\"},\n\t\t{Alias: \"a1\"},\n\t}\n\tb := []config.OpenAICompatibilityModel{\n\t\t{Alias: \"A1\"},\n\t\t{Name: \"gpt-4\", Alias: \"gpt4\"},\n\t}\n\th1 := ComputeOpenAICompatModelsHash(a)\n\th2 := ComputeOpenAICompatModelsHash(b)\n\tif h1 == \"\" || h2 == \"\" {\n\t\tt.Fatal(\"expected non-empty hashes for non-empty model sets\")\n\t}\n\tif h1 != h2 {\n\t\tt.Fatalf(\"expected normalized hashes to match, got %s / %s\", h1, h2)\n\t}\n}\n\nfunc TestComputeVertexCompatModelsHash_DifferentInputs(t *testing.T) {\n\tmodels := []config.VertexCompatModel{{Name: \"gemini-pro\", Alias: \"pro\"}}\n\thash1 := ComputeVertexCompatModelsHash(models)\n\thash2 := ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: \"gemini-1.5-pro\", Alias: \"pro\"}})\n\tif hash1 == \"\" || hash2 == \"\" {\n\t\tt.Fatal(\"hashes should not be empty for non-empty models\")\n\t}\n\tif hash1 == hash2 {\n\t\tt.Fatal(\"hash should differ when model content differs\")\n\t}\n}\n\nfunc TestComputeVertexCompatModelsHash_IgnoresBlankAndOrder(t *testing.T) {\n\ta := []config.VertexCompatModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t\t{Name: \" \"},\n\t\t{Name: \"M1\", Alias: \"A1\"},\n\t}\n\tb := []config.VertexCompatModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t}\n\tif h1, h2 := ComputeVertexCompatModelsHash(a), ComputeVertexCompatModelsHash(b); h1 == \"\" || h1 != h2 {\n\t\tt.Fatalf(\"expected same hash ignoring blanks/dupes, got %q / %q\", h1, h2)\n\t}\n}\n\nfunc TestComputeClaudeModelsHash_Empty(t *testing.T) {\n\tif got := ComputeClaudeModelsHash(nil); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for nil models, got %q\", got)\n\t}\n\tif got := ComputeClaudeModelsHash([]config.ClaudeModel{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for empty slice, got %q\", got)\n\t}\n}\n\nfunc TestComputeCodexModelsHash_Empty(t *testing.T) {\n\tif got := ComputeCodexModelsHash(nil); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for nil models, got %q\", got)\n\t}\n\tif got := ComputeCodexModelsHash([]config.CodexModel{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for empty slice, got %q\", got)\n\t}\n}\n\nfunc TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) {\n\ta := []config.ClaudeModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t\t{Name: \" \"},\n\t\t{Name: \"M1\", Alias: \"A1\"},\n\t}\n\tb := []config.ClaudeModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t}\n\tif h1, h2 := ComputeClaudeModelsHash(a), ComputeClaudeModelsHash(b); h1 == \"\" || h1 != h2 {\n\t\tt.Fatalf(\"expected same hash ignoring blanks/dupes, got %q / %q\", h1, h2)\n\t}\n}\n\nfunc TestComputeCodexModelsHash_IgnoresBlankAndDedup(t *testing.T) {\n\ta := []config.CodexModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t\t{Name: \" \"},\n\t\t{Name: \"M1\", Alias: \"A1\"},\n\t}\n\tb := []config.CodexModel{\n\t\t{Name: \"m1\", Alias: \"a1\"},\n\t}\n\tif h1, h2 := ComputeCodexModelsHash(a), ComputeCodexModelsHash(b); h1 == \"\" || h1 != h2 {\n\t\tt.Fatalf(\"expected same hash ignoring blanks/dupes, got %q / %q\", h1, h2)\n\t}\n}\n\nfunc TestComputeExcludedModelsHash_Normalizes(t *testing.T) {\n\thash1 := ComputeExcludedModelsHash([]string{\" A \", \"b\", \"a\"})\n\thash2 := ComputeExcludedModelsHash([]string{\"a\", \" b\", \"A\"})\n\tif hash1 == \"\" || hash2 == \"\" {\n\t\tt.Fatal(\"hash should not be empty for non-empty input\")\n\t}\n\tif hash1 != hash2 {\n\t\tt.Fatalf(\"hash should be order/space insensitive for same multiset, got %s vs %s\", hash1, hash2)\n\t}\n\thash3 := ComputeExcludedModelsHash([]string{\"c\"})\n\tif hash1 == hash3 {\n\t\tt.Fatal(\"hash should differ for different normalized sets\")\n\t}\n}\n\nfunc TestComputeOpenAICompatModelsHash_Empty(t *testing.T) {\n\tif got := ComputeOpenAICompatModelsHash(nil); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for nil input, got %q\", got)\n\t}\n\tif got := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for empty slice, got %q\", got)\n\t}\n\tif got := ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: \" \"}, {Alias: \"\"}}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for blank models, got %q\", got)\n\t}\n}\n\nfunc TestComputeVertexCompatModelsHash_Empty(t *testing.T) {\n\tif got := ComputeVertexCompatModelsHash(nil); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for nil input, got %q\", got)\n\t}\n\tif got := ComputeVertexCompatModelsHash([]config.VertexCompatModel{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for empty slice, got %q\", got)\n\t}\n\tif got := ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: \" \"}}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for blank models, got %q\", got)\n\t}\n}\n\nfunc TestComputeExcludedModelsHash_Empty(t *testing.T) {\n\tif got := ComputeExcludedModelsHash(nil); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for nil input, got %q\", got)\n\t}\n\tif got := ComputeExcludedModelsHash([]string{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for empty slice, got %q\", got)\n\t}\n\tif got := ComputeExcludedModelsHash([]string{\"  \", \"\"}); got != \"\" {\n\t\tt.Fatalf(\"expected empty hash for whitespace-only entries, got %q\", got)\n\t}\n}\n\nfunc TestComputeClaudeModelsHash_Deterministic(t *testing.T) {\n\tmodels := []config.ClaudeModel{{Name: \"a\", Alias: \"A\"}, {Name: \"b\"}}\n\th1 := ComputeClaudeModelsHash(models)\n\th2 := ComputeClaudeModelsHash(models)\n\tif h1 == \"\" || h1 != h2 {\n\t\tt.Fatalf(\"expected deterministic hash, got %s / %s\", h1, h2)\n\t}\n\tif h3 := ComputeClaudeModelsHash([]config.ClaudeModel{{Name: \"a\"}}); h3 == h1 {\n\t\tt.Fatalf(\"expected different hash when models change, got %s\", h3)\n\t}\n}\n\nfunc TestComputeCodexModelsHash_Deterministic(t *testing.T) {\n\tmodels := []config.CodexModel{{Name: \"a\", Alias: \"A\"}, {Name: \"b\"}}\n\th1 := ComputeCodexModelsHash(models)\n\th2 := ComputeCodexModelsHash(models)\n\tif h1 == \"\" || h1 != h2 {\n\t\tt.Fatalf(\"expected deterministic hash, got %s / %s\", h1, h2)\n\t}\n\tif h3 := ComputeCodexModelsHash([]config.CodexModel{{Name: \"a\"}}); h3 == h1 {\n\t\tt.Fatalf(\"expected different hash when models change, got %s\", h3)\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/diff/models_summary.go",
    "content": "package diff\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\ntype GeminiModelsSummary struct {\n\thash  string\n\tcount int\n}\n\ntype ClaudeModelsSummary struct {\n\thash  string\n\tcount int\n}\n\ntype CodexModelsSummary struct {\n\thash  string\n\tcount int\n}\n\ntype VertexModelsSummary struct {\n\thash  string\n\tcount int\n}\n\n// SummarizeGeminiModels hashes Gemini model aliases for change detection.\nfunc SummarizeGeminiModels(models []config.GeminiModel) GeminiModelsSummary {\n\tif len(models) == 0 {\n\t\treturn GeminiModelsSummary{}\n\t}\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn GeminiModelsSummary{\n\t\thash:  hashJoined(keys),\n\t\tcount: len(keys),\n\t}\n}\n\n// SummarizeClaudeModels hashes Claude model aliases for change detection.\nfunc SummarizeClaudeModels(models []config.ClaudeModel) ClaudeModelsSummary {\n\tif len(models) == 0 {\n\t\treturn ClaudeModelsSummary{}\n\t}\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn ClaudeModelsSummary{\n\t\thash:  hashJoined(keys),\n\t\tcount: len(keys),\n\t}\n}\n\n// SummarizeCodexModels hashes Codex model aliases for change detection.\nfunc SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary {\n\tif len(models) == 0 {\n\t\treturn CodexModelsSummary{}\n\t}\n\tkeys := normalizeModelPairs(func(out func(key string)) {\n\t\tfor _, model := range models {\n\t\t\tname := strings.TrimSpace(model.Name)\n\t\t\talias := strings.TrimSpace(model.Alias)\n\t\t\tif name == \"\" && alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout(strings.ToLower(name) + \"|\" + strings.ToLower(alias))\n\t\t}\n\t})\n\treturn CodexModelsSummary{\n\t\thash:  hashJoined(keys),\n\t\tcount: len(keys),\n\t}\n}\n\n// SummarizeVertexModels hashes Vertex-compatible model aliases for change detection.\nfunc SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummary {\n\tif len(models) == 0 {\n\t\treturn VertexModelsSummary{}\n\t}\n\tnames := make([]string, 0, len(models))\n\tfor _, model := range models {\n\t\tname := strings.TrimSpace(model.Name)\n\t\talias := strings.TrimSpace(model.Alias)\n\t\tif name == \"\" && alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif alias != \"\" {\n\t\t\tname = alias\n\t\t}\n\t\tnames = append(names, name)\n\t}\n\tif len(names) == 0 {\n\t\treturn VertexModelsSummary{}\n\t}\n\tsort.Strings(names)\n\tsum := sha256.Sum256([]byte(strings.Join(names, \"|\")))\n\treturn VertexModelsSummary{\n\t\thash:  hex.EncodeToString(sum[:]),\n\t\tcount: len(names),\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/diff/oauth_excluded.go",
    "content": "package diff\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\ntype ExcludedModelsSummary struct {\n\thash  string\n\tcount int\n}\n\n// SummarizeExcludedModels normalizes and hashes an excluded-model list.\nfunc SummarizeExcludedModels(list []string) ExcludedModelsSummary {\n\tif len(list) == 0 {\n\t\treturn ExcludedModelsSummary{}\n\t}\n\tseen := make(map[string]struct{}, len(list))\n\tnormalized := make([]string, 0, len(list))\n\tfor _, entry := range list {\n\t\tif trimmed := strings.ToLower(strings.TrimSpace(entry)); trimmed != \"\" {\n\t\t\tif _, exists := seen[trimmed]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[trimmed] = struct{}{}\n\t\t\tnormalized = append(normalized, trimmed)\n\t\t}\n\t}\n\tsort.Strings(normalized)\n\treturn ExcludedModelsSummary{\n\t\thash:  ComputeExcludedModelsHash(normalized),\n\t\tcount: len(normalized),\n\t}\n}\n\n// SummarizeOAuthExcludedModels summarizes OAuth excluded models per provider.\nfunc SummarizeOAuthExcludedModels(entries map[string][]string) map[string]ExcludedModelsSummary {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]ExcludedModelsSummary, len(entries))\n\tfor k, v := range entries {\n\t\tkey := strings.ToLower(strings.TrimSpace(k))\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[key] = SummarizeExcludedModels(v)\n\t}\n\treturn out\n}\n\n// DiffOAuthExcludedModelChanges compares OAuth excluded models maps.\nfunc DiffOAuthExcludedModelChanges(oldMap, newMap map[string][]string) ([]string, []string) {\n\toldSummary := SummarizeOAuthExcludedModels(oldMap)\n\tnewSummary := SummarizeOAuthExcludedModels(newMap)\n\tkeys := make(map[string]struct{}, len(oldSummary)+len(newSummary))\n\tfor k := range oldSummary {\n\t\tkeys[k] = struct{}{}\n\t}\n\tfor k := range newSummary {\n\t\tkeys[k] = struct{}{}\n\t}\n\tchanges := make([]string, 0, len(keys))\n\taffected := make([]string, 0, len(keys))\n\tfor key := range keys {\n\t\toldInfo, okOld := oldSummary[key]\n\t\tnewInfo, okNew := newSummary[key]\n\t\tswitch {\n\t\tcase okOld && !okNew:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-excluded-models[%s]: removed\", key))\n\t\t\taffected = append(affected, key)\n\t\tcase !okOld && okNew:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-excluded-models[%s]: added (%d entries)\", key, newInfo.count))\n\t\t\taffected = append(affected, key)\n\t\tcase okOld && okNew && oldInfo.hash != newInfo.hash:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-excluded-models[%s]: updated (%d -> %d entries)\", key, oldInfo.count, newInfo.count))\n\t\t\taffected = append(affected, key)\n\t\t}\n\t}\n\tsort.Strings(changes)\n\tsort.Strings(affected)\n\treturn changes, affected\n}\n\ntype AmpModelMappingsSummary struct {\n\thash  string\n\tcount int\n}\n\n// SummarizeAmpModelMappings hashes Amp model mappings for change detection.\nfunc SummarizeAmpModelMappings(mappings []config.AmpModelMapping) AmpModelMappingsSummary {\n\tif len(mappings) == 0 {\n\t\treturn AmpModelMappingsSummary{}\n\t}\n\tentries := make([]string, 0, len(mappings))\n\tfor _, mapping := range mappings {\n\t\tfrom := strings.TrimSpace(mapping.From)\n\t\tto := strings.TrimSpace(mapping.To)\n\t\tif from == \"\" && to == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentries = append(entries, from+\"->\"+to)\n\t}\n\tif len(entries) == 0 {\n\t\treturn AmpModelMappingsSummary{}\n\t}\n\tsort.Strings(entries)\n\tsum := sha256.Sum256([]byte(strings.Join(entries, \"|\")))\n\treturn AmpModelMappingsSummary{\n\t\thash:  hex.EncodeToString(sum[:]),\n\t\tcount: len(entries),\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/diff/oauth_excluded_test.go",
    "content": "package diff\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc TestSummarizeExcludedModels_NormalizesAndDedupes(t *testing.T) {\n\tsummary := SummarizeExcludedModels([]string{\"A\", \" a \", \"B\", \"b\"})\n\tif summary.count != 2 {\n\t\tt.Fatalf(\"expected 2 unique entries, got %d\", summary.count)\n\t}\n\tif summary.hash == \"\" {\n\t\tt.Fatal(\"expected non-empty hash\")\n\t}\n\tif empty := SummarizeExcludedModels(nil); empty.count != 0 || empty.hash != \"\" {\n\t\tt.Fatalf(\"expected empty summary for nil input, got %+v\", empty)\n\t}\n}\n\nfunc TestDiffOAuthExcludedModelChanges(t *testing.T) {\n\toldMap := map[string][]string{\n\t\t\"ProviderA\": {\"model-1\", \"model-2\"},\n\t\t\"providerB\": {\"x\"},\n\t}\n\tnewMap := map[string][]string{\n\t\t\"providerA\": {\"model-1\", \"model-3\"},\n\t\t\"providerC\": {\"y\"},\n\t}\n\n\tchanges, affected := DiffOAuthExcludedModelChanges(oldMap, newMap)\n\texpectContains(t, changes, \"oauth-excluded-models[providera]: updated (2 -> 2 entries)\")\n\texpectContains(t, changes, \"oauth-excluded-models[providerb]: removed\")\n\texpectContains(t, changes, \"oauth-excluded-models[providerc]: added (1 entries)\")\n\n\tif len(affected) != 3 {\n\t\tt.Fatalf(\"expected 3 affected providers, got %d\", len(affected))\n\t}\n}\n\nfunc TestSummarizeAmpModelMappings(t *testing.T) {\n\tsummary := SummarizeAmpModelMappings([]config.AmpModelMapping{\n\t\t{From: \"a\", To: \"A\"},\n\t\t{From: \"b\", To: \"B\"},\n\t\t{From: \" \", To: \" \"}, // ignored\n\t})\n\tif summary.count != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", summary.count)\n\t}\n\tif summary.hash == \"\" {\n\t\tt.Fatal(\"expected non-empty hash\")\n\t}\n\tif empty := SummarizeAmpModelMappings(nil); empty.count != 0 || empty.hash != \"\" {\n\t\tt.Fatalf(\"expected empty summary for nil input, got %+v\", empty)\n\t}\n\tif blank := SummarizeAmpModelMappings([]config.AmpModelMapping{{From: \" \", To: \" \"}}); blank.count != 0 || blank.hash != \"\" {\n\t\tt.Fatalf(\"expected blank mappings ignored, got %+v\", blank)\n\t}\n}\n\nfunc TestSummarizeOAuthExcludedModels_NormalizesKeys(t *testing.T) {\n\tout := SummarizeOAuthExcludedModels(map[string][]string{\n\t\t\"ProvA\": {\"X\"},\n\t\t\"\":      {\"ignored\"},\n\t})\n\tif len(out) != 1 {\n\t\tt.Fatalf(\"expected only non-empty key summary, got %d\", len(out))\n\t}\n\tif _, ok := out[\"prova\"]; !ok {\n\t\tt.Fatalf(\"expected normalized key 'prova', got keys %v\", out)\n\t}\n\tif out[\"prova\"].count != 1 || out[\"prova\"].hash == \"\" {\n\t\tt.Fatalf(\"unexpected summary %+v\", out[\"prova\"])\n\t}\n\tif outEmpty := SummarizeOAuthExcludedModels(nil); outEmpty != nil {\n\t\tt.Fatalf(\"expected nil map for nil input, got %v\", outEmpty)\n\t}\n}\n\nfunc TestSummarizeVertexModels(t *testing.T) {\n\tsummary := SummarizeVertexModels([]config.VertexCompatModel{\n\t\t{Name: \"m1\"},\n\t\t{Name: \" \", Alias: \"alias\"},\n\t\t{}, // ignored\n\t})\n\tif summary.count != 2 {\n\t\tt.Fatalf(\"expected 2 vertex models, got %d\", summary.count)\n\t}\n\tif summary.hash == \"\" {\n\t\tt.Fatal(\"expected non-empty hash\")\n\t}\n\tif empty := SummarizeVertexModels(nil); empty.count != 0 || empty.hash != \"\" {\n\t\tt.Fatalf(\"expected empty summary for nil input, got %+v\", empty)\n\t}\n\tif blank := SummarizeVertexModels([]config.VertexCompatModel{{Name: \" \"}}); blank.count != 0 || blank.hash != \"\" {\n\t\tt.Fatalf(\"expected blank model ignored, got %+v\", blank)\n\t}\n}\n\nfunc expectContains(t *testing.T, list []string, target string) {\n\tt.Helper()\n\tfor _, entry := range list {\n\t\tif entry == target {\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"expected list to contain %q, got %#v\", target, list)\n}\n"
  },
  {
    "path": "internal/watcher/diff/oauth_model_alias.go",
    "content": "package diff\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\ntype OAuthModelAliasSummary struct {\n\thash  string\n\tcount int\n}\n\n// SummarizeOAuthModelAlias summarizes OAuth model alias per channel.\nfunc SummarizeOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string]OAuthModelAliasSummary {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]OAuthModelAliasSummary, len(entries))\n\tfor k, v := range entries {\n\t\tkey := strings.ToLower(strings.TrimSpace(k))\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[key] = summarizeOAuthModelAliasList(v)\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// DiffOAuthModelAliasChanges compares OAuth model alias maps.\nfunc DiffOAuthModelAliasChanges(oldMap, newMap map[string][]config.OAuthModelAlias) ([]string, []string) {\n\toldSummary := SummarizeOAuthModelAlias(oldMap)\n\tnewSummary := SummarizeOAuthModelAlias(newMap)\n\tkeys := make(map[string]struct{}, len(oldSummary)+len(newSummary))\n\tfor k := range oldSummary {\n\t\tkeys[k] = struct{}{}\n\t}\n\tfor k := range newSummary {\n\t\tkeys[k] = struct{}{}\n\t}\n\tchanges := make([]string, 0, len(keys))\n\taffected := make([]string, 0, len(keys))\n\tfor key := range keys {\n\t\toldInfo, okOld := oldSummary[key]\n\t\tnewInfo, okNew := newSummary[key]\n\t\tswitch {\n\t\tcase okOld && !okNew:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-model-alias[%s]: removed\", key))\n\t\t\taffected = append(affected, key)\n\t\tcase !okOld && okNew:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-model-alias[%s]: added (%d entries)\", key, newInfo.count))\n\t\t\taffected = append(affected, key)\n\t\tcase okOld && okNew && oldInfo.hash != newInfo.hash:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"oauth-model-alias[%s]: updated (%d -> %d entries)\", key, oldInfo.count, newInfo.count))\n\t\t\taffected = append(affected, key)\n\t\t}\n\t}\n\tsort.Strings(changes)\n\tsort.Strings(affected)\n\treturn changes, affected\n}\n\nfunc summarizeOAuthModelAliasList(list []config.OAuthModelAlias) OAuthModelAliasSummary {\n\tif len(list) == 0 {\n\t\treturn OAuthModelAliasSummary{}\n\t}\n\tseen := make(map[string]struct{}, len(list))\n\tnormalized := make([]string, 0, len(list))\n\tfor _, alias := range list {\n\t\tname := strings.ToLower(strings.TrimSpace(alias.Name))\n\t\taliasVal := strings.ToLower(strings.TrimSpace(alias.Alias))\n\t\tif name == \"\" || aliasVal == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkey := name + \"->\" + aliasVal\n\t\tif alias.Fork {\n\t\t\tkey += \"|fork\"\n\t\t}\n\t\tif _, exists := seen[key]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tnormalized = append(normalized, key)\n\t}\n\tif len(normalized) == 0 {\n\t\treturn OAuthModelAliasSummary{}\n\t}\n\tsort.Strings(normalized)\n\tsum := sha256.Sum256([]byte(strings.Join(normalized, \"|\")))\n\treturn OAuthModelAliasSummary{\n\t\thash:  hex.EncodeToString(sum[:]),\n\t\tcount: len(normalized),\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/diff/openai_compat.go",
    "content": "package diff\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// DiffOpenAICompatibility produces human-readable change descriptions.\nfunc DiffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []string {\n\tchanges := make([]string, 0)\n\toldMap := make(map[string]config.OpenAICompatibility, len(oldList))\n\toldLabels := make(map[string]string, len(oldList))\n\tfor idx, entry := range oldList {\n\t\tkey, label := openAICompatKey(entry, idx)\n\t\toldMap[key] = entry\n\t\toldLabels[key] = label\n\t}\n\tnewMap := make(map[string]config.OpenAICompatibility, len(newList))\n\tnewLabels := make(map[string]string, len(newList))\n\tfor idx, entry := range newList {\n\t\tkey, label := openAICompatKey(entry, idx)\n\t\tnewMap[key] = entry\n\t\tnewLabels[key] = label\n\t}\n\tkeySet := make(map[string]struct{}, len(oldMap)+len(newMap))\n\tfor key := range oldMap {\n\t\tkeySet[key] = struct{}{}\n\t}\n\tfor key := range newMap {\n\t\tkeySet[key] = struct{}{}\n\t}\n\torderedKeys := make([]string, 0, len(keySet))\n\tfor key := range keySet {\n\t\torderedKeys = append(orderedKeys, key)\n\t}\n\tsort.Strings(orderedKeys)\n\tfor _, key := range orderedKeys {\n\t\toldEntry, oldOk := oldMap[key]\n\t\tnewEntry, newOk := newMap[key]\n\t\tlabel := oldLabels[key]\n\t\tif label == \"\" {\n\t\t\tlabel = newLabels[key]\n\t\t}\n\t\tswitch {\n\t\tcase !oldOk:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"provider added: %s (api-keys=%d, models=%d)\", label, countAPIKeys(newEntry), countOpenAIModels(newEntry.Models)))\n\t\tcase !newOk:\n\t\t\tchanges = append(changes, fmt.Sprintf(\"provider removed: %s (api-keys=%d, models=%d)\", label, countAPIKeys(oldEntry), countOpenAIModels(oldEntry.Models)))\n\t\tdefault:\n\t\t\tif detail := describeOpenAICompatibilityUpdate(oldEntry, newEntry); detail != \"\" {\n\t\t\t\tchanges = append(changes, fmt.Sprintf(\"provider updated: %s %s\", label, detail))\n\t\t\t}\n\t\t}\n\t}\n\treturn changes\n}\n\nfunc describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibility) string {\n\toldKeyCount := countAPIKeys(oldEntry)\n\tnewKeyCount := countAPIKeys(newEntry)\n\toldModelCount := countOpenAIModels(oldEntry.Models)\n\tnewModelCount := countOpenAIModels(newEntry.Models)\n\tdetails := make([]string, 0, 3)\n\tif oldKeyCount != newKeyCount {\n\t\tdetails = append(details, fmt.Sprintf(\"api-keys %d -> %d\", oldKeyCount, newKeyCount))\n\t}\n\tif oldModelCount != newModelCount {\n\t\tdetails = append(details, fmt.Sprintf(\"models %d -> %d\", oldModelCount, newModelCount))\n\t}\n\tif !equalStringMap(oldEntry.Headers, newEntry.Headers) {\n\t\tdetails = append(details, \"headers updated\")\n\t}\n\tif len(details) == 0 {\n\t\treturn \"\"\n\t}\n\treturn \"(\" + strings.Join(details, \", \") + \")\"\n}\n\nfunc countAPIKeys(entry config.OpenAICompatibility) int {\n\tcount := 0\n\tfor _, keyEntry := range entry.APIKeyEntries {\n\t\tif strings.TrimSpace(keyEntry.APIKey) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc countOpenAIModels(models []config.OpenAICompatibilityModel) int {\n\tcount := 0\n\tfor _, model := range models {\n\t\tname := strings.TrimSpace(model.Name)\n\t\talias := strings.TrimSpace(model.Alias)\n\t\tif name == \"\" && alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcount++\n\t}\n\treturn count\n}\n\nfunc openAICompatKey(entry config.OpenAICompatibility, index int) (string, string) {\n\tname := strings.TrimSpace(entry.Name)\n\tif name != \"\" {\n\t\treturn \"name:\" + name, name\n\t}\n\tbase := strings.TrimSpace(entry.BaseURL)\n\tif base != \"\" {\n\t\treturn \"base:\" + base, base\n\t}\n\tfor _, model := range entry.Models {\n\t\talias := strings.TrimSpace(model.Alias)\n\t\tif alias == \"\" {\n\t\t\talias = strings.TrimSpace(model.Name)\n\t\t}\n\t\tif alias != \"\" {\n\t\t\treturn \"alias:\" + alias, alias\n\t\t}\n\t}\n\tsig := openAICompatSignature(entry)\n\tif sig == \"\" {\n\t\treturn fmt.Sprintf(\"index:%d\", index), fmt.Sprintf(\"entry-%d\", index+1)\n\t}\n\tshort := sig\n\tif len(short) > 8 {\n\t\tshort = short[:8]\n\t}\n\treturn \"sig:\" + sig, \"compat-\" + short\n}\n\nfunc openAICompatSignature(entry config.OpenAICompatibility) string {\n\tvar parts []string\n\n\tif v := strings.TrimSpace(entry.Name); v != \"\" {\n\t\tparts = append(parts, \"name=\"+strings.ToLower(v))\n\t}\n\tif v := strings.TrimSpace(entry.BaseURL); v != \"\" {\n\t\tparts = append(parts, \"base=\"+v)\n\t}\n\n\tmodels := make([]string, 0, len(entry.Models))\n\tfor _, model := range entry.Models {\n\t\tname := strings.TrimSpace(model.Name)\n\t\talias := strings.TrimSpace(model.Alias)\n\t\tif name == \"\" && alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tmodels = append(models, strings.ToLower(name)+\"|\"+strings.ToLower(alias))\n\t}\n\tif len(models) > 0 {\n\t\tsort.Strings(models)\n\t\tparts = append(parts, \"models=\"+strings.Join(models, \",\"))\n\t}\n\n\tif len(entry.Headers) > 0 {\n\t\tkeys := make([]string, 0, len(entry.Headers))\n\t\tfor k := range entry.Headers {\n\t\t\tif trimmed := strings.TrimSpace(k); trimmed != \"\" {\n\t\t\t\tkeys = append(keys, strings.ToLower(trimmed))\n\t\t\t}\n\t\t}\n\t\tif len(keys) > 0 {\n\t\t\tsort.Strings(keys)\n\t\t\tparts = append(parts, \"headers=\"+strings.Join(keys, \",\"))\n\t\t}\n\t}\n\n\t// Intentionally exclude API key material; only count non-empty entries.\n\tif count := countAPIKeys(entry); count > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"api_keys=%d\", count))\n\t}\n\n\tif len(parts) == 0 {\n\t\treturn \"\"\n\t}\n\tsum := sha256.Sum256([]byte(strings.Join(parts, \"|\")))\n\treturn hex.EncodeToString(sum[:])\n}\n"
  },
  {
    "path": "internal/watcher/diff/openai_compat_test.go",
    "content": "package diff\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc TestDiffOpenAICompatibility(t *testing.T) {\n\toldList := []config.OpenAICompatibility{\n\t\t{\n\t\t\tName: \"provider-a\",\n\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t{APIKey: \"key-a\"},\n\t\t\t},\n\t\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t\t{Name: \"m1\"},\n\t\t\t},\n\t\t},\n\t}\n\tnewList := []config.OpenAICompatibility{\n\t\t{\n\t\t\tName: \"provider-a\",\n\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t{APIKey: \"key-a\"},\n\t\t\t\t{APIKey: \"key-b\"},\n\t\t\t},\n\t\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t\t{Name: \"m1\"},\n\t\t\t\t{Name: \"m2\"},\n\t\t\t},\n\t\t\tHeaders: map[string]string{\"X-Test\": \"1\"},\n\t\t},\n\t\t{\n\t\t\tName:          \"provider-b\",\n\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"key-b\"}},\n\t\t},\n\t}\n\n\tchanges := DiffOpenAICompatibility(oldList, newList)\n\texpectContains(t, changes, \"provider added: provider-b (api-keys=1, models=0)\")\n\texpectContains(t, changes, \"provider updated: provider-a (api-keys 1 -> 2, models 1 -> 2, headers updated)\")\n}\n\nfunc TestDiffOpenAICompatibility_RemovedAndUnchanged(t *testing.T) {\n\toldList := []config.OpenAICompatibility{\n\t\t{\n\t\t\tName:          \"provider-a\",\n\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"key-a\"}},\n\t\t\tModels:        []config.OpenAICompatibilityModel{{Name: \"m1\"}},\n\t\t},\n\t}\n\tnewList := []config.OpenAICompatibility{\n\t\t{\n\t\t\tName:          \"provider-a\",\n\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"key-a\"}},\n\t\t\tModels:        []config.OpenAICompatibilityModel{{Name: \"m1\"}},\n\t\t},\n\t}\n\tif changes := DiffOpenAICompatibility(oldList, newList); len(changes) != 0 {\n\t\tt.Fatalf(\"expected no changes, got %v\", changes)\n\t}\n\n\tnewList = nil\n\tchanges := DiffOpenAICompatibility(oldList, newList)\n\texpectContains(t, changes, \"provider removed: provider-a (api-keys=1, models=1)\")\n}\n\nfunc TestOpenAICompatKeyFallbacks(t *testing.T) {\n\tentry := config.OpenAICompatibility{\n\t\tBaseURL: \"http://base\",\n\t\tModels:  []config.OpenAICompatibilityModel{{Alias: \"alias-only\"}},\n\t}\n\tkey, label := openAICompatKey(entry, 0)\n\tif key != \"base:http://base\" || label != \"http://base\" {\n\t\tt.Fatalf(\"expected base key, got %s/%s\", key, label)\n\t}\n\n\tentry.BaseURL = \"\"\n\tkey, label = openAICompatKey(entry, 1)\n\tif key != \"alias:alias-only\" || label != \"alias-only\" {\n\t\tt.Fatalf(\"expected alias fallback, got %s/%s\", key, label)\n\t}\n\n\tentry.Models = nil\n\tkey, label = openAICompatKey(entry, 2)\n\tif key != \"index:2\" || label != \"entry-3\" {\n\t\tt.Fatalf(\"expected index fallback, got %s/%s\", key, label)\n\t}\n}\n\nfunc TestOpenAICompatKey_UsesName(t *testing.T) {\n\tentry := config.OpenAICompatibility{Name: \"My-Provider\"}\n\tkey, label := openAICompatKey(entry, 0)\n\tif key != \"name:My-Provider\" || label != \"My-Provider\" {\n\t\tt.Fatalf(\"expected name key, got %s/%s\", key, label)\n\t}\n}\n\nfunc TestOpenAICompatKey_SignatureFallbackWhenOnlyAPIKeys(t *testing.T) {\n\tentry := config.OpenAICompatibility{\n\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"k1\"}, {APIKey: \"k2\"}},\n\t}\n\tkey, label := openAICompatKey(entry, 0)\n\tif !strings.HasPrefix(key, \"sig:\") || !strings.HasPrefix(label, \"compat-\") {\n\t\tt.Fatalf(\"expected signature key, got %s/%s\", key, label)\n\t}\n}\n\nfunc TestOpenAICompatSignature_EmptyReturnsEmpty(t *testing.T) {\n\tif got := openAICompatSignature(config.OpenAICompatibility{}); got != \"\" {\n\t\tt.Fatalf(\"expected empty signature, got %q\", got)\n\t}\n}\n\nfunc TestOpenAICompatSignature_StableAndNormalized(t *testing.T) {\n\ta := config.OpenAICompatibility{\n\t\tName:    \"  Provider  \",\n\t\tBaseURL: \"http://base\",\n\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t{Name: \"m1\"},\n\t\t\t{Name: \"  \"},\n\t\t\t{Alias: \"A1\"},\n\t\t},\n\t\tHeaders: map[string]string{\n\t\t\t\"X-Test\": \"1\",\n\t\t\t\"  \":     \"ignored\",\n\t\t},\n\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t{APIKey: \"k1\"},\n\t\t\t{APIKey: \" \"},\n\t\t},\n\t}\n\tb := config.OpenAICompatibility{\n\t\tName:    \"provider\",\n\t\tBaseURL: \"http://base\",\n\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t{Alias: \"a1\"},\n\t\t\t{Name: \"m1\"},\n\t\t},\n\t\tHeaders: map[string]string{\n\t\t\t\"x-test\": \"2\",\n\t\t},\n\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t{APIKey: \"k2\"},\n\t\t},\n\t}\n\n\tsigA := openAICompatSignature(a)\n\tsigB := openAICompatSignature(b)\n\tif sigA == \"\" || sigB == \"\" {\n\t\tt.Fatalf(\"expected non-empty signatures, got %q / %q\", sigA, sigB)\n\t}\n\tif sigA != sigB {\n\t\tt.Fatalf(\"expected normalized signatures to match, got %s / %s\", sigA, sigB)\n\t}\n\n\tc := b\n\tc.Models = append(c.Models, config.OpenAICompatibilityModel{Name: \"m2\"})\n\tif sigC := openAICompatSignature(c); sigC == sigB {\n\t\tt.Fatalf(\"expected signature to change when models change, got %s\", sigC)\n\t}\n}\n\nfunc TestCountOpenAIModelsSkipsBlanks(t *testing.T) {\n\tmodels := []config.OpenAICompatibilityModel{\n\t\t{Name: \"m1\"},\n\t\t{Name: \"\"},\n\t\t{Alias: \"\"},\n\t\t{Name: \" \"},\n\t\t{Alias: \"a1\"},\n\t}\n\tif got := countOpenAIModels(models); got != 2 {\n\t\tt.Fatalf(\"expected 2 counted models, got %d\", got)\n\t}\n}\n\nfunc TestOpenAICompatKeyUsesModelNameWhenAliasEmpty(t *testing.T) {\n\tentry := config.OpenAICompatibility{\n\t\tModels: []config.OpenAICompatibilityModel{{Name: \"model-name\"}},\n\t}\n\tkey, label := openAICompatKey(entry, 5)\n\tif key != \"alias:model-name\" || label != \"model-name\" {\n\t\tt.Fatalf(\"expected model-name fallback, got %s/%s\", key, label)\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/dispatcher.go",
    "content": "// dispatcher.go implements auth update dispatching and queue management.\n// It batches, deduplicates, and delivers auth updates to registered consumers.\npackage watcher\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nvar snapshotCoreAuthsFunc = snapshotCoreAuths\n\nfunc (w *Watcher) setAuthUpdateQueue(queue chan<- AuthUpdate) {\n\tw.clientsMutex.Lock()\n\tdefer w.clientsMutex.Unlock()\n\tw.authQueue = queue\n\tif w.dispatchCond == nil {\n\t\tw.dispatchCond = sync.NewCond(&w.dispatchMu)\n\t}\n\tif w.dispatchCancel != nil {\n\t\tw.dispatchCancel()\n\t\tif w.dispatchCond != nil {\n\t\t\tw.dispatchMu.Lock()\n\t\t\tw.dispatchCond.Broadcast()\n\t\t\tw.dispatchMu.Unlock()\n\t\t}\n\t\tw.dispatchCancel = nil\n\t}\n\tif queue != nil {\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\tw.dispatchCancel = cancel\n\t\tgo w.dispatchLoop(ctx)\n\t}\n}\n\nfunc (w *Watcher) dispatchRuntimeAuthUpdate(update AuthUpdate) bool {\n\tif w == nil {\n\t\treturn false\n\t}\n\tw.clientsMutex.Lock()\n\tif w.runtimeAuths == nil {\n\t\tw.runtimeAuths = make(map[string]*coreauth.Auth)\n\t}\n\tswitch update.Action {\n\tcase AuthUpdateActionAdd, AuthUpdateActionModify:\n\t\tif update.Auth != nil && update.Auth.ID != \"\" {\n\t\t\tclone := update.Auth.Clone()\n\t\t\tw.runtimeAuths[clone.ID] = clone\n\t\t\tif w.currentAuths == nil {\n\t\t\t\tw.currentAuths = make(map[string]*coreauth.Auth)\n\t\t\t}\n\t\t\tw.currentAuths[clone.ID] = clone.Clone()\n\t\t}\n\tcase AuthUpdateActionDelete:\n\t\tid := update.ID\n\t\tif id == \"\" && update.Auth != nil {\n\t\t\tid = update.Auth.ID\n\t\t}\n\t\tif id != \"\" {\n\t\t\tdelete(w.runtimeAuths, id)\n\t\t\tif w.currentAuths != nil {\n\t\t\t\tdelete(w.currentAuths, id)\n\t\t\t}\n\t\t}\n\t}\n\tw.clientsMutex.Unlock()\n\tif w.getAuthQueue() == nil {\n\t\treturn false\n\t}\n\tw.dispatchAuthUpdates([]AuthUpdate{update})\n\treturn true\n}\n\nfunc (w *Watcher) refreshAuthState(force bool) {\n\tw.clientsMutex.RLock()\n\tcfg := w.config\n\tauthDir := w.authDir\n\tw.clientsMutex.RUnlock()\n\tauths := snapshotCoreAuthsFunc(cfg, authDir)\n\tw.clientsMutex.Lock()\n\tif len(w.runtimeAuths) > 0 {\n\t\tfor _, a := range w.runtimeAuths {\n\t\t\tif a != nil {\n\t\t\t\tauths = append(auths, a.Clone())\n\t\t\t}\n\t\t}\n\t}\n\tupdates := w.prepareAuthUpdatesLocked(auths, force)\n\tw.clientsMutex.Unlock()\n\tw.dispatchAuthUpdates(updates)\n}\n\nfunc (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) []AuthUpdate {\n\tnewState := make(map[string]*coreauth.Auth, len(auths))\n\tfor _, auth := range auths {\n\t\tif auth == nil || auth.ID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnewState[auth.ID] = auth.Clone()\n\t}\n\tif w.currentAuths == nil {\n\t\tw.currentAuths = newState\n\t\tif w.authQueue == nil {\n\t\t\treturn nil\n\t\t}\n\t\tupdates := make([]AuthUpdate, 0, len(newState))\n\t\tfor id, auth := range newState {\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})\n\t\t}\n\t\treturn updates\n\t}\n\tif w.authQueue == nil {\n\t\tw.currentAuths = newState\n\t\treturn nil\n\t}\n\tupdates := make([]AuthUpdate, 0, len(newState)+len(w.currentAuths))\n\tfor id, auth := range newState {\n\t\tif existing, ok := w.currentAuths[id]; !ok {\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})\n\t\t} else if force || !authEqual(existing, auth) {\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: auth.Clone()})\n\t\t}\n\t}\n\tfor id := range w.currentAuths {\n\t\tif _, ok := newState[id]; !ok {\n\t\t\tupdates = append(updates, AuthUpdate{Action: AuthUpdateActionDelete, ID: id})\n\t\t}\n\t}\n\tw.currentAuths = newState\n\treturn updates\n}\n\nfunc (w *Watcher) dispatchAuthUpdates(updates []AuthUpdate) {\n\tif len(updates) == 0 {\n\t\treturn\n\t}\n\tqueue := w.getAuthQueue()\n\tif queue == nil {\n\t\treturn\n\t}\n\tbaseTS := time.Now().UnixNano()\n\tw.dispatchMu.Lock()\n\tif w.pendingUpdates == nil {\n\t\tw.pendingUpdates = make(map[string]AuthUpdate)\n\t}\n\tfor idx, update := range updates {\n\t\tkey := w.authUpdateKey(update, baseTS+int64(idx))\n\t\tif _, exists := w.pendingUpdates[key]; !exists {\n\t\t\tw.pendingOrder = append(w.pendingOrder, key)\n\t\t}\n\t\tw.pendingUpdates[key] = update\n\t}\n\tif w.dispatchCond != nil {\n\t\tw.dispatchCond.Signal()\n\t}\n\tw.dispatchMu.Unlock()\n}\n\nfunc (w *Watcher) authUpdateKey(update AuthUpdate, ts int64) string {\n\tif update.ID != \"\" {\n\t\treturn update.ID\n\t}\n\treturn fmt.Sprintf(\"%s:%d\", update.Action, ts)\n}\n\nfunc (w *Watcher) dispatchLoop(ctx context.Context) {\n\tfor {\n\t\tbatch, ok := w.nextPendingBatch(ctx)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tqueue := w.getAuthQueue()\n\t\tif queue == nil {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\tfor _, update := range batch {\n\t\t\tselect {\n\t\t\tcase queue <- update:\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (w *Watcher) nextPendingBatch(ctx context.Context) ([]AuthUpdate, bool) {\n\tw.dispatchMu.Lock()\n\tdefer w.dispatchMu.Unlock()\n\tfor len(w.pendingOrder) == 0 {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tw.dispatchCond.Wait()\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, false\n\t\t}\n\t}\n\tbatch := make([]AuthUpdate, 0, len(w.pendingOrder))\n\tfor _, key := range w.pendingOrder {\n\t\tbatch = append(batch, w.pendingUpdates[key])\n\t\tdelete(w.pendingUpdates, key)\n\t}\n\tw.pendingOrder = w.pendingOrder[:0]\n\treturn batch, true\n}\n\nfunc (w *Watcher) getAuthQueue() chan<- AuthUpdate {\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\treturn w.authQueue\n}\n\nfunc (w *Watcher) stopDispatch() {\n\tif w.dispatchCancel != nil {\n\t\tw.dispatchCancel()\n\t\tw.dispatchCancel = nil\n\t}\n\tw.dispatchMu.Lock()\n\tw.pendingOrder = nil\n\tw.pendingUpdates = nil\n\tif w.dispatchCond != nil {\n\t\tw.dispatchCond.Broadcast()\n\t}\n\tw.dispatchMu.Unlock()\n\tw.clientsMutex.Lock()\n\tw.authQueue = nil\n\tw.clientsMutex.Unlock()\n}\n\nfunc authEqual(a, b *coreauth.Auth) bool {\n\treturn reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b))\n}\n\nfunc normalizeAuth(a *coreauth.Auth) *coreauth.Auth {\n\tif a == nil {\n\t\treturn nil\n\t}\n\tclone := a.Clone()\n\tclone.CreatedAt = time.Time{}\n\tclone.UpdatedAt = time.Time{}\n\tclone.LastRefreshedAt = time.Time{}\n\tclone.NextRefreshAfter = time.Time{}\n\tclone.Runtime = nil\n\tclone.Quota.NextRecoverAt = time.Time{}\n\treturn clone\n}\n\nfunc snapshotCoreAuths(cfg *config.Config, authDir string) []*coreauth.Auth {\n\tctx := &synthesizer.SynthesisContext{\n\t\tConfig:      cfg,\n\t\tAuthDir:     authDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: synthesizer.NewStableIDGenerator(),\n\t}\n\n\tvar out []*coreauth.Auth\n\n\tconfigSynth := synthesizer.NewConfigSynthesizer()\n\tif auths, err := configSynth.Synthesize(ctx); err == nil {\n\t\tout = append(out, auths...)\n\t}\n\n\tfileSynth := synthesizer.NewFileSynthesizer()\n\tif auths, err := fileSynth.Synthesize(ctx); err == nil {\n\t\tout = append(out, auths...)\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "internal/watcher/events.go",
    "content": "// events.go implements fsnotify event handling for config and auth file changes.\n// It normalizes paths, debounces noisy events, and triggers reload/update logic.\npackage watcher\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nfunc matchProvider(provider string, targets []string) (string, bool) {\n\tp := strings.ToLower(strings.TrimSpace(provider))\n\tfor _, t := range targets {\n\t\tif strings.EqualFold(p, strings.TrimSpace(t)) {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn p, false\n}\n\nfunc (w *Watcher) start(ctx context.Context) error {\n\tif errAddConfig := w.watcher.Add(w.configPath); errAddConfig != nil {\n\t\tlog.Errorf(\"failed to watch config file %s: %v\", w.configPath, errAddConfig)\n\t\treturn errAddConfig\n\t}\n\tlog.Debugf(\"watching config file: %s\", w.configPath)\n\n\tif errAddAuthDir := w.watcher.Add(w.authDir); errAddAuthDir != nil {\n\t\tlog.Errorf(\"failed to watch auth directory %s: %v\", w.authDir, errAddAuthDir)\n\t\treturn errAddAuthDir\n\t}\n\tlog.Debugf(\"watching auth directory: %s\", w.authDir)\n\n\tgo w.processEvents(ctx)\n\n\tw.reloadClients(true, nil, false)\n\treturn nil\n}\n\nfunc (w *Watcher) processEvents(ctx context.Context) {\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase event, ok := <-w.watcher.Events:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.handleEvent(event)\n\t\tcase errWatch, ok := <-w.watcher.Errors:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog.Errorf(\"file watcher error: %v\", errWatch)\n\t\t}\n\t}\n}\n\nfunc (w *Watcher) handleEvent(event fsnotify.Event) {\n\t// Filter only relevant events: config file or auth-dir JSON files.\n\tconfigOps := fsnotify.Write | fsnotify.Create | fsnotify.Rename\n\tnormalizedName := w.normalizeAuthPath(event.Name)\n\tnormalizedConfigPath := w.normalizeAuthPath(w.configPath)\n\tnormalizedAuthDir := w.normalizeAuthPath(w.authDir)\n\tisConfigEvent := normalizedName == normalizedConfigPath && event.Op&configOps != 0\n\tauthOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename\n\tisAuthJSON := strings.HasPrefix(normalizedName, normalizedAuthDir) && strings.HasSuffix(normalizedName, \".json\") && event.Op&authOps != 0\n\tif !isConfigEvent && !isAuthJSON {\n\t\t// Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise.\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\tlog.Debugf(\"file system event detected: %s %s\", event.Op.String(), event.Name)\n\n\t// Handle config file changes\n\tif isConfigEvent {\n\t\tlog.Debugf(\"config file change details - operation: %s, timestamp: %s\", event.Op.String(), now.Format(\"2006-01-02 15:04:05.000\"))\n\t\tw.scheduleConfigReload()\n\t\treturn\n\t}\n\n\t// Handle auth directory changes incrementally (.json only)\n\tif event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {\n\t\tif w.shouldDebounceRemove(normalizedName, now) {\n\t\t\tlog.Debugf(\"debouncing remove event for %s\", filepath.Base(event.Name))\n\t\t\treturn\n\t\t}\n\t\t// Atomic replace on some platforms may surface as Rename (or Remove) before the new file is ready.\n\t\t// Wait briefly; if the path exists again, treat as an update instead of removal.\n\t\ttime.Sleep(replaceCheckDelay)\n\t\tif _, statErr := os.Stat(event.Name); statErr == nil {\n\t\t\tif unchanged, errSame := w.authFileUnchanged(event.Name); errSame == nil && unchanged {\n\t\t\t\tlog.Debugf(\"auth file unchanged (hash match), skipping reload: %s\", filepath.Base(event.Name))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog.Infof(\"auth file changed (%s): %s, processing incrementally\", event.Op.String(), filepath.Base(event.Name))\n\t\t\tw.addOrUpdateClient(event.Name)\n\t\t\treturn\n\t\t}\n\t\tif !w.isKnownAuthFile(event.Name) {\n\t\t\tlog.Debugf(\"ignoring remove for unknown auth file: %s\", filepath.Base(event.Name))\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"auth file changed (%s): %s, processing incrementally\", event.Op.String(), filepath.Base(event.Name))\n\t\tw.removeClient(event.Name)\n\t\treturn\n\t}\n\tif event.Op&(fsnotify.Create|fsnotify.Write) != 0 {\n\t\tif unchanged, errSame := w.authFileUnchanged(event.Name); errSame == nil && unchanged {\n\t\t\tlog.Debugf(\"auth file unchanged (hash match), skipping reload: %s\", filepath.Base(event.Name))\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"auth file changed (%s): %s, processing incrementally\", event.Op.String(), filepath.Base(event.Name))\n\t\tw.addOrUpdateClient(event.Name)\n\t}\n}\n\nfunc (w *Watcher) authFileUnchanged(path string) (bool, error) {\n\tdata, errRead := os.ReadFile(path)\n\tif errRead != nil {\n\t\treturn false, errRead\n\t}\n\tif len(data) == 0 {\n\t\treturn false, nil\n\t}\n\tsum := sha256.Sum256(data)\n\tcurHash := hex.EncodeToString(sum[:])\n\n\tnormalized := w.normalizeAuthPath(path)\n\tw.clientsMutex.RLock()\n\tprevHash, ok := w.lastAuthHashes[normalized]\n\tw.clientsMutex.RUnlock()\n\tif ok && prevHash == curHash {\n\t\treturn true, nil\n\t}\n\treturn false, nil\n}\n\nfunc (w *Watcher) isKnownAuthFile(path string) bool {\n\tnormalized := w.normalizeAuthPath(path)\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\t_, ok := w.lastAuthHashes[normalized]\n\treturn ok\n}\n\nfunc (w *Watcher) normalizeAuthPath(path string) string {\n\ttrimmed := strings.TrimSpace(path)\n\tif trimmed == \"\" {\n\t\treturn \"\"\n\t}\n\tcleaned := filepath.Clean(trimmed)\n\tif runtime.GOOS == \"windows\" {\n\t\tcleaned = strings.TrimPrefix(cleaned, `\\\\?\\`)\n\t\tcleaned = strings.ToLower(cleaned)\n\t}\n\treturn cleaned\n}\n\nfunc (w *Watcher) shouldDebounceRemove(normalizedPath string, now time.Time) bool {\n\tif normalizedPath == \"\" {\n\t\treturn false\n\t}\n\tw.clientsMutex.Lock()\n\tif w.lastRemoveTimes == nil {\n\t\tw.lastRemoveTimes = make(map[string]time.Time)\n\t}\n\tif last, ok := w.lastRemoveTimes[normalizedPath]; ok {\n\t\tif now.Sub(last) < authRemoveDebounceWindow {\n\t\t\tw.clientsMutex.Unlock()\n\t\t\treturn true\n\t\t}\n\t}\n\tw.lastRemoveTimes[normalizedPath] = now\n\tif len(w.lastRemoveTimes) > 128 {\n\t\tcutoff := now.Add(-2 * authRemoveDebounceWindow)\n\t\tfor p, t := range w.lastRemoveTimes {\n\t\t\tif t.Before(cutoff) {\n\t\t\t\tdelete(w.lastRemoveTimes, p)\n\t\t\t}\n\t\t}\n\t}\n\tw.clientsMutex.Unlock()\n\treturn false\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/config.go",
    "content": "package synthesizer\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// ConfigSynthesizer generates Auth entries from configuration API keys.\n// It handles Gemini, Claude, Codex, OpenAI-compat, and Vertex-compat providers.\ntype ConfigSynthesizer struct{}\n\n// NewConfigSynthesizer creates a new ConfigSynthesizer instance.\nfunc NewConfigSynthesizer() *ConfigSynthesizer {\n\treturn &ConfigSynthesizer{}\n}\n\n// Synthesize generates Auth entries from config API keys.\nfunc (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error) {\n\tout := make([]*coreauth.Auth, 0, 32)\n\tif ctx == nil || ctx.Config == nil {\n\t\treturn out, nil\n\t}\n\n\t// Gemini API Keys\n\tout = append(out, s.synthesizeGeminiKeys(ctx)...)\n\t// Claude API Keys\n\tout = append(out, s.synthesizeClaudeKeys(ctx)...)\n\t// Codex API Keys\n\tout = append(out, s.synthesizeCodexKeys(ctx)...)\n\t// OpenAI-compat\n\tout = append(out, s.synthesizeOpenAICompat(ctx)...)\n\t// Vertex-compat\n\tout = append(out, s.synthesizeVertexCompat(ctx)...)\n\n\treturn out, nil\n}\n\n// synthesizeGeminiKeys creates Auth entries for Gemini API keys.\nfunc (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*coreauth.Auth {\n\tcfg := ctx.Config\n\tnow := ctx.Now\n\tidGen := ctx.IDGenerator\n\n\tout := make([]*coreauth.Auth, 0, len(cfg.GeminiKey))\n\tfor i := range cfg.GeminiKey {\n\t\tentry := cfg.GeminiKey[i]\n\t\tkey := strings.TrimSpace(entry.APIKey)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tprefix := strings.TrimSpace(entry.Prefix)\n\t\tbase := strings.TrimSpace(entry.BaseURL)\n\t\tproxyURL := strings.TrimSpace(entry.ProxyURL)\n\t\tid, token := idGen.Next(\"gemini:apikey\", key, base)\n\t\tattrs := map[string]string{\n\t\t\t\"source\":  fmt.Sprintf(\"config:gemini[%s]\", token),\n\t\t\t\"api_key\": key,\n\t\t}\n\t\tif entry.Priority != 0 {\n\t\t\tattrs[\"priority\"] = strconv.Itoa(entry.Priority)\n\t\t}\n\t\tif base != \"\" {\n\t\t\tattrs[\"base_url\"] = base\n\t\t}\n\t\tif hash := diff.ComputeGeminiModelsHash(entry.Models); hash != \"\" {\n\t\t\tattrs[\"models_hash\"] = hash\n\t\t}\n\t\taddConfigHeadersToAttrs(entry.Headers, attrs)\n\t\ta := &coreauth.Auth{\n\t\t\tID:         id,\n\t\t\tProvider:   \"gemini\",\n\t\t\tLabel:      \"gemini-apikey\",\n\t\t\tPrefix:     prefix,\n\t\t\tStatus:     coreauth.StatusActive,\n\t\t\tProxyURL:   proxyURL,\n\t\t\tAttributes: attrs,\n\t\t\tCreatedAt:  now,\n\t\t\tUpdatedAt:  now,\n\t\t}\n\t\tApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, \"apikey\")\n\t\tout = append(out, a)\n\t}\n\treturn out\n}\n\n// synthesizeClaudeKeys creates Auth entries for Claude API keys.\nfunc (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*coreauth.Auth {\n\tcfg := ctx.Config\n\tnow := ctx.Now\n\tidGen := ctx.IDGenerator\n\n\tout := make([]*coreauth.Auth, 0, len(cfg.ClaudeKey))\n\tfor i := range cfg.ClaudeKey {\n\t\tck := cfg.ClaudeKey[i]\n\t\tkey := strings.TrimSpace(ck.APIKey)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tprefix := strings.TrimSpace(ck.Prefix)\n\t\tbase := strings.TrimSpace(ck.BaseURL)\n\t\tid, token := idGen.Next(\"claude:apikey\", key, base)\n\t\tattrs := map[string]string{\n\t\t\t\"source\":  fmt.Sprintf(\"config:claude[%s]\", token),\n\t\t\t\"api_key\": key,\n\t\t}\n\t\tif ck.Priority != 0 {\n\t\t\tattrs[\"priority\"] = strconv.Itoa(ck.Priority)\n\t\t}\n\t\tif base != \"\" {\n\t\t\tattrs[\"base_url\"] = base\n\t\t}\n\t\tif hash := diff.ComputeClaudeModelsHash(ck.Models); hash != \"\" {\n\t\t\tattrs[\"models_hash\"] = hash\n\t\t}\n\t\taddConfigHeadersToAttrs(ck.Headers, attrs)\n\t\tproxyURL := strings.TrimSpace(ck.ProxyURL)\n\t\ta := &coreauth.Auth{\n\t\t\tID:         id,\n\t\t\tProvider:   \"claude\",\n\t\t\tLabel:      \"claude-apikey\",\n\t\t\tPrefix:     prefix,\n\t\t\tStatus:     coreauth.StatusActive,\n\t\t\tProxyURL:   proxyURL,\n\t\t\tAttributes: attrs,\n\t\t\tCreatedAt:  now,\n\t\t\tUpdatedAt:  now,\n\t\t}\n\t\tApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, \"apikey\")\n\t\tout = append(out, a)\n\t}\n\treturn out\n}\n\n// synthesizeCodexKeys creates Auth entries for Codex API keys.\nfunc (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreauth.Auth {\n\tcfg := ctx.Config\n\tnow := ctx.Now\n\tidGen := ctx.IDGenerator\n\n\tout := make([]*coreauth.Auth, 0, len(cfg.CodexKey))\n\tfor i := range cfg.CodexKey {\n\t\tck := cfg.CodexKey[i]\n\t\tkey := strings.TrimSpace(ck.APIKey)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tprefix := strings.TrimSpace(ck.Prefix)\n\t\tid, token := idGen.Next(\"codex:apikey\", key, ck.BaseURL)\n\t\tattrs := map[string]string{\n\t\t\t\"source\":  fmt.Sprintf(\"config:codex[%s]\", token),\n\t\t\t\"api_key\": key,\n\t\t}\n\t\tif ck.Priority != 0 {\n\t\t\tattrs[\"priority\"] = strconv.Itoa(ck.Priority)\n\t\t}\n\t\tif ck.BaseURL != \"\" {\n\t\t\tattrs[\"base_url\"] = ck.BaseURL\n\t\t}\n\t\tif ck.Websockets {\n\t\t\tattrs[\"websockets\"] = \"true\"\n\t\t}\n\t\tif hash := diff.ComputeCodexModelsHash(ck.Models); hash != \"\" {\n\t\t\tattrs[\"models_hash\"] = hash\n\t\t}\n\t\taddConfigHeadersToAttrs(ck.Headers, attrs)\n\t\tproxyURL := strings.TrimSpace(ck.ProxyURL)\n\t\ta := &coreauth.Auth{\n\t\t\tID:         id,\n\t\t\tProvider:   \"codex\",\n\t\t\tLabel:      \"codex-apikey\",\n\t\t\tPrefix:     prefix,\n\t\t\tStatus:     coreauth.StatusActive,\n\t\t\tProxyURL:   proxyURL,\n\t\t\tAttributes: attrs,\n\t\t\tCreatedAt:  now,\n\t\t\tUpdatedAt:  now,\n\t\t}\n\t\tApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, \"apikey\")\n\t\tout = append(out, a)\n\t}\n\treturn out\n}\n\n// synthesizeOpenAICompat creates Auth entries for OpenAI-compatible providers.\nfunc (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*coreauth.Auth {\n\tcfg := ctx.Config\n\tnow := ctx.Now\n\tidGen := ctx.IDGenerator\n\n\tout := make([]*coreauth.Auth, 0)\n\tfor i := range cfg.OpenAICompatibility {\n\t\tcompat := &cfg.OpenAICompatibility[i]\n\t\tprefix := strings.TrimSpace(compat.Prefix)\n\t\tproviderName := strings.ToLower(strings.TrimSpace(compat.Name))\n\t\tif providerName == \"\" {\n\t\t\tproviderName = \"openai-compatibility\"\n\t\t}\n\t\tbase := strings.TrimSpace(compat.BaseURL)\n\n\t\t// Handle new APIKeyEntries format (preferred)\n\t\tcreatedEntries := 0\n\t\tfor j := range compat.APIKeyEntries {\n\t\t\tentry := &compat.APIKeyEntries[j]\n\t\t\tkey := strings.TrimSpace(entry.APIKey)\n\t\t\tproxyURL := strings.TrimSpace(entry.ProxyURL)\n\t\t\tidKind := fmt.Sprintf(\"openai-compatibility:%s\", providerName)\n\t\t\tid, token := idGen.Next(idKind, key, base, proxyURL)\n\t\t\tattrs := map[string]string{\n\t\t\t\t\"source\":       fmt.Sprintf(\"config:%s[%s]\", providerName, token),\n\t\t\t\t\"base_url\":     base,\n\t\t\t\t\"compat_name\":  compat.Name,\n\t\t\t\t\"provider_key\": providerName,\n\t\t\t}\n\t\t\tif compat.Priority != 0 {\n\t\t\t\tattrs[\"priority\"] = strconv.Itoa(compat.Priority)\n\t\t\t}\n\t\t\tif key != \"\" {\n\t\t\t\tattrs[\"api_key\"] = key\n\t\t\t}\n\t\t\tif hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != \"\" {\n\t\t\t\tattrs[\"models_hash\"] = hash\n\t\t\t}\n\t\t\taddConfigHeadersToAttrs(compat.Headers, attrs)\n\t\t\ta := &coreauth.Auth{\n\t\t\t\tID:         id,\n\t\t\t\tProvider:   providerName,\n\t\t\t\tLabel:      compat.Name,\n\t\t\t\tPrefix:     prefix,\n\t\t\t\tStatus:     coreauth.StatusActive,\n\t\t\t\tProxyURL:   proxyURL,\n\t\t\t\tAttributes: attrs,\n\t\t\t\tCreatedAt:  now,\n\t\t\t\tUpdatedAt:  now,\n\t\t\t}\n\t\t\tout = append(out, a)\n\t\t\tcreatedEntries++\n\t\t}\n\t\t// Fallback: create entry without API key if no APIKeyEntries\n\t\tif createdEntries == 0 {\n\t\t\tidKind := fmt.Sprintf(\"openai-compatibility:%s\", providerName)\n\t\t\tid, token := idGen.Next(idKind, base)\n\t\t\tattrs := map[string]string{\n\t\t\t\t\"source\":       fmt.Sprintf(\"config:%s[%s]\", providerName, token),\n\t\t\t\t\"base_url\":     base,\n\t\t\t\t\"compat_name\":  compat.Name,\n\t\t\t\t\"provider_key\": providerName,\n\t\t\t}\n\t\t\tif compat.Priority != 0 {\n\t\t\t\tattrs[\"priority\"] = strconv.Itoa(compat.Priority)\n\t\t\t}\n\t\t\tif hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != \"\" {\n\t\t\t\tattrs[\"models_hash\"] = hash\n\t\t\t}\n\t\t\taddConfigHeadersToAttrs(compat.Headers, attrs)\n\t\t\ta := &coreauth.Auth{\n\t\t\t\tID:         id,\n\t\t\t\tProvider:   providerName,\n\t\t\t\tLabel:      compat.Name,\n\t\t\t\tPrefix:     prefix,\n\t\t\t\tStatus:     coreauth.StatusActive,\n\t\t\t\tAttributes: attrs,\n\t\t\t\tCreatedAt:  now,\n\t\t\t\tUpdatedAt:  now,\n\t\t\t}\n\t\t\tout = append(out, a)\n\t\t}\n\t}\n\treturn out\n}\n\n// synthesizeVertexCompat creates Auth entries for Vertex-compatible providers.\nfunc (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*coreauth.Auth {\n\tcfg := ctx.Config\n\tnow := ctx.Now\n\tidGen := ctx.IDGenerator\n\n\tout := make([]*coreauth.Auth, 0, len(cfg.VertexCompatAPIKey))\n\tfor i := range cfg.VertexCompatAPIKey {\n\t\tcompat := &cfg.VertexCompatAPIKey[i]\n\t\tproviderName := \"vertex\"\n\t\tbase := strings.TrimSpace(compat.BaseURL)\n\n\t\tkey := strings.TrimSpace(compat.APIKey)\n\t\tprefix := strings.TrimSpace(compat.Prefix)\n\t\tproxyURL := strings.TrimSpace(compat.ProxyURL)\n\t\tidKind := \"vertex:apikey\"\n\t\tid, token := idGen.Next(idKind, key, base, proxyURL)\n\t\tattrs := map[string]string{\n\t\t\t\"source\":       fmt.Sprintf(\"config:vertex-apikey[%s]\", token),\n\t\t\t\"base_url\":     base,\n\t\t\t\"provider_key\": providerName,\n\t\t}\n\t\tif compat.Priority != 0 {\n\t\t\tattrs[\"priority\"] = strconv.Itoa(compat.Priority)\n\t\t}\n\t\tif key != \"\" {\n\t\t\tattrs[\"api_key\"] = key\n\t\t}\n\t\tif hash := diff.ComputeVertexCompatModelsHash(compat.Models); hash != \"\" {\n\t\t\tattrs[\"models_hash\"] = hash\n\t\t}\n\t\taddConfigHeadersToAttrs(compat.Headers, attrs)\n\t\ta := &coreauth.Auth{\n\t\t\tID:         id,\n\t\t\tProvider:   providerName,\n\t\t\tLabel:      \"vertex-apikey\",\n\t\t\tPrefix:     prefix,\n\t\t\tStatus:     coreauth.StatusActive,\n\t\t\tProxyURL:   proxyURL,\n\t\t\tAttributes: attrs,\n\t\t\tCreatedAt:  now,\n\t\t\tUpdatedAt:  now,\n\t\t}\n\t\tApplyAuthExcludedModelsMeta(a, cfg, compat.ExcludedModels, \"apikey\")\n\t\tout = append(out, a)\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/config_test.go",
    "content": "package synthesizer\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestNewConfigSynthesizer(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tif synth == nil {\n\t\tt.Fatal(\"expected non-nil synthesizer\")\n\t}\n}\n\nfunc TestConfigSynthesizer_Synthesize_NilContext(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tauths, err := synth.Synthesize(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 0 {\n\t\tt.Fatalf(\"expected empty auths, got %d\", len(auths))\n\t}\n}\n\nfunc TestConfigSynthesizer_Synthesize_NilConfig(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      nil,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 0 {\n\t\tt.Fatalf(\"expected empty auths, got %d\", len(auths))\n\t}\n}\n\nfunc TestConfigSynthesizer_GeminiKeys(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tgeminiKeys []config.GeminiKey\n\t\twantLen    int\n\t\tvalidate   func(*testing.T, []*coreauth.Auth)\n\t}{\n\t\t{\n\t\t\tname: \"single gemini key\",\n\t\t\tgeminiKeys: []config.GeminiKey{\n\t\t\t\t{APIKey: \"test-key-123\", Prefix: \"team-a\"},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t\tvalidate: func(t *testing.T, auths []*coreauth.Auth) {\n\t\t\t\tif auths[0].Provider != \"gemini\" {\n\t\t\t\t\tt.Errorf(\"expected provider gemini, got %s\", auths[0].Provider)\n\t\t\t\t}\n\t\t\t\tif auths[0].Prefix != \"team-a\" {\n\t\t\t\t\tt.Errorf(\"expected prefix team-a, got %s\", auths[0].Prefix)\n\t\t\t\t}\n\t\t\t\tif auths[0].Label != \"gemini-apikey\" {\n\t\t\t\t\tt.Errorf(\"expected label gemini-apikey, got %s\", auths[0].Label)\n\t\t\t\t}\n\t\t\t\tif auths[0].Attributes[\"api_key\"] != \"test-key-123\" {\n\t\t\t\t\tt.Errorf(\"expected api_key test-key-123, got %s\", auths[0].Attributes[\"api_key\"])\n\t\t\t\t}\n\t\t\t\tif auths[0].Status != coreauth.StatusActive {\n\t\t\t\t\tt.Errorf(\"expected status active, got %s\", auths[0].Status)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gemini key with base url and proxy\",\n\t\t\tgeminiKeys: []config.GeminiKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:   \"api-key\",\n\t\t\t\t\tBaseURL:  \"https://custom.api.com\",\n\t\t\t\t\tProxyURL: \"http://proxy.local:8080\",\n\t\t\t\t\tPrefix:   \"custom\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t\tvalidate: func(t *testing.T, auths []*coreauth.Auth) {\n\t\t\t\tif auths[0].Attributes[\"base_url\"] != \"https://custom.api.com\" {\n\t\t\t\t\tt.Errorf(\"expected base_url https://custom.api.com, got %s\", auths[0].Attributes[\"base_url\"])\n\t\t\t\t}\n\t\t\t\tif auths[0].ProxyURL != \"http://proxy.local:8080\" {\n\t\t\t\t\tt.Errorf(\"expected proxy_url http://proxy.local:8080, got %s\", auths[0].ProxyURL)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gemini key with headers\",\n\t\t\tgeminiKeys: []config.GeminiKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:  \"api-key\",\n\t\t\t\t\tHeaders: map[string]string{\"X-Custom\": \"value\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t\tvalidate: func(t *testing.T, auths []*coreauth.Auth) {\n\t\t\t\tif auths[0].Attributes[\"header:X-Custom\"] != \"value\" {\n\t\t\t\t\tt.Errorf(\"expected header:X-Custom=value, got %s\", auths[0].Attributes[\"header:X-Custom\"])\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty api key skipped\",\n\t\t\tgeminiKeys: []config.GeminiKey{\n\t\t\t\t{APIKey: \"\"},\n\t\t\t\t{APIKey: \"  \"},\n\t\t\t\t{APIKey: \"valid-key\"},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple gemini keys\",\n\t\t\tgeminiKeys: []config.GeminiKey{\n\t\t\t\t{APIKey: \"key-1\", Prefix: \"a\"},\n\t\t\t\t{APIKey: \"key-2\", Prefix: \"b\"},\n\t\t\t\t{APIKey: \"key-3\", Prefix: \"c\"},\n\t\t\t},\n\t\t\twantLen: 3,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsynth := NewConfigSynthesizer()\n\t\t\tctx := &SynthesisContext{\n\t\t\t\tConfig: &config.Config{\n\t\t\t\t\tGeminiKey: tt.geminiKeys,\n\t\t\t\t},\n\t\t\t\tNow:         time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\t\t\tIDGenerator: NewStableIDGenerator(),\n\t\t\t}\n\n\t\t\tauths, err := synth.Synthesize(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif len(auths) != tt.wantLen {\n\t\t\t\tt.Fatalf(\"expected %d auths, got %d\", tt.wantLen, len(auths))\n\t\t\t}\n\n\t\t\tif tt.validate != nil && len(auths) > 0 {\n\t\t\t\ttt.validate(t, auths)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfigSynthesizer_ClaudeKeys(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:  \"sk-ant-api-xxx\",\n\t\t\t\t\tPrefix:  \"main\",\n\t\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\t\tModels: []config.ClaudeModel{\n\t\t\t\t\t\t{Name: \"claude-3-opus\"},\n\t\t\t\t\t\t{Name: \"claude-3-sonnet\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tif auths[0].Provider != \"claude\" {\n\t\tt.Errorf(\"expected provider claude, got %s\", auths[0].Provider)\n\t}\n\tif auths[0].Label != \"claude-apikey\" {\n\t\tt.Errorf(\"expected label claude-apikey, got %s\", auths[0].Label)\n\t}\n\tif auths[0].Prefix != \"main\" {\n\t\tt.Errorf(\"expected prefix main, got %s\", auths[0].Prefix)\n\t}\n\tif auths[0].Attributes[\"api_key\"] != \"sk-ant-api-xxx\" {\n\t\tt.Errorf(\"expected api_key sk-ant-api-xxx, got %s\", auths[0].Attributes[\"api_key\"])\n\t}\n\tif _, ok := auths[0].Attributes[\"models_hash\"]; !ok {\n\t\tt.Error(\"expected models_hash in attributes\")\n\t}\n}\n\nfunc TestConfigSynthesizer_ClaudeKeys_SkipsEmptyAndHeaders(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t\t{APIKey: \"\"},    // empty, should be skipped\n\t\t\t\t{APIKey: \"   \"}, // whitespace, should be skipped\n\t\t\t\t{APIKey: \"valid-key\", Headers: map[string]string{\"X-Custom\": \"value\"}},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth (empty keys skipped), got %d\", len(auths))\n\t}\n\tif auths[0].Attributes[\"header:X-Custom\"] != \"value\" {\n\t\tt.Errorf(\"expected header:X-Custom=value, got %s\", auths[0].Attributes[\"header:X-Custom\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_CodexKeys(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tCodexKey: []config.CodexKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:     \"codex-key-123\",\n\t\t\t\t\tPrefix:     \"dev\",\n\t\t\t\t\tBaseURL:    \"https://api.openai.com\",\n\t\t\t\t\tProxyURL:   \"http://proxy.local\",\n\t\t\t\t\tWebsockets: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tif auths[0].Provider != \"codex\" {\n\t\tt.Errorf(\"expected provider codex, got %s\", auths[0].Provider)\n\t}\n\tif auths[0].Label != \"codex-apikey\" {\n\t\tt.Errorf(\"expected label codex-apikey, got %s\", auths[0].Label)\n\t}\n\tif auths[0].ProxyURL != \"http://proxy.local\" {\n\t\tt.Errorf(\"expected proxy_url http://proxy.local, got %s\", auths[0].ProxyURL)\n\t}\n\tif auths[0].Attributes[\"websockets\"] != \"true\" {\n\t\tt.Errorf(\"expected websockets=true, got %s\", auths[0].Attributes[\"websockets\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tCodexKey: []config.CodexKey{\n\t\t\t\t{APIKey: \"\"},   // empty, should be skipped\n\t\t\t\t{APIKey: \"  \"}, // whitespace, should be skipped\n\t\t\t\t{APIKey: \"valid-key\", Headers: map[string]string{\"Authorization\": \"Bearer xyz\"}},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth (empty keys skipped), got %d\", len(auths))\n\t}\n\tif auths[0].Attributes[\"header:Authorization\"] != \"Bearer xyz\" {\n\t\tt.Errorf(\"expected header:Authorization=Bearer xyz, got %s\", auths[0].Attributes[\"header:Authorization\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_OpenAICompat(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcompat  []config.OpenAICompatibility\n\t\twantLen int\n\t}{\n\t\t{\n\t\t\tname: \"with APIKeyEntries\",\n\t\t\tcompat: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"CustomProvider\",\n\t\t\t\t\tBaseURL: \"https://custom.api.com\",\n\t\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t\t{APIKey: \"key-1\"},\n\t\t\t\t\t\t{APIKey: \"key-2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKeyEntries included (legacy)\",\n\t\t\tcompat: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"EmptyKeys\",\n\t\t\t\t\tBaseURL: \"https://empty.api.com\",\n\t\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t\t{APIKey: \"\"},\n\t\t\t\t\t\t{APIKey: \"   \"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"without APIKeyEntries (fallback)\",\n\t\t\tcompat: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"NoKeyProvider\",\n\t\t\t\t\tBaseURL: \"https://no-key.api.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"empty name defaults\",\n\t\t\tcompat: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"\",\n\t\t\t\t\tBaseURL: \"https://default.api.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantLen: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsynth := NewConfigSynthesizer()\n\t\t\tctx := &SynthesisContext{\n\t\t\t\tConfig: &config.Config{\n\t\t\t\t\tOpenAICompatibility: tt.compat,\n\t\t\t\t},\n\t\t\t\tNow:         time.Now(),\n\t\t\t\tIDGenerator: NewStableIDGenerator(),\n\t\t\t}\n\n\t\t\tauths, err := synth.Synthesize(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif len(auths) != tt.wantLen {\n\t\t\t\tt.Fatalf(\"expected %d auths, got %d\", tt.wantLen, len(auths))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfigSynthesizer_VertexCompat(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:  \"vertex-key-123\",\n\t\t\t\t\tBaseURL: \"https://vertex.googleapis.com\",\n\t\t\t\t\tPrefix:  \"vertex-prod\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tif auths[0].Provider != \"vertex\" {\n\t\tt.Errorf(\"expected provider vertex, got %s\", auths[0].Provider)\n\t}\n\tif auths[0].Label != \"vertex-apikey\" {\n\t\tt.Errorf(\"expected label vertex-apikey, got %s\", auths[0].Label)\n\t}\n\tif auths[0].Prefix != \"vertex-prod\" {\n\t\tt.Errorf(\"expected prefix vertex-prod, got %s\", auths[0].Prefix)\n\t}\n}\n\nfunc TestConfigSynthesizer_VertexCompat_SkipsEmptyAndHeaders(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t\t{APIKey: \"\", BaseURL: \"https://vertex.api\"},   // empty key creates auth without api_key attr\n\t\t\t\t{APIKey: \"  \", BaseURL: \"https://vertex.api\"}, // whitespace key creates auth without api_key attr\n\t\t\t\t{APIKey: \"valid-key\", BaseURL: \"https://vertex.api\", Headers: map[string]string{\"X-Vertex\": \"test\"}},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Vertex compat doesn't skip empty keys - it creates auths without api_key attribute\n\tif len(auths) != 3 {\n\t\tt.Fatalf(\"expected 3 auths, got %d\", len(auths))\n\t}\n\t// First two should not have api_key attribute\n\tif _, ok := auths[0].Attributes[\"api_key\"]; ok {\n\t\tt.Error(\"expected first auth to not have api_key attribute\")\n\t}\n\tif _, ok := auths[1].Attributes[\"api_key\"]; ok {\n\t\tt.Error(\"expected second auth to not have api_key attribute\")\n\t}\n\t// Third should have headers\n\tif auths[2].Attributes[\"header:X-Vertex\"] != \"test\" {\n\t\tt.Errorf(\"expected header:X-Vertex=test, got %s\", auths[2].Attributes[\"header:X-Vertex\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_OpenAICompat_WithModelsHash(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"TestProvider\",\n\t\t\t\t\tBaseURL: \"https://test.api.com\",\n\t\t\t\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t\t\t\t{Name: \"model-a\"},\n\t\t\t\t\t\t{Name: \"model-b\"},\n\t\t\t\t\t},\n\t\t\t\t\tAPIKeyEntries: []config.OpenAICompatibilityAPIKey{\n\t\t\t\t\t\t{APIKey: \"key-with-models\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\tif _, ok := auths[0].Attributes[\"models_hash\"]; !ok {\n\t\tt.Error(\"expected models_hash in attributes\")\n\t}\n\tif auths[0].Attributes[\"api_key\"] != \"key-with-models\" {\n\t\tt.Errorf(\"expected api_key key-with-models, got %s\", auths[0].Attributes[\"api_key\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_OpenAICompat_FallbackWithModels(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t\t{\n\t\t\t\t\tName:    \"NoKeyWithModels\",\n\t\t\t\t\tBaseURL: \"https://nokey.api.com\",\n\t\t\t\t\tModels: []config.OpenAICompatibilityModel{\n\t\t\t\t\t\t{Name: \"model-x\"},\n\t\t\t\t\t},\n\t\t\t\t\tHeaders: map[string]string{\"X-API\": \"header-value\"},\n\t\t\t\t\t// No APIKeyEntries - should use fallback path\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\tif _, ok := auths[0].Attributes[\"models_hash\"]; !ok {\n\t\tt.Error(\"expected models_hash in fallback path\")\n\t}\n\tif auths[0].Attributes[\"header:X-API\"] != \"header-value\" {\n\t\tt.Errorf(\"expected header:X-API=header-value, got %s\", auths[0].Attributes[\"header:X-API\"])\n\t}\n}\n\nfunc TestConfigSynthesizer_VertexCompat_WithModels(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t\t{\n\t\t\t\t\tAPIKey:  \"vertex-key\",\n\t\t\t\t\tBaseURL: \"https://vertex.api\",\n\t\t\t\t\tModels: []config.VertexCompatModel{\n\t\t\t\t\t\t{Name: \"gemini-pro\", Alias: \"pro\"},\n\t\t\t\t\t\t{Name: \"gemini-ultra\", Alias: \"ultra\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\tif _, ok := auths[0].Attributes[\"models_hash\"]; !ok {\n\t\tt.Error(\"expected models_hash in vertex auth with models\")\n\t}\n}\n\nfunc TestConfigSynthesizer_IDStability(t *testing.T) {\n\tcfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{APIKey: \"stable-key\", Prefix: \"test\"},\n\t\t},\n\t}\n\n\t// Generate IDs twice with fresh generators\n\tsynth1 := NewConfigSynthesizer()\n\tctx1 := &SynthesisContext{\n\t\tConfig:      cfg,\n\t\tNow:         time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\tauths1, _ := synth1.Synthesize(ctx1)\n\n\tsynth2 := NewConfigSynthesizer()\n\tctx2 := &SynthesisContext{\n\t\tConfig:      cfg,\n\t\tNow:         time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\tauths2, _ := synth2.Synthesize(ctx2)\n\n\tif auths1[0].ID != auths2[0].ID {\n\t\tt.Errorf(\"same config should produce same ID: got %q and %q\", auths1[0].ID, auths2[0].ID)\n\t}\n}\n\nfunc TestConfigSynthesizer_AllProviders(t *testing.T) {\n\tsynth := NewConfigSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tGeminiKey: []config.GeminiKey{\n\t\t\t\t{APIKey: \"gemini-key\"},\n\t\t\t},\n\t\t\tClaudeKey: []config.ClaudeKey{\n\t\t\t\t{APIKey: \"claude-key\"},\n\t\t\t},\n\t\t\tCodexKey: []config.CodexKey{\n\t\t\t\t{APIKey: \"codex-key\"},\n\t\t\t},\n\t\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t\t{Name: \"compat\", BaseURL: \"https://compat.api\"},\n\t\t\t},\n\t\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t\t{APIKey: \"vertex-key\", BaseURL: \"https://vertex.api\"},\n\t\t\t},\n\t\t},\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 5 {\n\t\tt.Fatalf(\"expected 5 auths, got %d\", len(auths))\n\t}\n\n\tproviders := make(map[string]bool)\n\tfor _, a := range auths {\n\t\tproviders[a.Provider] = true\n\t}\n\n\texpected := []string{\"gemini\", \"claude\", \"codex\", \"compat\", \"vertex\"}\n\tfor _, p := range expected {\n\t\tif !providers[p] {\n\t\t\tt.Errorf(\"expected provider %s not found\", p)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/context.go",
    "content": "package synthesizer\n\nimport (\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\n// SynthesisContext provides the context needed for auth synthesis.\ntype SynthesisContext struct {\n\t// Config is the current configuration\n\tConfig *config.Config\n\t// AuthDir is the directory containing auth files\n\tAuthDir string\n\t// Now is the current time for timestamps\n\tNow time.Time\n\t// IDGenerator generates stable IDs for auth entries\n\tIDGenerator *StableIDGenerator\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/file.go",
    "content": "package synthesizer\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// FileSynthesizer generates Auth entries from OAuth JSON files.\n// It handles file-based authentication and Gemini virtual auth generation.\ntype FileSynthesizer struct{}\n\n// NewFileSynthesizer creates a new FileSynthesizer instance.\nfunc NewFileSynthesizer() *FileSynthesizer {\n\treturn &FileSynthesizer{}\n}\n\n// Synthesize generates Auth entries from auth files in the auth directory.\nfunc (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error) {\n\tout := make([]*coreauth.Auth, 0, 16)\n\tif ctx == nil || ctx.AuthDir == \"\" {\n\t\treturn out, nil\n\t}\n\n\tentries, err := os.ReadDir(ctx.AuthDir)\n\tif err != nil {\n\t\t// Not an error if directory doesn't exist\n\t\treturn out, nil\n\t}\n\n\tfor _, e := range entries {\n\t\tif e.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := e.Name()\n\t\tif !strings.HasSuffix(strings.ToLower(name), \".json\") {\n\t\t\tcontinue\n\t\t}\n\t\tfull := filepath.Join(ctx.AuthDir, name)\n\t\tdata, errRead := os.ReadFile(full)\n\t\tif errRead != nil || len(data) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tauths := synthesizeFileAuths(ctx, full, data)\n\t\tif len(auths) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, auths...)\n\t}\n\treturn out, nil\n}\n\n// SynthesizeAuthFile generates Auth entries for one auth JSON file payload.\n// It shares exactly the same mapping behavior as FileSynthesizer.Synthesize.\nfunc SynthesizeAuthFile(ctx *SynthesisContext, fullPath string, data []byte) []*coreauth.Auth {\n\treturn synthesizeFileAuths(ctx, fullPath, data)\n}\n\nfunc synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) []*coreauth.Auth {\n\tif ctx == nil || len(data) == 0 {\n\t\treturn nil\n\t}\n\tnow := ctx.Now\n\tcfg := ctx.Config\n\tvar metadata map[string]any\n\tif errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {\n\t\treturn nil\n\t}\n\tt, _ := metadata[\"type\"].(string)\n\tif t == \"\" {\n\t\treturn nil\n\t}\n\tprovider := strings.ToLower(t)\n\tif provider == \"gemini\" {\n\t\tprovider = \"gemini-cli\"\n\t}\n\tlabel := provider\n\tif email, _ := metadata[\"email\"].(string); email != \"\" {\n\t\tlabel = email\n\t}\n\t// Use relative path under authDir as ID to stay consistent with the file-based token store.\n\tid := fullPath\n\tif strings.TrimSpace(ctx.AuthDir) != \"\" {\n\t\tif rel, errRel := filepath.Rel(ctx.AuthDir, fullPath); errRel == nil && rel != \"\" {\n\t\t\tid = rel\n\t\t}\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\tid = strings.ToLower(id)\n\t}\n\n\tproxyURL := \"\"\n\tif p, ok := metadata[\"proxy_url\"].(string); ok {\n\t\tproxyURL = p\n\t}\n\n\tprefix := \"\"\n\tif rawPrefix, ok := metadata[\"prefix\"].(string); ok {\n\t\ttrimmed := strings.TrimSpace(rawPrefix)\n\t\ttrimmed = strings.Trim(trimmed, \"/\")\n\t\tif trimmed != \"\" && !strings.Contains(trimmed, \"/\") {\n\t\t\tprefix = trimmed\n\t\t}\n\t}\n\n\tdisabled, _ := metadata[\"disabled\"].(bool)\n\tstatus := coreauth.StatusActive\n\tif disabled {\n\t\tstatus = coreauth.StatusDisabled\n\t}\n\n\t// Read per-account excluded models from the OAuth JSON file.\n\tperAccountExcluded := extractExcludedModelsFromMetadata(metadata)\n\n\ta := &coreauth.Auth{\n\t\tID:       id,\n\t\tProvider: provider,\n\t\tLabel:    label,\n\t\tPrefix:   prefix,\n\t\tStatus:   status,\n\t\tDisabled: disabled,\n\t\tAttributes: map[string]string{\n\t\t\t\"source\": fullPath,\n\t\t\t\"path\":   fullPath,\n\t\t},\n\t\tProxyURL:  proxyURL,\n\t\tMetadata:  metadata,\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}\n\t// Read priority from auth file.\n\tif rawPriority, ok := metadata[\"priority\"]; ok {\n\t\tswitch v := rawPriority.(type) {\n\t\tcase float64:\n\t\t\ta.Attributes[\"priority\"] = strconv.Itoa(int(v))\n\t\tcase string:\n\t\t\tpriority := strings.TrimSpace(v)\n\t\t\tif _, errAtoi := strconv.Atoi(priority); errAtoi == nil {\n\t\t\t\ta.Attributes[\"priority\"] = priority\n\t\t\t}\n\t\t}\n\t}\n\t// Read note from auth file.\n\tif rawNote, ok := metadata[\"note\"]; ok {\n\t\tif note, isStr := rawNote.(string); isStr {\n\t\t\tif trimmed := strings.TrimSpace(note); trimmed != \"\" {\n\t\t\t\ta.Attributes[\"note\"] = trimmed\n\t\t\t}\n\t\t}\n\t}\n\tApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, \"oauth\")\n\t// For codex auth files, extract plan_type from the JWT id_token.\n\tif provider == \"codex\" {\n\t\tif idTokenRaw, ok := metadata[\"id_token\"].(string); ok && strings.TrimSpace(idTokenRaw) != \"\" {\n\t\t\tif claims, errParse := codex.ParseJWTToken(idTokenRaw); errParse == nil && claims != nil {\n\t\t\t\tif pt := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); pt != \"\" {\n\t\t\t\t\ta.Attributes[\"plan_type\"] = pt\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif provider == \"gemini-cli\" {\n\t\tif virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {\n\t\t\tfor _, v := range virtuals {\n\t\t\t\tApplyAuthExcludedModelsMeta(v, cfg, perAccountExcluded, \"oauth\")\n\t\t\t}\n\t\t\tout := make([]*coreauth.Auth, 0, 1+len(virtuals))\n\t\t\tout = append(out, a)\n\t\t\tout = append(out, virtuals...)\n\t\t\treturn out\n\t\t}\n\t}\n\treturn []*coreauth.Auth{a}\n}\n\n// SynthesizeGeminiVirtualAuths creates virtual Auth entries for multi-project Gemini credentials.\n// It disables the primary auth and creates one virtual auth per project.\nfunc SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]any, now time.Time) []*coreauth.Auth {\n\tif primary == nil || metadata == nil {\n\t\treturn nil\n\t}\n\tprojects := splitGeminiProjectIDs(metadata)\n\tif len(projects) <= 1 {\n\t\treturn nil\n\t}\n\temail, _ := metadata[\"email\"].(string)\n\tshared := geminicli.NewSharedCredential(primary.ID, email, metadata, projects)\n\tprimary.Disabled = true\n\tprimary.Status = coreauth.StatusDisabled\n\tprimary.Runtime = shared\n\tif primary.Attributes == nil {\n\t\tprimary.Attributes = make(map[string]string)\n\t}\n\tprimary.Attributes[\"gemini_virtual_primary\"] = \"true\"\n\tprimary.Attributes[\"virtual_children\"] = strings.Join(projects, \",\")\n\tsource := primary.Attributes[\"source\"]\n\tauthPath := primary.Attributes[\"path\"]\n\toriginalProvider := primary.Provider\n\tif originalProvider == \"\" {\n\t\toriginalProvider = \"gemini-cli\"\n\t}\n\tlabel := primary.Label\n\tif label == \"\" {\n\t\tlabel = originalProvider\n\t}\n\tvirtuals := make([]*coreauth.Auth, 0, len(projects))\n\tfor _, projectID := range projects {\n\t\tattrs := map[string]string{\n\t\t\t\"runtime_only\":           \"true\",\n\t\t\t\"gemini_virtual_parent\":  primary.ID,\n\t\t\t\"gemini_virtual_project\": projectID,\n\t\t}\n\t\tif source != \"\" {\n\t\t\tattrs[\"source\"] = source\n\t\t}\n\t\tif authPath != \"\" {\n\t\t\tattrs[\"path\"] = authPath\n\t\t}\n\t\t// Propagate priority from primary auth to virtual auths\n\t\tif priorityVal, hasPriority := primary.Attributes[\"priority\"]; hasPriority && priorityVal != \"\" {\n\t\t\tattrs[\"priority\"] = priorityVal\n\t\t}\n\t\t// Propagate note from primary auth to virtual auths\n\t\tif noteVal, hasNote := primary.Attributes[\"note\"]; hasNote && noteVal != \"\" {\n\t\t\tattrs[\"note\"] = noteVal\n\t\t}\n\t\tmetadataCopy := map[string]any{\n\t\t\t\"email\":             email,\n\t\t\t\"project_id\":        projectID,\n\t\t\t\"virtual\":           true,\n\t\t\t\"virtual_parent_id\": primary.ID,\n\t\t\t\"type\":              metadata[\"type\"],\n\t\t}\n\t\tif v, ok := metadata[\"disable_cooling\"]; ok {\n\t\t\tmetadataCopy[\"disable_cooling\"] = v\n\t\t} else if v, ok := metadata[\"disable-cooling\"]; ok {\n\t\t\tmetadataCopy[\"disable_cooling\"] = v\n\t\t}\n\t\tif v, ok := metadata[\"request_retry\"]; ok {\n\t\t\tmetadataCopy[\"request_retry\"] = v\n\t\t} else if v, ok := metadata[\"request-retry\"]; ok {\n\t\t\tmetadataCopy[\"request_retry\"] = v\n\t\t}\n\t\tproxy := strings.TrimSpace(primary.ProxyURL)\n\t\tif proxy != \"\" {\n\t\t\tmetadataCopy[\"proxy_url\"] = proxy\n\t\t}\n\t\tvirtual := &coreauth.Auth{\n\t\t\tID:         buildGeminiVirtualID(primary.ID, projectID),\n\t\t\tProvider:   originalProvider,\n\t\t\tLabel:      fmt.Sprintf(\"%s [%s]\", label, projectID),\n\t\t\tStatus:     coreauth.StatusActive,\n\t\t\tAttributes: attrs,\n\t\t\tMetadata:   metadataCopy,\n\t\t\tProxyURL:   primary.ProxyURL,\n\t\t\tPrefix:     primary.Prefix,\n\t\t\tCreatedAt:  primary.CreatedAt,\n\t\t\tUpdatedAt:  primary.UpdatedAt,\n\t\t\tRuntime:    geminicli.NewVirtualCredential(projectID, shared),\n\t\t}\n\t\tvirtuals = append(virtuals, virtual)\n\t}\n\treturn virtuals\n}\n\n// splitGeminiProjectIDs extracts and deduplicates project IDs from metadata.\nfunc splitGeminiProjectIDs(metadata map[string]any) []string {\n\traw, _ := metadata[\"project_id\"].(string)\n\ttrimmed := strings.TrimSpace(raw)\n\tif trimmed == \"\" {\n\t\treturn nil\n\t}\n\tparts := strings.Split(trimmed, \",\")\n\tresult := make([]string, 0, len(parts))\n\tseen := make(map[string]struct{}, len(parts))\n\tfor _, part := range parts {\n\t\tid := strings.TrimSpace(part)\n\t\tif id == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[id]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tresult = append(result, id)\n\t}\n\treturn result\n}\n\n// buildGeminiVirtualID constructs a virtual auth ID from base ID and project ID.\nfunc buildGeminiVirtualID(baseID, projectID string) string {\n\tproject := strings.TrimSpace(projectID)\n\tif project == \"\" {\n\t\tproject = \"project\"\n\t}\n\treplacer := strings.NewReplacer(\"/\", \"_\", \"\\\\\", \"_\", \" \", \"_\")\n\treturn fmt.Sprintf(\"%s::%s\", baseID, replacer.Replace(project))\n}\n\n// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata.\n// Supports both \"excluded_models\" and \"excluded-models\" keys, and accepts both []string and []interface{}.\nfunc extractExcludedModelsFromMetadata(metadata map[string]any) []string {\n\tif metadata == nil {\n\t\treturn nil\n\t}\n\t// Try both key formats\n\traw, ok := metadata[\"excluded_models\"]\n\tif !ok {\n\t\traw, ok = metadata[\"excluded-models\"]\n\t}\n\tif !ok || raw == nil {\n\t\treturn nil\n\t}\n\tvar stringSlice []string\n\tswitch v := raw.(type) {\n\tcase []string:\n\t\tstringSlice = v\n\tcase []interface{}:\n\t\tstringSlice = make([]string, 0, len(v))\n\t\tfor _, item := range v {\n\t\t\tif s, ok := item.(string); ok {\n\t\t\t\tstringSlice = append(stringSlice, s)\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn nil\n\t}\n\tresult := make([]string, 0, len(stringSlice))\n\tfor _, s := range stringSlice {\n\t\tif trimmed := strings.TrimSpace(s); trimmed != \"\" {\n\t\t\tresult = append(result, trimmed)\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/file_test.go",
    "content": "package synthesizer\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestNewFileSynthesizer(t *testing.T) {\n\tsynth := NewFileSynthesizer()\n\tif synth == nil {\n\t\tt.Fatal(\"expected non-nil synthesizer\")\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_NilContext(t *testing.T) {\n\tsynth := NewFileSynthesizer()\n\tauths, err := synth.Synthesize(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 0 {\n\t\tt.Fatalf(\"expected empty auths, got %d\", len(auths))\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_EmptyAuthDir(t *testing.T) {\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     \"\",\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 0 {\n\t\tt.Fatalf(\"expected empty auths, got %d\", len(auths))\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_NonExistentDir(t *testing.T) {\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     \"/non/existent/path\",\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 0 {\n\t\tt.Fatalf(\"expected empty auths, got %d\", len(auths))\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Create a valid auth file\n\tauthData := map[string]any{\n\t\t\"type\":            \"claude\",\n\t\t\"email\":           \"test@example.com\",\n\t\t\"proxy_url\":       \"http://proxy.local\",\n\t\t\"prefix\":          \"test-prefix\",\n\t\t\"disable_cooling\": true,\n\t\t\"request_retry\":   2,\n\t}\n\tdata, _ := json.Marshal(authData)\n\terr := os.WriteFile(filepath.Join(tempDir, \"claude-auth.json\"), data, 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tif auths[0].Provider != \"claude\" {\n\t\tt.Errorf(\"expected provider claude, got %s\", auths[0].Provider)\n\t}\n\tif auths[0].Label != \"test@example.com\" {\n\t\tt.Errorf(\"expected label test@example.com, got %s\", auths[0].Label)\n\t}\n\tif auths[0].Prefix != \"test-prefix\" {\n\t\tt.Errorf(\"expected prefix test-prefix, got %s\", auths[0].Prefix)\n\t}\n\tif auths[0].ProxyURL != \"http://proxy.local\" {\n\t\tt.Errorf(\"expected proxy_url http://proxy.local, got %s\", auths[0].ProxyURL)\n\t}\n\tif v, ok := auths[0].Metadata[\"disable_cooling\"].(bool); !ok || !v {\n\t\tt.Errorf(\"expected disable_cooling true, got %v\", auths[0].Metadata[\"disable_cooling\"])\n\t}\n\tif v, ok := auths[0].Metadata[\"request_retry\"].(float64); !ok || int(v) != 2 {\n\t\tt.Errorf(\"expected request_retry 2, got %v\", auths[0].Metadata[\"request_retry\"])\n\t}\n\tif auths[0].Status != coreauth.StatusActive {\n\t\tt.Errorf(\"expected status active, got %s\", auths[0].Status)\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_GeminiProviderMapping(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Gemini type should be mapped to gemini-cli\n\tauthData := map[string]any{\n\t\t\"type\":  \"gemini\",\n\t\t\"email\": \"gemini@example.com\",\n\t}\n\tdata, _ := json.Marshal(authData)\n\terr := os.WriteFile(filepath.Join(tempDir, \"gemini-auth.json\"), data, 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tif auths[0].Provider != \"gemini-cli\" {\n\t\tt.Errorf(\"gemini should be mapped to gemini-cli, got %s\", auths[0].Provider)\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_SkipsInvalidFiles(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Create various invalid files\n\t_ = os.WriteFile(filepath.Join(tempDir, \"not-json.txt\"), []byte(\"text content\"), 0644)\n\t_ = os.WriteFile(filepath.Join(tempDir, \"invalid.json\"), []byte(\"not valid json\"), 0644)\n\t_ = os.WriteFile(filepath.Join(tempDir, \"empty.json\"), []byte(\"\"), 0644)\n\t_ = os.WriteFile(filepath.Join(tempDir, \"no-type.json\"), []byte(`{\"email\": \"test@example.com\"}`), 0644)\n\n\t// Create one valid file\n\tvalidData, _ := json.Marshal(map[string]any{\"type\": \"claude\", \"email\": \"valid@example.com\"})\n\t_ = os.WriteFile(filepath.Join(tempDir, \"valid.json\"), validData, 0644)\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"only valid auth file should be processed, got %d\", len(auths))\n\t}\n\tif auths[0].Label != \"valid@example.com\" {\n\t\tt.Errorf(\"expected label valid@example.com, got %s\", auths[0].Label)\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_SkipsDirectories(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Create a subdirectory with a json file inside\n\tsubDir := filepath.Join(tempDir, \"subdir.json\")\n\terr := os.Mkdir(subDir, 0755)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create subdir: %v\", err)\n\t}\n\n\t// Create a valid file in root\n\tvalidData, _ := json.Marshal(map[string]any{\"type\": \"claude\"})\n\t_ = os.WriteFile(filepath.Join(tempDir, \"valid.json\"), validData, 0644)\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_RelativeID(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\tauthData := map[string]any{\"type\": \"claude\"}\n\tdata, _ := json.Marshal(authData)\n\terr := os.WriteFile(filepath.Join(tempDir, \"my-auth.json\"), data, 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\t// ID should be relative path\n\tif auths[0].ID != \"my-auth.json\" {\n\t\tt.Errorf(\"expected ID my-auth.json, got %s\", auths[0].ID)\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tprefix     string\n\t\twantPrefix string\n\t}{\n\t\t{\"valid prefix\", \"myprefix\", \"myprefix\"},\n\t\t{\"prefix with slashes trimmed\", \"/myprefix/\", \"myprefix\"},\n\t\t{\"prefix with spaces trimmed\", \"  myprefix  \", \"myprefix\"},\n\t\t{\"prefix with internal slash rejected\", \"my/prefix\", \"\"},\n\t\t{\"empty prefix\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttempDir := t.TempDir()\n\t\t\tauthData := map[string]any{\n\t\t\t\t\"type\":   \"claude\",\n\t\t\t\t\"prefix\": tt.prefix,\n\t\t\t}\n\t\t\tdata, _ := json.Marshal(authData)\n\t\t\t_ = os.WriteFile(filepath.Join(tempDir, \"auth.json\"), data, 0644)\n\n\t\t\tsynth := NewFileSynthesizer()\n\t\t\tctx := &SynthesisContext{\n\t\t\t\tConfig:      &config.Config{},\n\t\t\t\tAuthDir:     tempDir,\n\t\t\t\tNow:         time.Now(),\n\t\t\t\tIDGenerator: NewStableIDGenerator(),\n\t\t\t}\n\n\t\t\tauths, err := synth.Synthesize(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif len(auths) != 1 {\n\t\t\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t\t\t}\n\t\t\tif auths[0].Prefix != tt.wantPrefix {\n\t\t\t\tt.Errorf(\"expected prefix %q, got %q\", tt.wantPrefix, auths[0].Prefix)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tpriority any\n\t\twant     string\n\t\thasValue bool\n\t}{\n\t\t{\n\t\t\tname:     \"string with spaces\",\n\t\t\tpriority: \" 10 \",\n\t\t\twant:     \"10\",\n\t\t\thasValue: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"number\",\n\t\t\tpriority: 8,\n\t\t\twant:     \"8\",\n\t\t\thasValue: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid string\",\n\t\t\tpriority: \"1x\",\n\t\t\thasValue: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttempDir := t.TempDir()\n\t\t\tauthData := map[string]any{\n\t\t\t\t\"type\":     \"claude\",\n\t\t\t\t\"priority\": tt.priority,\n\t\t\t}\n\t\t\tdata, _ := json.Marshal(authData)\n\t\t\terrWriteFile := os.WriteFile(filepath.Join(tempDir, \"auth.json\"), data, 0644)\n\t\t\tif errWriteFile != nil {\n\t\t\t\tt.Fatalf(\"failed to write auth file: %v\", errWriteFile)\n\t\t\t}\n\n\t\t\tsynth := NewFileSynthesizer()\n\t\t\tctx := &SynthesisContext{\n\t\t\t\tConfig:      &config.Config{},\n\t\t\t\tAuthDir:     tempDir,\n\t\t\t\tNow:         time.Now(),\n\t\t\t\tIDGenerator: NewStableIDGenerator(),\n\t\t\t}\n\n\t\t\tauths, errSynthesize := synth.Synthesize(ctx)\n\t\t\tif errSynthesize != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", errSynthesize)\n\t\t\t}\n\t\t\tif len(auths) != 1 {\n\t\t\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t\t\t}\n\n\t\t\tvalue, ok := auths[0].Attributes[\"priority\"]\n\t\t\tif tt.hasValue {\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatal(\"expected priority attribute to be set\")\n\t\t\t\t}\n\t\t\t\tif value != tt.want {\n\t\t\t\t\tt.Fatalf(\"expected priority %q, got %q\", tt.want, value)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif ok {\n\t\t\t\tt.Fatalf(\"expected priority attribute to be absent, got %q\", value)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {\n\ttempDir := t.TempDir()\n\tauthData := map[string]any{\n\t\t\"type\":            \"claude\",\n\t\t\"excluded_models\": []string{\"custom-model\", \"MODEL-B\"},\n\t}\n\tdata, _ := json.Marshal(authData)\n\terrWriteFile := os.WriteFile(filepath.Join(tempDir, \"auth.json\"), data, 0644)\n\tif errWriteFile != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", errWriteFile)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig: &config.Config{\n\t\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\t\"claude\": {\"shared\", \"model-b\"},\n\t\t\t},\n\t\t},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, errSynthesize := synth.Synthesize(ctx)\n\tif errSynthesize != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errSynthesize)\n\t}\n\tif len(auths) != 1 {\n\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t}\n\n\tgot := auths[0].Attributes[\"excluded_models\"]\n\twant := \"custom-model,model-b,shared\"\n\tif got != want {\n\t\tt.Fatalf(\"expected excluded_models %q, got %q\", want, got)\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_NilInputs(t *testing.T) {\n\tnow := time.Now()\n\n\tif SynthesizeGeminiVirtualAuths(nil, nil, now) != nil {\n\t\tt.Error(\"expected nil for nil primary\")\n\t}\n\tif SynthesizeGeminiVirtualAuths(&coreauth.Auth{}, nil, now) != nil {\n\t\tt.Error(\"expected nil for nil metadata\")\n\t}\n\tif SynthesizeGeminiVirtualAuths(nil, map[string]any{}, now) != nil {\n\t\tt.Error(\"expected nil for nil primary with metadata\")\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_SingleProject(t *testing.T) {\n\tnow := time.Now()\n\tprimary := &coreauth.Auth{\n\t\tID:       \"test-id\",\n\t\tProvider: \"gemini-cli\",\n\t\tLabel:    \"test@example.com\",\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\": \"single-project\",\n\t\t\"email\":      \"test@example.com\",\n\t\t\"type\":       \"gemini\",\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\tif virtuals != nil {\n\t\tt.Error(\"single project should not create virtuals\")\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_MultiProject(t *testing.T) {\n\tnow := time.Now()\n\tprimary := &coreauth.Auth{\n\t\tID:       \"primary-id\",\n\t\tProvider: \"gemini-cli\",\n\t\tLabel:    \"test@example.com\",\n\t\tPrefix:   \"test-prefix\",\n\t\tProxyURL: \"http://proxy.local\",\n\t\tAttributes: map[string]string{\n\t\t\t\"source\": \"test-source\",\n\t\t\t\"path\":   \"/path/to/auth\",\n\t\t},\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\":      \"project-a, project-b, project-c\",\n\t\t\"email\":           \"test@example.com\",\n\t\t\"type\":            \"gemini\",\n\t\t\"request_retry\":   2,\n\t\t\"disable_cooling\": true,\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\n\tif len(virtuals) != 3 {\n\t\tt.Fatalf(\"expected 3 virtuals, got %d\", len(virtuals))\n\t}\n\n\t// Check primary is disabled\n\tif !primary.Disabled {\n\t\tt.Error(\"expected primary to be disabled\")\n\t}\n\tif primary.Status != coreauth.StatusDisabled {\n\t\tt.Errorf(\"expected primary status disabled, got %s\", primary.Status)\n\t}\n\tif primary.Attributes[\"gemini_virtual_primary\"] != \"true\" {\n\t\tt.Error(\"expected gemini_virtual_primary=true\")\n\t}\n\tif !strings.Contains(primary.Attributes[\"virtual_children\"], \"project-a\") {\n\t\tt.Error(\"expected virtual_children to contain project-a\")\n\t}\n\n\t// Check virtuals\n\tprojectIDs := []string{\"project-a\", \"project-b\", \"project-c\"}\n\tfor i, v := range virtuals {\n\t\tif v.Provider != \"gemini-cli\" {\n\t\t\tt.Errorf(\"expected provider gemini-cli, got %s\", v.Provider)\n\t\t}\n\t\tif v.Status != coreauth.StatusActive {\n\t\t\tt.Errorf(\"expected status active, got %s\", v.Status)\n\t\t}\n\t\tif v.Prefix != \"test-prefix\" {\n\t\t\tt.Errorf(\"expected prefix test-prefix, got %s\", v.Prefix)\n\t\t}\n\t\tif v.ProxyURL != \"http://proxy.local\" {\n\t\t\tt.Errorf(\"expected proxy_url http://proxy.local, got %s\", v.ProxyURL)\n\t\t}\n\t\tif vv, ok := v.Metadata[\"disable_cooling\"].(bool); !ok || !vv {\n\t\t\tt.Errorf(\"expected disable_cooling true, got %v\", v.Metadata[\"disable_cooling\"])\n\t\t}\n\t\tif vv, ok := v.Metadata[\"request_retry\"].(int); !ok || vv != 2 {\n\t\t\tt.Errorf(\"expected request_retry 2, got %v\", v.Metadata[\"request_retry\"])\n\t\t}\n\t\tif v.Attributes[\"runtime_only\"] != \"true\" {\n\t\t\tt.Error(\"expected runtime_only=true\")\n\t\t}\n\t\tif v.Attributes[\"gemini_virtual_parent\"] != \"primary-id\" {\n\t\t\tt.Errorf(\"expected gemini_virtual_parent=primary-id, got %s\", v.Attributes[\"gemini_virtual_parent\"])\n\t\t}\n\t\tif v.Attributes[\"gemini_virtual_project\"] != projectIDs[i] {\n\t\t\tt.Errorf(\"expected gemini_virtual_project=%s, got %s\", projectIDs[i], v.Attributes[\"gemini_virtual_project\"])\n\t\t}\n\t\tif !strings.Contains(v.Label, \"[\"+projectIDs[i]+\"]\") {\n\t\t\tt.Errorf(\"expected label to contain [%s], got %s\", projectIDs[i], v.Label)\n\t\t}\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_EmptyProviderAndLabel(t *testing.T) {\n\tnow := time.Now()\n\t// Test with empty Provider and Label to cover fallback branches\n\tprimary := &coreauth.Auth{\n\t\tID:         \"primary-id\",\n\t\tProvider:   \"\", // empty provider - should default to gemini-cli\n\t\tLabel:      \"\", // empty label - should default to provider\n\t\tAttributes: map[string]string{},\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\": \"proj-a, proj-b\",\n\t\t\"email\":      \"user@example.com\",\n\t\t\"type\":       \"gemini\",\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\n\tif len(virtuals) != 2 {\n\t\tt.Fatalf(\"expected 2 virtuals, got %d\", len(virtuals))\n\t}\n\n\t// Check that empty provider defaults to gemini-cli\n\tif virtuals[0].Provider != \"gemini-cli\" {\n\t\tt.Errorf(\"expected provider gemini-cli (default), got %s\", virtuals[0].Provider)\n\t}\n\t// Check that empty label defaults to provider\n\tif !strings.Contains(virtuals[0].Label, \"gemini-cli\") {\n\t\tt.Errorf(\"expected label to contain gemini-cli, got %s\", virtuals[0].Label)\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_NilPrimaryAttributes(t *testing.T) {\n\tnow := time.Now()\n\tprimary := &coreauth.Auth{\n\t\tID:         \"primary-id\",\n\t\tProvider:   \"gemini-cli\",\n\t\tLabel:      \"test@example.com\",\n\t\tAttributes: nil, // nil attributes\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\": \"proj-a, proj-b\",\n\t\t\"email\":      \"test@example.com\",\n\t\t\"type\":       \"gemini\",\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\n\tif len(virtuals) != 2 {\n\t\tt.Fatalf(\"expected 2 virtuals, got %d\", len(virtuals))\n\t}\n\t// Nil attributes should be initialized\n\tif primary.Attributes == nil {\n\t\tt.Error(\"expected primary.Attributes to be initialized\")\n\t}\n\tif primary.Attributes[\"gemini_virtual_primary\"] != \"true\" {\n\t\tt.Error(\"expected gemini_virtual_primary=true\")\n\t}\n}\n\nfunc TestSplitGeminiProjectIDs(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tmetadata map[string]any\n\t\twant     []string\n\t}{\n\t\t{\n\t\t\tname:     \"single project\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"proj-a\"},\n\t\t\twant:     []string{\"proj-a\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple projects\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"proj-a, proj-b, proj-c\"},\n\t\t\twant:     []string{\"proj-a\", \"proj-b\", \"proj-c\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"with duplicates\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"proj-a, proj-b, proj-a\"},\n\t\t\twant:     []string{\"proj-a\", \"proj-b\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"with empty parts\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"proj-a, , proj-b, \"},\n\t\t\twant:     []string{\"proj-a\", \"proj-b\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty project_id\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"\"},\n\t\t\twant:     nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"no project_id\",\n\t\t\tmetadata: map[string]any{},\n\t\t\twant:     nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"whitespace only\",\n\t\t\tmetadata: map[string]any{\"project_id\": \"   \"},\n\t\t\twant:     nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := splitGeminiProjectIDs(tt.metadata)\n\t\t\tif len(got) != len(tt.want) {\n\t\t\t\tt.Fatalf(\"expected %v, got %v\", tt.want, got)\n\t\t\t}\n\t\t\tfor i := range got {\n\t\t\t\tif got[i] != tt.want[i] {\n\t\t\t\t\tt.Errorf(\"expected %v, got %v\", tt.want, got)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Create a gemini auth file with multiple projects\n\tauthData := map[string]any{\n\t\t\"type\":       \"gemini\",\n\t\t\"email\":      \"multi@example.com\",\n\t\t\"project_id\": \"project-a, project-b, project-c\",\n\t\t\"priority\":   \" 10 \",\n\t}\n\tdata, _ := json.Marshal(authData)\n\terr := os.WriteFile(filepath.Join(tempDir, \"gemini-multi.json\"), data, 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Should have 4 auths: 1 primary (disabled) + 3 virtuals\n\tif len(auths) != 4 {\n\t\tt.Fatalf(\"expected 4 auths (1 primary + 3 virtuals), got %d\", len(auths))\n\t}\n\n\t// First auth should be the primary (disabled)\n\tprimary := auths[0]\n\tif !primary.Disabled {\n\t\tt.Error(\"expected primary to be disabled\")\n\t}\n\tif primary.Status != coreauth.StatusDisabled {\n\t\tt.Errorf(\"expected primary status disabled, got %s\", primary.Status)\n\t}\n\tif gotPriority := primary.Attributes[\"priority\"]; gotPriority != \"10\" {\n\t\tt.Errorf(\"expected primary priority 10, got %q\", gotPriority)\n\t}\n\n\t// Remaining auths should be virtuals\n\tfor i := 1; i < 4; i++ {\n\t\tv := auths[i]\n\t\tif v.Status != coreauth.StatusActive {\n\t\t\tt.Errorf(\"expected virtual %d to be active, got %s\", i, v.Status)\n\t\t}\n\t\tif v.Attributes[\"gemini_virtual_parent\"] != primary.ID {\n\t\t\tt.Errorf(\"expected virtual %d parent to be %s, got %s\", i, primary.ID, v.Attributes[\"gemini_virtual_parent\"])\n\t\t}\n\t\tif gotPriority := v.Attributes[\"priority\"]; gotPriority != \"10\" {\n\t\t\tt.Errorf(\"expected virtual %d priority 10, got %q\", i, gotPriority)\n\t\t}\n\t}\n}\n\nfunc TestBuildGeminiVirtualID(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tbaseID    string\n\t\tprojectID string\n\t\twant      string\n\t}{\n\t\t{\n\t\t\tname:      \"basic\",\n\t\t\tbaseID:    \"auth.json\",\n\t\t\tprojectID: \"my-project\",\n\t\t\twant:      \"auth.json::my-project\",\n\t\t},\n\t\t{\n\t\t\tname:      \"with slashes\",\n\t\t\tbaseID:    \"path/to/auth.json\",\n\t\t\tprojectID: \"project/with/slashes\",\n\t\t\twant:      \"path/to/auth.json::project_with_slashes\",\n\t\t},\n\t\t{\n\t\t\tname:      \"with spaces\",\n\t\t\tbaseID:    \"auth.json\",\n\t\t\tprojectID: \"my project\",\n\t\t\twant:      \"auth.json::my_project\",\n\t\t},\n\t\t{\n\t\t\tname:      \"empty project\",\n\t\t\tbaseID:    \"auth.json\",\n\t\t\tprojectID: \"\",\n\t\t\twant:      \"auth.json::project\",\n\t\t},\n\t\t{\n\t\t\tname:      \"whitespace project\",\n\t\t\tbaseID:    \"auth.json\",\n\t\t\tprojectID: \"   \",\n\t\t\twant:      \"auth.json::project\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := buildGeminiVirtualID(tt.baseID, tt.projectID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_NotePropagated(t *testing.T) {\n\tnow := time.Now()\n\tprimary := &coreauth.Auth{\n\t\tID:       \"primary-id\",\n\t\tProvider: \"gemini-cli\",\n\t\tLabel:    \"test@example.com\",\n\t\tAttributes: map[string]string{\n\t\t\t\"source\":   \"test-source\",\n\t\t\t\"path\":     \"/path/to/auth\",\n\t\t\t\"priority\": \"5\",\n\t\t\t\"note\":     \"my test note\",\n\t\t},\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\": \"proj-a, proj-b\",\n\t\t\"email\":      \"test@example.com\",\n\t\t\"type\":       \"gemini\",\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\n\tif len(virtuals) != 2 {\n\t\tt.Fatalf(\"expected 2 virtuals, got %d\", len(virtuals))\n\t}\n\n\tfor i, v := range virtuals {\n\t\tif got := v.Attributes[\"note\"]; got != \"my test note\" {\n\t\t\tt.Errorf(\"virtual %d: expected note %q, got %q\", i, \"my test note\", got)\n\t\t}\n\t\tif got := v.Attributes[\"priority\"]; got != \"5\" {\n\t\t\tt.Errorf(\"virtual %d: expected priority %q, got %q\", i, \"5\", got)\n\t\t}\n\t}\n}\n\nfunc TestSynthesizeGeminiVirtualAuths_NoteAbsentWhenEmpty(t *testing.T) {\n\tnow := time.Now()\n\tprimary := &coreauth.Auth{\n\t\tID:       \"primary-id\",\n\t\tProvider: \"gemini-cli\",\n\t\tLabel:    \"test@example.com\",\n\t\tAttributes: map[string]string{\n\t\t\t\"source\": \"test-source\",\n\t\t\t\"path\":   \"/path/to/auth\",\n\t\t},\n\t}\n\tmetadata := map[string]any{\n\t\t\"project_id\": \"proj-a, proj-b\",\n\t\t\"email\":      \"test@example.com\",\n\t\t\"type\":       \"gemini\",\n\t}\n\n\tvirtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)\n\n\tif len(virtuals) != 2 {\n\t\tt.Fatalf(\"expected 2 virtuals, got %d\", len(virtuals))\n\t}\n\n\tfor i, v := range virtuals {\n\t\tif _, hasNote := v.Attributes[\"note\"]; hasNote {\n\t\t\tt.Errorf(\"virtual %d: expected no note attribute when primary has no note\", i)\n\t\t}\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_NoteParsing(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tnote     any\n\t\twant     string\n\t\thasValue bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid string note\",\n\t\t\tnote:     \"hello world\",\n\t\t\twant:     \"hello world\",\n\t\t\thasValue: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"string note with whitespace\",\n\t\t\tnote:     \"  trimmed note  \",\n\t\t\twant:     \"trimmed note\",\n\t\t\thasValue: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string note\",\n\t\t\tnote:     \"\",\n\t\t\thasValue: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"whitespace only note\",\n\t\t\tnote:     \"   \",\n\t\t\thasValue: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-string note ignored\",\n\t\t\tnote:     12345,\n\t\t\thasValue: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttempDir := t.TempDir()\n\t\t\tauthData := map[string]any{\n\t\t\t\t\"type\": \"claude\",\n\t\t\t\t\"note\": tt.note,\n\t\t\t}\n\t\t\tdata, _ := json.Marshal(authData)\n\t\t\terrWriteFile := os.WriteFile(filepath.Join(tempDir, \"auth.json\"), data, 0644)\n\t\t\tif errWriteFile != nil {\n\t\t\t\tt.Fatalf(\"failed to write auth file: %v\", errWriteFile)\n\t\t\t}\n\n\t\t\tsynth := NewFileSynthesizer()\n\t\t\tctx := &SynthesisContext{\n\t\t\t\tConfig:      &config.Config{},\n\t\t\t\tAuthDir:     tempDir,\n\t\t\t\tNow:         time.Now(),\n\t\t\t\tIDGenerator: NewStableIDGenerator(),\n\t\t\t}\n\n\t\t\tauths, errSynthesize := synth.Synthesize(ctx)\n\t\t\tif errSynthesize != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", errSynthesize)\n\t\t\t}\n\t\t\tif len(auths) != 1 {\n\t\t\t\tt.Fatalf(\"expected 1 auth, got %d\", len(auths))\n\t\t\t}\n\n\t\t\tvalue, ok := auths[0].Attributes[\"note\"]\n\t\t\tif tt.hasValue {\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatal(\"expected note attribute to be set\")\n\t\t\t\t}\n\t\t\t\tif value != tt.want {\n\t\t\t\t\tt.Fatalf(\"expected note %q, got %q\", tt.want, value)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif ok {\n\t\t\t\tt.Fatalf(\"expected note attribute to be absent, got %q\", value)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSynthesizer_Synthesize_MultiProjectGeminiWithNote(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\tauthData := map[string]any{\n\t\t\"type\":       \"gemini\",\n\t\t\"email\":      \"multi@example.com\",\n\t\t\"project_id\": \"project-a, project-b\",\n\t\t\"priority\":   5,\n\t\t\"note\":       \"production keys\",\n\t}\n\tdata, _ := json.Marshal(authData)\n\terr := os.WriteFile(filepath.Join(tempDir, \"gemini-multi.json\"), data, 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tsynth := NewFileSynthesizer()\n\tctx := &SynthesisContext{\n\t\tConfig:      &config.Config{},\n\t\tAuthDir:     tempDir,\n\t\tNow:         time.Now(),\n\t\tIDGenerator: NewStableIDGenerator(),\n\t}\n\n\tauths, err := synth.Synthesize(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Should have 3 auths: 1 primary (disabled) + 2 virtuals\n\tif len(auths) != 3 {\n\t\tt.Fatalf(\"expected 3 auths (1 primary + 2 virtuals), got %d\", len(auths))\n\t}\n\n\tprimary := auths[0]\n\tif gotNote := primary.Attributes[\"note\"]; gotNote != \"production keys\" {\n\t\tt.Errorf(\"expected primary note %q, got %q\", \"production keys\", gotNote)\n\t}\n\n\t// Verify virtuals inherit note\n\tfor i := 1; i < len(auths); i++ {\n\t\tv := auths[i]\n\t\tif gotNote := v.Attributes[\"note\"]; gotNote != \"production keys\" {\n\t\t\tt.Errorf(\"expected virtual %d note %q, got %q\", i, \"production keys\", gotNote)\n\t\t}\n\t\tif gotPriority := v.Attributes[\"priority\"]; gotPriority != \"5\" {\n\t\t\tt.Errorf(\"expected virtual %d priority %q, got %q\", i, \"5\", gotPriority)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/helpers.go",
    "content": "package synthesizer\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// StableIDGenerator generates stable, deterministic IDs for auth entries.\n// It uses SHA256 hashing with collision handling via counters.\n// It is not safe for concurrent use.\ntype StableIDGenerator struct {\n\tcounters map[string]int\n}\n\n// NewStableIDGenerator creates a new StableIDGenerator instance.\nfunc NewStableIDGenerator() *StableIDGenerator {\n\treturn &StableIDGenerator{counters: make(map[string]int)}\n}\n\n// Next generates a stable ID based on the kind and parts.\n// Returns the full ID (kind:hash) and the short hash portion.\nfunc (g *StableIDGenerator) Next(kind string, parts ...string) (string, string) {\n\tif g == nil {\n\t\treturn kind + \":000000000000\", \"000000000000\"\n\t}\n\thasher := sha256.New()\n\thasher.Write([]byte(kind))\n\tfor _, part := range parts {\n\t\ttrimmed := strings.TrimSpace(part)\n\t\thasher.Write([]byte{0})\n\t\thasher.Write([]byte(trimmed))\n\t}\n\tdigest := hex.EncodeToString(hasher.Sum(nil))\n\tif len(digest) < 12 {\n\t\tdigest = fmt.Sprintf(\"%012s\", digest)\n\t}\n\tshort := digest[:12]\n\tkey := kind + \":\" + short\n\tindex := g.counters[key]\n\tg.counters[key] = index + 1\n\tif index > 0 {\n\t\tshort = fmt.Sprintf(\"%s-%d\", short, index)\n\t}\n\treturn fmt.Sprintf(\"%s:%s\", kind, short), short\n}\n\n// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.\n// It computes a hash of excluded models and sets the auth_kind attribute.\n// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged\n// with the global oauth-excluded-models config for the provider.\nfunc ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {\n\tif auth == nil || cfg == nil {\n\t\treturn\n\t}\n\tauthKindKey := strings.ToLower(strings.TrimSpace(authKind))\n\tseen := make(map[string]struct{})\n\tadd := func(list []string) {\n\t\tfor _, entry := range list {\n\t\t\tif trimmed := strings.TrimSpace(entry); trimmed != \"\" {\n\t\t\t\tkey := strings.ToLower(trimmed)\n\t\t\t\tif _, exists := seen[key]; exists {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tseen[key] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\tif authKindKey == \"apikey\" {\n\t\tadd(perKey)\n\t} else {\n\t\t// For OAuth: merge per-account excluded models with global provider-level exclusions\n\t\tadd(perKey)\n\t\tif cfg.OAuthExcludedModels != nil {\n\t\t\tproviderKey := strings.ToLower(strings.TrimSpace(auth.Provider))\n\t\t\tadd(cfg.OAuthExcludedModels[providerKey])\n\t\t}\n\t}\n\tcombined := make([]string, 0, len(seen))\n\tfor k := range seen {\n\t\tcombined = append(combined, k)\n\t}\n\tsort.Strings(combined)\n\thash := diff.ComputeExcludedModelsHash(combined)\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tif hash != \"\" {\n\t\tauth.Attributes[\"excluded_models_hash\"] = hash\n\t}\n\t// Store the combined excluded models list so that routing can read it at runtime\n\tif len(combined) > 0 {\n\t\tauth.Attributes[\"excluded_models\"] = strings.Join(combined, \",\")\n\t}\n\tif authKind != \"\" {\n\t\tauth.Attributes[\"auth_kind\"] = authKind\n\t}\n}\n\n// addConfigHeadersToAttrs adds header configuration to auth attributes.\n// Headers are prefixed with \"header:\" in the attributes map.\nfunc addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) {\n\tif len(headers) == 0 || attrs == nil {\n\t\treturn\n\t}\n\tfor hk, hv := range headers {\n\t\tkey := strings.TrimSpace(hk)\n\t\tval := strings.TrimSpace(hv)\n\t\tif key == \"\" || val == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tattrs[\"header:\"+key] = val\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/helpers_test.go",
    "content": "package synthesizer\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestNewStableIDGenerator(t *testing.T) {\n\tgen := NewStableIDGenerator()\n\tif gen == nil {\n\t\tt.Fatal(\"expected non-nil generator\")\n\t}\n\tif gen.counters == nil {\n\t\tt.Fatal(\"expected non-nil counters map\")\n\t}\n}\n\nfunc TestStableIDGenerator_Next(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tkind       string\n\t\tparts      []string\n\t\twantPrefix string\n\t}{\n\t\t{\n\t\t\tname:       \"basic gemini apikey\",\n\t\t\tkind:       \"gemini:apikey\",\n\t\t\tparts:      []string{\"test-key\", \"\"},\n\t\t\twantPrefix: \"gemini:apikey:\",\n\t\t},\n\t\t{\n\t\t\tname:       \"claude with base url\",\n\t\t\tkind:       \"claude:apikey\",\n\t\t\tparts:      []string{\"sk-ant-xxx\", \"https://api.anthropic.com\"},\n\t\t\twantPrefix: \"claude:apikey:\",\n\t\t},\n\t\t{\n\t\t\tname:       \"empty parts\",\n\t\t\tkind:       \"codex:apikey\",\n\t\t\tparts:      []string{},\n\t\t\twantPrefix: \"codex:apikey:\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgen := NewStableIDGenerator()\n\t\t\tid, short := gen.Next(tt.kind, tt.parts...)\n\n\t\t\tif !strings.Contains(id, tt.wantPrefix) {\n\t\t\t\tt.Errorf(\"expected id to contain %q, got %q\", tt.wantPrefix, id)\n\t\t\t}\n\t\t\tif short == \"\" {\n\t\t\t\tt.Error(\"expected non-empty short id\")\n\t\t\t}\n\t\t\tif len(short) != 12 {\n\t\t\t\tt.Errorf(\"expected short id length 12, got %d\", len(short))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStableIDGenerator_Stability(t *testing.T) {\n\tgen1 := NewStableIDGenerator()\n\tgen2 := NewStableIDGenerator()\n\n\tid1, _ := gen1.Next(\"gemini:apikey\", \"test-key\", \"https://api.example.com\")\n\tid2, _ := gen2.Next(\"gemini:apikey\", \"test-key\", \"https://api.example.com\")\n\n\tif id1 != id2 {\n\t\tt.Errorf(\"same inputs should produce same ID: got %q and %q\", id1, id2)\n\t}\n}\n\nfunc TestStableIDGenerator_CollisionHandling(t *testing.T) {\n\tgen := NewStableIDGenerator()\n\n\tid1, short1 := gen.Next(\"gemini:apikey\", \"same-key\")\n\tid2, short2 := gen.Next(\"gemini:apikey\", \"same-key\")\n\n\tif id1 == id2 {\n\t\tt.Error(\"collision should be handled with suffix\")\n\t}\n\tif short1 == short2 {\n\t\tt.Error(\"short ids should differ\")\n\t}\n\tif !strings.Contains(short2, \"-1\") {\n\t\tt.Errorf(\"second short id should contain -1 suffix, got %q\", short2)\n\t}\n}\n\nfunc TestStableIDGenerator_NilReceiver(t *testing.T) {\n\tvar gen *StableIDGenerator = nil\n\tid, short := gen.Next(\"test:kind\", \"part\")\n\n\tif id != \"test:kind:000000000000\" {\n\t\tt.Errorf(\"expected test:kind:000000000000, got %q\", id)\n\t}\n\tif short != \"000000000000\" {\n\t\tt.Errorf(\"expected 000000000000, got %q\", short)\n\t}\n}\n\nfunc TestApplyAuthExcludedModelsMeta(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tauth     *coreauth.Auth\n\t\tcfg      *config.Config\n\t\tperKey   []string\n\t\tauthKind string\n\t\twantHash bool\n\t\twantKind string\n\t}{\n\t\t{\n\t\t\tname: \"apikey with excluded models\",\n\t\t\tauth: &coreauth.Auth{\n\t\t\t\tProvider:   \"gemini\",\n\t\t\t\tAttributes: make(map[string]string),\n\t\t\t},\n\t\t\tcfg:      &config.Config{},\n\t\t\tperKey:   []string{\"model-a\", \"model-b\"},\n\t\t\tauthKind: \"apikey\",\n\t\t\twantHash: true,\n\t\t\twantKind: \"apikey\",\n\t\t},\n\t\t{\n\t\t\tname: \"oauth with provider excluded models\",\n\t\t\tauth: &coreauth.Auth{\n\t\t\t\tProvider:   \"claude\",\n\t\t\t\tAttributes: make(map[string]string),\n\t\t\t},\n\t\t\tcfg: &config.Config{\n\t\t\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\t\t\"claude\": {\"claude-2.0\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tperKey:   nil,\n\t\t\tauthKind: \"oauth\",\n\t\t\twantHash: true,\n\t\t\twantKind: \"oauth\",\n\t\t},\n\t\t{\n\t\t\tname: \"nil auth\",\n\t\t\tauth: nil,\n\t\t\tcfg:  &config.Config{},\n\t\t},\n\t\t{\n\t\t\tname:     \"nil config\",\n\t\t\tauth:     &coreauth.Auth{Provider: \"test\"},\n\t\t\tcfg:      nil,\n\t\t\tauthKind: \"apikey\",\n\t\t},\n\t\t{\n\t\t\tname: \"nil attributes initialized\",\n\t\t\tauth: &coreauth.Auth{\n\t\t\t\tProvider:   \"gemini\",\n\t\t\t\tAttributes: nil,\n\t\t\t},\n\t\t\tcfg:      &config.Config{},\n\t\t\tperKey:   []string{\"model-x\"},\n\t\t\tauthKind: \"apikey\",\n\t\t\twantHash: true,\n\t\t\twantKind: \"apikey\",\n\t\t},\n\t\t{\n\t\t\tname: \"apikey with duplicate excluded models\",\n\t\t\tauth: &coreauth.Auth{\n\t\t\t\tProvider:   \"gemini\",\n\t\t\t\tAttributes: make(map[string]string),\n\t\t\t},\n\t\t\tcfg:      &config.Config{},\n\t\t\tperKey:   []string{\"model-a\", \"MODEL-A\", \"model-b\", \"model-a\"},\n\t\t\tauthKind: \"apikey\",\n\t\t\twantHash: true,\n\t\t\twantKind: \"apikey\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tApplyAuthExcludedModelsMeta(tt.auth, tt.cfg, tt.perKey, tt.authKind)\n\n\t\t\tif tt.auth != nil && tt.cfg != nil {\n\t\t\t\tif tt.wantHash {\n\t\t\t\t\tif _, ok := tt.auth.Attributes[\"excluded_models_hash\"]; !ok {\n\t\t\t\t\t\tt.Error(\"expected excluded_models_hash in attributes\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif tt.wantKind != \"\" {\n\t\t\t\t\tif got := tt.auth.Attributes[\"auth_kind\"]; got != tt.wantKind {\n\t\t\t\t\t\tt.Errorf(\"expected auth_kind=%s, got %s\", tt.wantKind, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) {\n\tauth := &coreauth.Auth{\n\t\tProvider:   \"claude\",\n\t\tAttributes: make(map[string]string),\n\t}\n\tcfg := &config.Config{\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"claude\": {\"global-a\", \"shared\"},\n\t\t},\n\t}\n\n\tApplyAuthExcludedModelsMeta(auth, cfg, []string{\"per\", \"SHARED\"}, \"oauth\")\n\n\tconst wantCombined = \"global-a,per,shared\"\n\tif gotCombined := auth.Attributes[\"excluded_models\"]; gotCombined != wantCombined {\n\t\tt.Fatalf(\"expected excluded_models=%q, got %q\", wantCombined, gotCombined)\n\t}\n\n\texpectedHash := diff.ComputeExcludedModelsHash([]string{\"global-a\", \"per\", \"shared\"})\n\tif gotHash := auth.Attributes[\"excluded_models_hash\"]; gotHash != expectedHash {\n\t\tt.Fatalf(\"expected excluded_models_hash=%q, got %q\", expectedHash, gotHash)\n\t}\n}\n\nfunc TestAddConfigHeadersToAttrs(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\theaders map[string]string\n\t\tattrs   map[string]string\n\t\twant    map[string]string\n\t}{\n\t\t{\n\t\t\tname: \"basic headers\",\n\t\t\theaders: map[string]string{\n\t\t\t\t\"Authorization\": \"Bearer token\",\n\t\t\t\t\"X-Custom\":      \"value\",\n\t\t\t},\n\t\t\tattrs: map[string]string{\"existing\": \"key\"},\n\t\t\twant: map[string]string{\n\t\t\t\t\"existing\":             \"key\",\n\t\t\t\t\"header:Authorization\": \"Bearer token\",\n\t\t\t\t\"header:X-Custom\":      \"value\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"empty headers\",\n\t\t\theaders: map[string]string{},\n\t\t\tattrs:   map[string]string{\"existing\": \"key\"},\n\t\t\twant:    map[string]string{\"existing\": \"key\"},\n\t\t},\n\t\t{\n\t\t\tname:    \"nil headers\",\n\t\t\theaders: nil,\n\t\t\tattrs:   map[string]string{\"existing\": \"key\"},\n\t\t\twant:    map[string]string{\"existing\": \"key\"},\n\t\t},\n\t\t{\n\t\t\tname:    \"nil attrs\",\n\t\t\theaders: map[string]string{\"key\": \"value\"},\n\t\t\tattrs:   nil,\n\t\t\twant:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"skip empty keys and values\",\n\t\t\theaders: map[string]string{\n\t\t\t\t\"\":      \"value\",\n\t\t\t\t\"key\":   \"\",\n\t\t\t\t\"  \":    \"value\",\n\t\t\t\t\"valid\": \"valid-value\",\n\t\t\t},\n\t\t\tattrs: make(map[string]string),\n\t\t\twant: map[string]string{\n\t\t\t\t\"header:valid\": \"valid-value\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\taddConfigHeadersToAttrs(tt.headers, tt.attrs)\n\t\t\tif !reflect.DeepEqual(tt.attrs, tt.want) {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", tt.want, tt.attrs)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/watcher/synthesizer/interface.go",
    "content": "// Package synthesizer provides auth synthesis strategies for the watcher package.\n// It implements the Strategy pattern to support multiple auth sources:\n// - ConfigSynthesizer: generates Auth entries from config API keys\n// - FileSynthesizer: generates Auth entries from OAuth JSON files\npackage synthesizer\n\nimport (\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// AuthSynthesizer defines the interface for generating Auth entries from various sources.\ntype AuthSynthesizer interface {\n\t// Synthesize generates Auth entries from the given context.\n\t// Returns a slice of Auth pointers and any error encountered.\n\tSynthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error)\n}\n"
  },
  {
    "path": "internal/watcher/watcher.go",
    "content": "// Package watcher watches config/auth files and triggers hot reloads.\n// It supports cross-platform fsnotify event handling.\npackage watcher\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"gopkg.in/yaml.v3\"\n\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// storePersister captures persistence-capable token store methods used by the watcher.\ntype storePersister interface {\n\tPersistConfig(ctx context.Context) error\n\tPersistAuthFiles(ctx context.Context, message string, paths ...string) error\n}\n\ntype authDirProvider interface {\n\tAuthDir() string\n}\n\n// Watcher manages file watching for configuration and authentication files\ntype Watcher struct {\n\tconfigPath        string\n\tauthDir           string\n\tconfig            *config.Config\n\tclientsMutex      sync.RWMutex\n\tconfigReloadMu    sync.Mutex\n\tconfigReloadTimer *time.Timer\n\tserverUpdateMu    sync.Mutex\n\tserverUpdateTimer *time.Timer\n\tserverUpdateLast  time.Time\n\tserverUpdatePend  bool\n\tstopped           atomic.Bool\n\treloadCallback    func(*config.Config)\n\twatcher           *fsnotify.Watcher\n\tlastAuthHashes    map[string]string\n\tlastAuthContents  map[string]*coreauth.Auth\n\tfileAuthsByPath   map[string]map[string]*coreauth.Auth\n\tlastRemoveTimes   map[string]time.Time\n\tlastConfigHash    string\n\tauthQueue         chan<- AuthUpdate\n\tcurrentAuths      map[string]*coreauth.Auth\n\truntimeAuths      map[string]*coreauth.Auth\n\tdispatchMu        sync.Mutex\n\tdispatchCond      *sync.Cond\n\tpendingUpdates    map[string]AuthUpdate\n\tpendingOrder      []string\n\tdispatchCancel    context.CancelFunc\n\tstorePersister    storePersister\n\tmirroredAuthDir   string\n\toldConfigYaml     []byte\n}\n\n// AuthUpdateAction represents the type of change detected in auth sources.\ntype AuthUpdateAction string\n\nconst (\n\tAuthUpdateActionAdd    AuthUpdateAction = \"add\"\n\tAuthUpdateActionModify AuthUpdateAction = \"modify\"\n\tAuthUpdateActionDelete AuthUpdateAction = \"delete\"\n)\n\n// AuthUpdate describes an incremental change to auth configuration.\ntype AuthUpdate struct {\n\tAction AuthUpdateAction\n\tID     string\n\tAuth   *coreauth.Auth\n}\n\nconst (\n\t// replaceCheckDelay is a short delay to allow atomic replace (rename) to settle\n\t// before deciding whether a Remove event indicates a real deletion.\n\treplaceCheckDelay        = 50 * time.Millisecond\n\tconfigReloadDebounce     = 150 * time.Millisecond\n\tauthRemoveDebounceWindow = 1 * time.Second\n\tserverUpdateDebounce     = 1 * time.Second\n)\n\n// NewWatcher creates a new file watcher instance\nfunc NewWatcher(configPath, authDir string, reloadCallback func(*config.Config)) (*Watcher, error) {\n\twatcher, errNewWatcher := fsnotify.NewWatcher()\n\tif errNewWatcher != nil {\n\t\treturn nil, errNewWatcher\n\t}\n\tw := &Watcher{\n\t\tconfigPath:      configPath,\n\t\tauthDir:         authDir,\n\t\treloadCallback:  reloadCallback,\n\t\twatcher:         watcher,\n\t\tlastAuthHashes:  make(map[string]string),\n\t\tfileAuthsByPath: make(map[string]map[string]*coreauth.Auth),\n\t}\n\tw.dispatchCond = sync.NewCond(&w.dispatchMu)\n\tif store := sdkAuth.GetTokenStore(); store != nil {\n\t\tif persister, ok := store.(storePersister); ok {\n\t\t\tw.storePersister = persister\n\t\t\tlog.Debug(\"persistence-capable token store detected; watcher will propagate persisted changes\")\n\t\t}\n\t\tif provider, ok := store.(authDirProvider); ok {\n\t\t\tif fixed := strings.TrimSpace(provider.AuthDir()); fixed != \"\" {\n\t\t\t\tw.mirroredAuthDir = fixed\n\t\t\t\tlog.Debugf(\"mirrored auth directory locked to %s\", fixed)\n\t\t\t}\n\t\t}\n\t}\n\treturn w, nil\n}\n\n// Start begins watching the configuration file and authentication directory\nfunc (w *Watcher) Start(ctx context.Context) error {\n\treturn w.start(ctx)\n}\n\n// Stop stops the file watcher\nfunc (w *Watcher) Stop() error {\n\tw.stopped.Store(true)\n\tw.stopDispatch()\n\tw.stopConfigReloadTimer()\n\tw.stopServerUpdateTimer()\n\treturn w.watcher.Close()\n}\n\n// SetConfig updates the current configuration\nfunc (w *Watcher) SetConfig(cfg *config.Config) {\n\tw.clientsMutex.Lock()\n\tdefer w.clientsMutex.Unlock()\n\tw.config = cfg\n\tw.oldConfigYaml, _ = yaml.Marshal(cfg)\n}\n\n// SetAuthUpdateQueue sets the queue used to emit auth updates.\nfunc (w *Watcher) SetAuthUpdateQueue(queue chan<- AuthUpdate) {\n\tw.setAuthUpdateQueue(queue)\n}\n\n// DispatchRuntimeAuthUpdate allows external runtime providers (e.g., websocket-driven auths)\n// to push auth updates through the same queue used by file/config watchers.\n// Returns true if the update was enqueued; false if no queue is configured.\nfunc (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool {\n\treturn w.dispatchRuntimeAuthUpdate(update)\n}\n\n// SnapshotCoreAuths converts current clients snapshot into core auth entries.\nfunc (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {\n\tw.clientsMutex.RLock()\n\tcfg := w.config\n\tw.clientsMutex.RUnlock()\n\treturn snapshotCoreAuths(cfg, w.authDir)\n}\n"
  },
  {
    "path": "internal/watcher/watcher_test.go",
    "content": "package watcher\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestApplyAuthExcludedModelsMeta_APIKey(t *testing.T) {\n\tauth := &coreauth.Auth{Attributes: map[string]string{}}\n\tcfg := &config.Config{}\n\tperKey := []string{\" Model-1 \", \"model-2\"}\n\n\tsynthesizer.ApplyAuthExcludedModelsMeta(auth, cfg, perKey, \"apikey\")\n\n\texpected := diff.ComputeExcludedModelsHash([]string{\"model-1\", \"model-2\"})\n\tif got := auth.Attributes[\"excluded_models_hash\"]; got != expected {\n\t\tt.Fatalf(\"expected hash %s, got %s\", expected, got)\n\t}\n\tif got := auth.Attributes[\"auth_kind\"]; got != \"apikey\" {\n\t\tt.Fatalf(\"expected auth_kind=apikey, got %s\", got)\n\t}\n}\n\nfunc TestApplyAuthExcludedModelsMeta_OAuthProvider(t *testing.T) {\n\tauth := &coreauth.Auth{\n\t\tProvider:   \"TestProv\",\n\t\tAttributes: map[string]string{},\n\t}\n\tcfg := &config.Config{\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"testprov\": {\"A\", \"b\"},\n\t\t},\n\t}\n\n\tsynthesizer.ApplyAuthExcludedModelsMeta(auth, cfg, nil, \"oauth\")\n\n\texpected := diff.ComputeExcludedModelsHash([]string{\"a\", \"b\"})\n\tif got := auth.Attributes[\"excluded_models_hash\"]; got != expected {\n\t\tt.Fatalf(\"expected hash %s, got %s\", expected, got)\n\t}\n\tif got := auth.Attributes[\"auth_kind\"]; got != \"oauth\" {\n\t\tt.Fatalf(\"expected auth_kind=oauth, got %s\", got)\n\t}\n}\n\nfunc TestBuildAPIKeyClientsCounts(t *testing.T) {\n\tcfg := &config.Config{\n\t\tGeminiKey: []config.GeminiKey{{APIKey: \"g1\"}, {APIKey: \"g2\"}},\n\t\tVertexCompatAPIKey: []config.VertexCompatKey{\n\t\t\t{APIKey: \"v1\"},\n\t\t},\n\t\tClaudeKey: []config.ClaudeKey{{APIKey: \"c1\"}},\n\t\tCodexKey:  []config.CodexKey{{APIKey: \"x1\"}, {APIKey: \"x2\"}},\n\t\tOpenAICompatibility: []config.OpenAICompatibility{\n\t\t\t{APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: \"o1\"}, {APIKey: \"o2\"}}},\n\t\t},\n\t}\n\n\tgemini, vertex, claude, codex, compat := BuildAPIKeyClients(cfg)\n\tif gemini != 2 || vertex != 1 || claude != 1 || codex != 2 || compat != 2 {\n\t\tt.Fatalf(\"unexpected counts: %d %d %d %d %d\", gemini, vertex, claude, codex, compat)\n\t}\n}\n\nfunc TestNormalizeAuthStripsTemporalFields(t *testing.T) {\n\tnow := time.Now()\n\tauth := &coreauth.Auth{\n\t\tCreatedAt:        now,\n\t\tUpdatedAt:        now,\n\t\tLastRefreshedAt:  now,\n\t\tNextRefreshAfter: now,\n\t\tQuota: coreauth.QuotaState{\n\t\t\tNextRecoverAt: now,\n\t\t},\n\t\tRuntime: map[string]any{\"k\": \"v\"},\n\t}\n\n\tnormalized := normalizeAuth(auth)\n\tif !normalized.CreatedAt.IsZero() || !normalized.UpdatedAt.IsZero() || !normalized.LastRefreshedAt.IsZero() || !normalized.NextRefreshAfter.IsZero() {\n\t\tt.Fatal(\"expected time fields to be zeroed\")\n\t}\n\tif normalized.Runtime != nil {\n\t\tt.Fatal(\"expected runtime to be nil\")\n\t}\n\tif !normalized.Quota.NextRecoverAt.IsZero() {\n\t\tt.Fatal(\"expected quota.NextRecoverAt to be zeroed\")\n\t}\n}\n\nfunc TestMatchProvider(t *testing.T) {\n\tif _, ok := matchProvider(\"OpenAI\", []string{\"openai\", \"claude\"}); !ok {\n\t\tt.Fatal(\"expected match to succeed ignoring case\")\n\t}\n\tif _, ok := matchProvider(\"missing\", []string{\"openai\"}); ok {\n\t\tt.Fatal(\"expected match to fail for unknown provider\")\n\t}\n}\n\nfunc TestSnapshotCoreAuths_ConfigAndAuthFiles(t *testing.T) {\n\tauthDir := t.TempDir()\n\tmetadata := map[string]any{\n\t\t\"type\":       \"gemini\",\n\t\t\"email\":      \"user@example.com\",\n\t\t\"project_id\": \"proj-a, proj-b\",\n\t\t\"proxy_url\":  \"https://proxy\",\n\t}\n\tauthFile := filepath.Join(authDir, \"gemini.json\")\n\tdata, err := json.Marshal(metadata)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to marshal metadata: %v\", err)\n\t}\n\tif err = os.WriteFile(authFile, data, 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tcfg := &config.Config{\n\t\tAuthDir: authDir,\n\t\tGeminiKey: []config.GeminiKey{\n\t\t\t{\n\t\t\t\tAPIKey:         \"g-key\",\n\t\t\t\tBaseURL:        \"https://gemini\",\n\t\t\t\tExcludedModels: []string{\"Model-A\", \"model-b\"},\n\t\t\t\tHeaders:        map[string]string{\"X-Req\": \"1\"},\n\t\t\t},\n\t\t},\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"gemini-cli\": {\"Foo\", \"bar\"},\n\t\t},\n\t}\n\n\tw := &Watcher{authDir: authDir}\n\tw.SetConfig(cfg)\n\n\tauths := w.SnapshotCoreAuths()\n\tif len(auths) != 4 {\n\t\tt.Fatalf(\"expected 4 auth entries (1 config + 1 primary + 2 virtual), got %d\", len(auths))\n\t}\n\n\tvar geminiAPIKeyAuth *coreauth.Auth\n\tvar geminiPrimary *coreauth.Auth\n\tvirtuals := make([]*coreauth.Auth, 0)\n\tfor _, a := range auths {\n\t\tswitch {\n\t\tcase a.Provider == \"gemini\" && a.Attributes[\"api_key\"] == \"g-key\":\n\t\t\tgeminiAPIKeyAuth = a\n\t\tcase a.Attributes[\"gemini_virtual_primary\"] == \"true\":\n\t\t\tgeminiPrimary = a\n\t\tcase strings.TrimSpace(a.Attributes[\"gemini_virtual_parent\"]) != \"\":\n\t\t\tvirtuals = append(virtuals, a)\n\t\t}\n\t}\n\tif geminiAPIKeyAuth == nil {\n\t\tt.Fatal(\"expected synthesized Gemini API key auth\")\n\t}\n\texpectedAPIKeyHash := diff.ComputeExcludedModelsHash([]string{\"Model-A\", \"model-b\"})\n\tif geminiAPIKeyAuth.Attributes[\"excluded_models_hash\"] != expectedAPIKeyHash {\n\t\tt.Fatalf(\"expected API key excluded hash %s, got %s\", expectedAPIKeyHash, geminiAPIKeyAuth.Attributes[\"excluded_models_hash\"])\n\t}\n\tif geminiAPIKeyAuth.Attributes[\"auth_kind\"] != \"apikey\" {\n\t\tt.Fatalf(\"expected auth_kind=apikey, got %s\", geminiAPIKeyAuth.Attributes[\"auth_kind\"])\n\t}\n\n\tif geminiPrimary == nil {\n\t\tt.Fatal(\"expected primary gemini-cli auth from file\")\n\t}\n\tif !geminiPrimary.Disabled || geminiPrimary.Status != coreauth.StatusDisabled {\n\t\tt.Fatal(\"expected primary gemini-cli auth to be disabled when virtual auths are synthesized\")\n\t}\n\texpectedOAuthHash := diff.ComputeExcludedModelsHash([]string{\"Foo\", \"bar\"})\n\tif geminiPrimary.Attributes[\"excluded_models_hash\"] != expectedOAuthHash {\n\t\tt.Fatalf(\"expected OAuth excluded hash %s, got %s\", expectedOAuthHash, geminiPrimary.Attributes[\"excluded_models_hash\"])\n\t}\n\tif geminiPrimary.Attributes[\"auth_kind\"] != \"oauth\" {\n\t\tt.Fatalf(\"expected auth_kind=oauth, got %s\", geminiPrimary.Attributes[\"auth_kind\"])\n\t}\n\n\tif len(virtuals) != 2 {\n\t\tt.Fatalf(\"expected 2 virtual auths, got %d\", len(virtuals))\n\t}\n\tfor _, v := range virtuals {\n\t\tif v.Attributes[\"gemini_virtual_parent\"] != geminiPrimary.ID {\n\t\t\tt.Fatalf(\"virtual auth missing parent link to %s\", geminiPrimary.ID)\n\t\t}\n\t\tif v.Attributes[\"excluded_models_hash\"] != expectedOAuthHash {\n\t\t\tt.Fatalf(\"expected virtual excluded hash %s, got %s\", expectedOAuthHash, v.Attributes[\"excluded_models_hash\"])\n\t\t}\n\t\tif v.Status != coreauth.StatusActive {\n\t\t\tt.Fatalf(\"expected virtual auth to be active, got %s\", v.Status)\n\t\t}\n\t}\n}\n\nfunc TestReloadConfigIfChanged_TriggersOnChangeAndSkipsUnchanged(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\twriteConfig := func(port int, allowRemote bool) {\n\t\tcfg := &config.Config{\n\t\t\tPort:    port,\n\t\t\tAuthDir: authDir,\n\t\t\tRemoteManagement: config.RemoteManagement{\n\t\t\t\tAllowRemote: allowRemote,\n\t\t\t},\n\t\t}\n\t\tdata, err := yaml.Marshal(cfg)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to marshal config: %v\", err)\n\t\t}\n\t\tif err = os.WriteFile(configPath, data, 0o644); err != nil {\n\t\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t\t}\n\t}\n\n\twriteConfig(8080, false)\n\n\treloads := 0\n\tw := &Watcher{\n\t\tconfigPath:     configPath,\n\t\tauthDir:        authDir,\n\t\treloadCallback: func(*config.Config) { reloads++ },\n\t}\n\n\tw.reloadConfigIfChanged()\n\tif reloads != 1 {\n\t\tt.Fatalf(\"expected first reload to trigger callback once, got %d\", reloads)\n\t}\n\n\t// Same content should be skipped by hash check.\n\tw.reloadConfigIfChanged()\n\tif reloads != 1 {\n\t\tt.Fatalf(\"expected unchanged config to be skipped, callback count %d\", reloads)\n\t}\n\n\twriteConfig(9090, true)\n\tw.reloadConfigIfChanged()\n\tif reloads != 2 {\n\t\tt.Fatalf(\"expected changed config to trigger reload, callback count %d\", reloads)\n\t}\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tif w.config == nil || w.config.Port != 9090 || !w.config.RemoteManagement.AllowRemote {\n\t\tt.Fatalf(\"expected config to be updated after reload, got %+v\", w.config)\n\t}\n}\n\nfunc TestStartAndStopSuccess(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create config file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw, err := NewWatcher(configPath, authDir, func(*config.Config) {\n\t\tatomic.AddInt32(&reloads, 1)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create watcher: %v\", err)\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tif err := w.Start(ctx); err != nil {\n\t\tt.Fatalf(\"expected Start to succeed: %v\", err)\n\t}\n\tcancel()\n\tif err := w.Stop(); err != nil {\n\t\tt.Fatalf(\"expected Stop to succeed: %v\", err)\n\t}\n\tif got := atomic.LoadInt32(&reloads); got != 1 {\n\t\tt.Fatalf(\"expected one reload callback, got %d\", got)\n\t}\n}\n\nfunc TestStartFailsWhenConfigMissing(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"missing-config.yaml\")\n\n\tw, err := NewWatcher(configPath, authDir, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create watcher: %v\", err)\n\t}\n\tdefer w.Stop()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tif err := w.Start(ctx); err == nil {\n\t\tt.Fatal(\"expected Start to fail for missing config file\")\n\t}\n}\n\nfunc TestDispatchRuntimeAuthUpdateEnqueuesAndUpdatesState(t *testing.T) {\n\tqueue := make(chan AuthUpdate, 4)\n\tw := &Watcher{}\n\tw.SetAuthUpdateQueue(queue)\n\tdefer w.stopDispatch()\n\n\tauth := &coreauth.Auth{ID: \"auth-1\", Provider: \"test\"}\n\tif ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionAdd, Auth: auth}); !ok {\n\t\tt.Fatal(\"expected DispatchRuntimeAuthUpdate to enqueue\")\n\t}\n\n\tselect {\n\tcase update := <-queue:\n\t\tif update.Action != AuthUpdateActionAdd || update.Auth.ID != \"auth-1\" {\n\t\t\tt.Fatalf(\"unexpected update: %+v\", update)\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for auth update\")\n\t}\n\n\tif ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionDelete, ID: \"auth-1\"}); !ok {\n\t\tt.Fatal(\"expected delete update to enqueue\")\n\t}\n\tselect {\n\tcase update := <-queue:\n\t\tif update.Action != AuthUpdateActionDelete || update.ID != \"auth-1\" {\n\t\t\tt.Fatalf(\"unexpected delete update: %+v\", update)\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for delete update\")\n\t}\n\tw.clientsMutex.RLock()\n\tif _, exists := w.runtimeAuths[\"auth-1\"]; exists {\n\t\tw.clientsMutex.RUnlock()\n\t\tt.Fatal(\"expected runtime auth to be cleared after delete\")\n\t}\n\tw.clientsMutex.RUnlock()\n}\n\nfunc TestAddOrUpdateClientSkipsUnchanged(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"sample.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create auth file: %v\", err)\n\t}\n\tdata, _ := os.ReadFile(authFile)\n\tsum := sha256.Sum256(data)\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        tmpDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) {\n\t\t\tatomic.AddInt32(&reloads, 1)\n\t\t},\n\t}\n\tw.SetConfig(&config.Config{AuthDir: tmpDir})\n\t// Use normalizeAuthPath to match how addOrUpdateClient stores the key\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(sum[:])\n\n\tw.addOrUpdateClient(authFile)\n\tif got := atomic.LoadInt32(&reloads); got != 0 {\n\t\tt.Fatalf(\"expected no reload for unchanged file, got %d\", got)\n\t}\n}\n\nfunc TestAddOrUpdateClientTriggersReloadAndHash(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"sample.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\",\"api_key\":\"k\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create auth file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        tmpDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) {\n\t\t\tatomic.AddInt32(&reloads, 1)\n\t\t},\n\t}\n\tw.SetConfig(&config.Config{AuthDir: tmpDir})\n\n\tw.addOrUpdateClient(authFile)\n\n\tif got := atomic.LoadInt32(&reloads); got != 0 {\n\t\tt.Fatalf(\"expected no reload callback for auth update, got %d\", got)\n\t}\n\t// Use normalizeAuthPath to match how addOrUpdateClient stores the key\n\tnormalized := w.normalizeAuthPath(authFile)\n\tif _, ok := w.lastAuthHashes[normalized]; !ok {\n\t\tt.Fatalf(\"expected hash to be stored for %s\", normalized)\n\t}\n}\n\nfunc TestRemoveClientRemovesHash(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"sample.json\")\n\tvar reloads int32\n\n\tw := &Watcher{\n\t\tauthDir:        tmpDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) {\n\t\t\tatomic.AddInt32(&reloads, 1)\n\t\t},\n\t}\n\tw.SetConfig(&config.Config{AuthDir: tmpDir})\n\t// Use normalizeAuthPath to set up the hash with the correct key format\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = \"hash\"\n\n\tw.removeClient(authFile)\n\tif _, ok := w.lastAuthHashes[w.normalizeAuthPath(authFile)]; ok {\n\t\tt.Fatal(\"expected hash to be removed after deletion\")\n\t}\n\tif got := atomic.LoadInt32(&reloads); got != 0 {\n\t\tt.Fatalf(\"expected no reload callback for auth removal, got %d\", got)\n\t}\n}\n\nfunc TestAuthFileEventsDoNotInvokeSnapshotCoreAuths(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"sample.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"codex\",\"email\":\"u@example.com\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create auth file: %v\", err)\n\t}\n\n\torigSnapshot := snapshotCoreAuthsFunc\n\tvar snapshotCalls int32\n\tsnapshotCoreAuthsFunc = func(cfg *config.Config, authDir string) []*coreauth.Auth {\n\t\tatomic.AddInt32(&snapshotCalls, 1)\n\t\treturn origSnapshot(cfg, authDir)\n\t}\n\tdefer func() { snapshotCoreAuthsFunc = origSnapshot }()\n\n\tw := &Watcher{\n\t\tauthDir:          tmpDir,\n\t\tlastAuthHashes:   make(map[string]string),\n\t\tlastAuthContents: make(map[string]*coreauth.Auth),\n\t\tfileAuthsByPath:  make(map[string]map[string]*coreauth.Auth),\n\t}\n\tw.SetConfig(&config.Config{AuthDir: tmpDir})\n\n\tw.addOrUpdateClient(authFile)\n\tw.removeClient(authFile)\n\n\tif got := atomic.LoadInt32(&snapshotCalls); got != 0 {\n\t\tt.Fatalf(\"expected auth file events to avoid full snapshot, got %d calls\", got)\n\t}\n}\n\nfunc TestAuthSliceToMap(t *testing.T) {\n\tt.Parallel()\n\n\tvalid1 := &coreauth.Auth{ID: \"a\"}\n\tvalid2 := &coreauth.Auth{ID: \"b\"}\n\tdupOld := &coreauth.Auth{ID: \"dup\", Label: \"old\"}\n\tdupNew := &coreauth.Auth{ID: \"dup\", Label: \"new\"}\n\tempty := &coreauth.Auth{ID: \"  \"}\n\n\ttests := []struct {\n\t\tname string\n\t\tin   []*coreauth.Auth\n\t\twant map[string]*coreauth.Auth\n\t}{\n\t\t{\n\t\t\tname: \"nil input\",\n\t\t\tin:   nil,\n\t\t\twant: map[string]*coreauth.Auth{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty input\",\n\t\t\tin:   []*coreauth.Auth{},\n\t\t\twant: map[string]*coreauth.Auth{},\n\t\t},\n\t\t{\n\t\t\tname: \"filters invalid auths\",\n\t\t\tin:   []*coreauth.Auth{nil, empty},\n\t\t\twant: map[string]*coreauth.Auth{},\n\t\t},\n\t\t{\n\t\t\tname: \"keeps valid auths\",\n\t\t\tin:   []*coreauth.Auth{valid1, nil, valid2},\n\t\t\twant: map[string]*coreauth.Auth{\"a\": valid1, \"b\": valid2},\n\t\t},\n\t\t{\n\t\t\tname: \"last duplicate wins\",\n\t\t\tin:   []*coreauth.Auth{dupOld, dupNew},\n\t\t\twant: map[string]*coreauth.Auth{\"dup\": dupNew},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tgot := authSliceToMap(tc.in)\n\t\t\tif len(tc.want) == 0 {\n\t\t\t\tif got == nil {\n\t\t\t\t\tt.Fatal(\"expected empty map, got nil\")\n\t\t\t\t}\n\t\t\t\tif len(got) != 0 {\n\t\t\t\t\tt.Fatalf(\"expected empty map, got %#v\", got)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(got) != len(tc.want) {\n\t\t\t\tt.Fatalf(\"unexpected map length: got %d, want %d\", len(got), len(tc.want))\n\t\t\t}\n\t\t\tfor id, wantAuth := range tc.want {\n\t\t\t\tgotAuth, ok := got[id]\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"missing id %q in result map\", id)\n\t\t\t\t}\n\t\t\t\tif !authEqual(gotAuth, wantAuth) {\n\t\t\t\t\tt.Fatalf(\"unexpected auth for id %q: got %#v, want %#v\", id, gotAuth, wantAuth)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTriggerServerUpdateCancelsPendingTimerOnImmediate(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tcfg := &config.Config{AuthDir: tmpDir}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\treloadCallback: func(*config.Config) {\n\t\t\tatomic.AddInt32(&reloads, 1)\n\t\t},\n\t}\n\tw.SetConfig(cfg)\n\n\tw.serverUpdateMu.Lock()\n\tw.serverUpdateLast = time.Now().Add(-(serverUpdateDebounce - 100*time.Millisecond))\n\tw.serverUpdateMu.Unlock()\n\tw.triggerServerUpdate(cfg)\n\n\tif got := atomic.LoadInt32(&reloads); got != 0 {\n\t\tt.Fatalf(\"expected no immediate reload, got %d\", got)\n\t}\n\n\tw.serverUpdateMu.Lock()\n\tif !w.serverUpdatePend || w.serverUpdateTimer == nil {\n\t\tw.serverUpdateMu.Unlock()\n\t\tt.Fatal(\"expected a pending server update timer\")\n\t}\n\tw.serverUpdateLast = time.Now().Add(-(serverUpdateDebounce + 10*time.Millisecond))\n\tw.serverUpdateMu.Unlock()\n\n\tw.triggerServerUpdate(cfg)\n\tif got := atomic.LoadInt32(&reloads); got != 1 {\n\t\tt.Fatalf(\"expected immediate reload once, got %d\", got)\n\t}\n\n\ttime.Sleep(250 * time.Millisecond)\n\tif got := atomic.LoadInt32(&reloads); got != 1 {\n\t\tt.Fatalf(\"expected pending timer to be cancelled, got %d reloads\", got)\n\t}\n}\n\nfunc TestShouldDebounceRemove(t *testing.T) {\n\tw := &Watcher{}\n\tpath := filepath.Clean(\"test.json\")\n\n\tif w.shouldDebounceRemove(path, time.Now()) {\n\t\tt.Fatal(\"first call should not debounce\")\n\t}\n\tif !w.shouldDebounceRemove(path, time.Now()) {\n\t\tt.Fatal(\"second call within window should debounce\")\n\t}\n\n\tw.clientsMutex.Lock()\n\tw.lastRemoveTimes = map[string]time.Time{path: time.Now().Add(-2 * authRemoveDebounceWindow)}\n\tw.clientsMutex.Unlock()\n\n\tif w.shouldDebounceRemove(path, time.Now()) {\n\t\tt.Fatal(\"call after window should not debounce\")\n\t}\n}\n\nfunc TestAuthFileUnchangedUsesHash(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"sample.json\")\n\tcontent := []byte(`{\"type\":\"demo\"}`)\n\tif err := os.WriteFile(authFile, content, 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tw := &Watcher{lastAuthHashes: make(map[string]string)}\n\tunchanged, err := w.authFileUnchanged(authFile)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif unchanged {\n\t\tt.Fatal(\"expected first check to report changed\")\n\t}\n\n\tsum := sha256.Sum256(content)\n\t// Use normalizeAuthPath to match how authFileUnchanged looks up the key\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(sum[:])\n\n\tunchanged, err = w.authFileUnchanged(authFile)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif !unchanged {\n\t\tt.Fatal(\"expected hash match to report unchanged\")\n\t}\n}\n\nfunc TestAuthFileUnchangedEmptyAndMissing(t *testing.T) {\n\ttmpDir := t.TempDir()\n\temptyFile := filepath.Join(tmpDir, \"empty.json\")\n\tif err := os.WriteFile(emptyFile, []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write empty auth file: %v\", err)\n\t}\n\n\tw := &Watcher{lastAuthHashes: make(map[string]string)}\n\tunchanged, err := w.authFileUnchanged(emptyFile)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error for empty file: %v\", err)\n\t}\n\tif unchanged {\n\t\tt.Fatal(\"expected empty file to be treated as changed\")\n\t}\n\n\t_, err = w.authFileUnchanged(filepath.Join(tmpDir, \"missing.json\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected error for missing auth file\")\n\t}\n}\n\nfunc TestReloadClientsCachesAuthHashes(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"one.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\tw := &Watcher{\n\t\tauthDir: tmpDir,\n\t\tconfig:  &config.Config{AuthDir: tmpDir},\n\t}\n\n\tw.reloadClients(true, nil, false)\n\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tif len(w.lastAuthHashes) != 1 {\n\t\tt.Fatalf(\"expected hash cache for one auth file, got %d\", len(w.lastAuthHashes))\n\t}\n}\n\nfunc TestReloadClientsLogsConfigDiffs(t *testing.T) {\n\ttmpDir := t.TempDir()\n\toldCfg := &config.Config{AuthDir: tmpDir, Port: 1, Debug: false}\n\tnewCfg := &config.Config{AuthDir: tmpDir, Port: 2, Debug: true}\n\n\tw := &Watcher{\n\t\tauthDir: tmpDir,\n\t\tconfig:  oldCfg,\n\t}\n\tw.SetConfig(oldCfg)\n\tw.oldConfigYaml, _ = yaml.Marshal(oldCfg)\n\n\tw.clientsMutex.Lock()\n\tw.config = newCfg\n\tw.clientsMutex.Unlock()\n\n\tw.reloadClients(false, nil, false)\n}\n\nfunc TestReloadClientsHandlesNilConfig(t *testing.T) {\n\tw := &Watcher{}\n\tw.reloadClients(true, nil, false)\n}\n\nfunc TestReloadClientsFiltersProvidersWithNilCurrentAuths(t *testing.T) {\n\ttmp := t.TempDir()\n\tw := &Watcher{\n\t\tauthDir: tmp,\n\t\tconfig:  &config.Config{AuthDir: tmp},\n\t}\n\tw.reloadClients(false, []string{\"match\"}, false)\n\tif w.currentAuths != nil && len(w.currentAuths) != 0 {\n\t\tt.Fatalf(\"expected currentAuths to be nil or empty, got %d\", len(w.currentAuths))\n\t}\n}\n\nfunc TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {\n\tw := &Watcher{}\n\tqueue := make(chan AuthUpdate, 1)\n\tw.SetAuthUpdateQueue(queue)\n\tif w.dispatchCond == nil || w.dispatchCancel == nil {\n\t\tt.Fatal(\"expected dispatch to be initialized\")\n\t}\n\tw.SetAuthUpdateQueue(nil)\n\tif w.dispatchCancel != nil {\n\t\tt.Fatal(\"expected dispatch cancel to be cleared when queue nil\")\n\t}\n}\n\nfunc TestPersistAsyncEarlyReturns(t *testing.T) {\n\tvar nilWatcher *Watcher\n\tnilWatcher.persistConfigAsync()\n\tnilWatcher.persistAuthAsync(\"msg\", \"a\")\n\n\tw := &Watcher{}\n\tw.persistConfigAsync()\n\tw.persistAuthAsync(\"msg\", \"   \", \"\")\n}\n\ntype errorPersister struct {\n\tconfigCalls int32\n\tauthCalls   int32\n}\n\nfunc (p *errorPersister) PersistConfig(context.Context) error {\n\tatomic.AddInt32(&p.configCalls, 1)\n\treturn fmt.Errorf(\"persist config error\")\n}\n\nfunc (p *errorPersister) PersistAuthFiles(context.Context, string, ...string) error {\n\tatomic.AddInt32(&p.authCalls, 1)\n\treturn fmt.Errorf(\"persist auth error\")\n}\n\nfunc TestPersistAsyncErrorPaths(t *testing.T) {\n\tp := &errorPersister{}\n\tw := &Watcher{storePersister: p}\n\tw.persistConfigAsync()\n\tw.persistAuthAsync(\"msg\", \"a\")\n\ttime.Sleep(30 * time.Millisecond)\n\tif atomic.LoadInt32(&p.configCalls) != 1 {\n\t\tt.Fatalf(\"expected PersistConfig to be called once, got %d\", p.configCalls)\n\t}\n\tif atomic.LoadInt32(&p.authCalls) != 1 {\n\t\tt.Fatalf(\"expected PersistAuthFiles to be called once, got %d\", p.authCalls)\n\t}\n}\n\nfunc TestStopConfigReloadTimerSafeWhenNil(t *testing.T) {\n\tw := &Watcher{}\n\tw.stopConfigReloadTimer()\n\tw.configReloadMu.Lock()\n\tw.configReloadTimer = time.AfterFunc(10*time.Millisecond, func() {})\n\tw.configReloadMu.Unlock()\n\ttime.Sleep(1 * time.Millisecond)\n\tw.stopConfigReloadTimer()\n}\n\nfunc TestHandleEventRemovesAuthFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthFile := filepath.Join(tmpDir, \"remove.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\tif err := os.Remove(authFile); err != nil {\n\t\tt.Fatalf(\"failed to remove auth file pre-check: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        tmpDir,\n\t\tconfig:         &config.Config{AuthDir: tmpDir},\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) {\n\t\t\tatomic.AddInt32(&reloads, 1)\n\t\t},\n\t}\n\t// Use normalizeAuthPath to set up the hash with the correct key format\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = \"hash\"\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})\n\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected no reload callback for auth removal, got %d\", reloads)\n\t}\n\tif _, ok := w.lastAuthHashes[w.normalizeAuthPath(authFile)]; ok {\n\t\tt.Fatal(\"expected hash entry to be removed\")\n\t}\n}\n\nfunc TestDispatchAuthUpdatesFlushesQueue(t *testing.T) {\n\tqueue := make(chan AuthUpdate, 4)\n\tw := &Watcher{}\n\tw.SetAuthUpdateQueue(queue)\n\tdefer w.stopDispatch()\n\n\tw.dispatchAuthUpdates([]AuthUpdate{\n\t\t{Action: AuthUpdateActionAdd, ID: \"a\"},\n\t\t{Action: AuthUpdateActionModify, ID: \"b\"},\n\t})\n\n\tgot := make([]AuthUpdate, 0, 2)\n\tfor i := 0; i < 2; i++ {\n\t\tselect {\n\t\tcase u := <-queue:\n\t\t\tgot = append(got, u)\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Fatalf(\"timed out waiting for update %d\", i)\n\t\t}\n\t}\n\tif len(got) != 2 || got[0].ID != \"a\" || got[1].ID != \"b\" {\n\t\tt.Fatalf(\"unexpected updates order/content: %+v\", got)\n\t}\n}\n\nfunc TestDispatchLoopExitsOnContextDoneWhileSending(t *testing.T) {\n\tqueue := make(chan AuthUpdate) // unbuffered to block sends\n\tw := &Watcher{\n\t\tauthQueue: queue,\n\t\tpendingUpdates: map[string]AuthUpdate{\n\t\t\t\"k\": {Action: AuthUpdateActionAdd, ID: \"k\"},\n\t\t},\n\t\tpendingOrder: []string{\"k\"},\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tw.dispatchLoop(ctx)\n\t\tclose(done)\n\t}()\n\n\ttime.Sleep(30 * time.Millisecond)\n\tcancel()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"expected dispatchLoop to exit after ctx canceled while blocked on send\")\n\t}\n}\n\nfunc TestProcessEventsHandlesEventErrorAndChannelClose(t *testing.T) {\n\tw := &Watcher{\n\t\twatcher: &fsnotify.Watcher{\n\t\t\tEvents: make(chan fsnotify.Event, 2),\n\t\t\tErrors: make(chan error, 2),\n\t\t},\n\t\tconfigPath: \"config.yaml\",\n\t\tauthDir:    \"auth\",\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tw.processEvents(ctx)\n\t\tclose(done)\n\t}()\n\n\tw.watcher.Events <- fsnotify.Event{Name: \"unrelated.txt\", Op: fsnotify.Write}\n\tw.watcher.Errors <- fmt.Errorf(\"watcher error\")\n\n\ttime.Sleep(20 * time.Millisecond)\n\tclose(w.watcher.Events)\n\tclose(w.watcher.Errors)\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"processEvents did not exit after channels closed\")\n\t}\n}\n\nfunc TestProcessEventsReturnsWhenErrorsChannelClosed(t *testing.T) {\n\tw := &Watcher{\n\t\twatcher: &fsnotify.Watcher{\n\t\t\tEvents: nil,\n\t\t\tErrors: make(chan error),\n\t\t},\n\t}\n\n\tclose(w.watcher.Errors)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tw.processEvents(ctx)\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"processEvents did not exit after errors channel closed\")\n\t}\n}\n\nfunc TestHandleEventIgnoresUnrelatedFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.handleEvent(fsnotify.Event{Name: filepath.Join(tmpDir, \"note.txt\"), Op: fsnotify.Write})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected no reloads for unrelated file, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventConfigChangeSchedulesReload(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.handleEvent(fsnotify.Event{Name: configPath, Op: fsnotify.Write})\n\n\ttime.Sleep(400 * time.Millisecond)\n\tif atomic.LoadInt32(&reloads) != 1 {\n\t\tt.Fatalf(\"expected config change to trigger reload once, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventAuthWriteTriggersUpdate(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"a.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Write})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected auth write to avoid global reload callback, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventRemoveDebounceSkips(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"remove.json\")\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\tlastRemoveTimes: map[string]time.Time{\n\t\t\tfilepath.Clean(authFile): time.Now(),\n\t\t},\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected remove to be debounced, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventAtomicReplaceUnchangedSkips(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"same.json\")\n\tcontent := []byte(`{\"type\":\"demo\"}`)\n\tif err := os.WriteFile(authFile, content, 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\tsum := sha256.Sum256(content)\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(sum[:])\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Rename})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected unchanged atomic replace to be skipped, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventAtomicReplaceChangedTriggersUpdate(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"change.json\")\n\toldContent := []byte(`{\"type\":\"demo\",\"v\":1}`)\n\tnewContent := []byte(`{\"type\":\"demo\",\"v\":2}`)\n\tif err := os.WriteFile(authFile, newContent, 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\toldSum := sha256.Sum256(oldContent)\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(oldSum[:])\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Rename})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected changed atomic replace to avoid global reload, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventRemoveUnknownFileIgnored(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"unknown.json\")\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected unknown remove to be ignored, got %d\", reloads)\n\t}\n}\n\nfunc TestHandleEventRemoveKnownFileDeletes(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthFile := filepath.Join(authDir, \"known.json\")\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tconfigPath:     configPath,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\tw.lastAuthHashes[w.normalizeAuthPath(authFile)] = \"hash\"\n\n\tw.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected known remove to avoid global reload, got %d\", reloads)\n\t}\n\tif _, ok := w.lastAuthHashes[w.normalizeAuthPath(authFile)]; ok {\n\t\tt.Fatal(\"expected known auth hash to be deleted\")\n\t}\n}\n\nfunc TestNormalizeAuthPathAndDebounceCleanup(t *testing.T) {\n\tw := &Watcher{}\n\tif got := w.normalizeAuthPath(\"   \"); got != \"\" {\n\t\tt.Fatalf(\"expected empty normalize result, got %q\", got)\n\t}\n\tif got := w.normalizeAuthPath(\"  a/../b  \"); got != filepath.Clean(\"a/../b\") {\n\t\tt.Fatalf(\"unexpected normalize result: %q\", got)\n\t}\n\n\tw.clientsMutex.Lock()\n\tw.lastRemoveTimes = make(map[string]time.Time, 140)\n\told := time.Now().Add(-3 * authRemoveDebounceWindow)\n\tfor i := 0; i < 129; i++ {\n\t\tw.lastRemoveTimes[fmt.Sprintf(\"old-%d\", i)] = old\n\t}\n\tw.clientsMutex.Unlock()\n\n\tw.shouldDebounceRemove(\"new-path\", time.Now())\n\n\tw.clientsMutex.Lock()\n\tgotLen := len(w.lastRemoveTimes)\n\tw.clientsMutex.Unlock()\n\tif gotLen >= 129 {\n\t\tt.Fatalf(\"expected debounce cleanup to shrink map, got %d\", gotLen)\n\t}\n}\n\nfunc TestRefreshAuthStateDispatchesRuntimeAuths(t *testing.T) {\n\tqueue := make(chan AuthUpdate, 8)\n\tw := &Watcher{\n\t\tauthDir:        t.TempDir(),\n\t\tlastAuthHashes: make(map[string]string),\n\t}\n\tw.SetConfig(&config.Config{AuthDir: w.authDir})\n\tw.SetAuthUpdateQueue(queue)\n\tdefer w.stopDispatch()\n\n\tw.clientsMutex.Lock()\n\tw.runtimeAuths = map[string]*coreauth.Auth{\n\t\t\"nil\": nil,\n\t\t\"r1\":  {ID: \"r1\", Provider: \"runtime\"},\n\t}\n\tw.clientsMutex.Unlock()\n\n\tw.refreshAuthState(false)\n\n\tselect {\n\tcase u := <-queue:\n\t\tif u.Action != AuthUpdateActionAdd || u.ID != \"r1\" {\n\t\t\tt.Fatalf(\"unexpected auth update: %+v\", u)\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for runtime auth update\")\n\t}\n}\n\nfunc TestAddOrUpdateClientEdgeCases(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := tmpDir\n\tauthFile := filepath.Join(tmpDir, \"edge.json\")\n\tif err := os.WriteFile(authFile, []byte(`{\"type\":\"demo\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\temptyFile := filepath.Join(tmpDir, \"empty.json\")\n\tif err := os.WriteFile(emptyFile, []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write empty auth file: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tauthDir:        authDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\n\tw.addOrUpdateClient(filepath.Join(tmpDir, \"missing.json\"))\n\tw.addOrUpdateClient(emptyFile)\n\tif atomic.LoadInt32(&reloads) != 0 {\n\t\tt.Fatalf(\"expected no reloads for missing/empty file, got %d\", reloads)\n\t}\n\n\tw.addOrUpdateClient(authFile) // config nil -> should not panic or update\n\tif len(w.lastAuthHashes) != 0 {\n\t\tt.Fatalf(\"expected no hash entries without config, got %d\", len(w.lastAuthHashes))\n\t}\n}\n\nfunc TestLoadFileClientsWalkError(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tnoAccessDir := filepath.Join(tmpDir, \"0noaccess\")\n\tif err := os.MkdirAll(noAccessDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create noaccess dir: %v\", err)\n\t}\n\tif err := os.Chmod(noAccessDir, 0); err != nil {\n\t\tt.Skipf(\"chmod not supported: %v\", err)\n\t}\n\tdefer func() { _ = os.Chmod(noAccessDir, 0o755) }()\n\n\tcfg := &config.Config{AuthDir: tmpDir}\n\tw := &Watcher{}\n\tw.SetConfig(cfg)\n\n\tcount := w.loadFileClients(cfg)\n\tif count != 0 {\n\t\tt.Fatalf(\"expected count 0 due to walk error, got %d\", count)\n\t}\n}\n\nfunc TestReloadConfigIfChangedHandlesMissingAndEmpty(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\n\tw := &Watcher{\n\t\tconfigPath: filepath.Join(tmpDir, \"missing.yaml\"),\n\t\tauthDir:    authDir,\n\t}\n\tw.reloadConfigIfChanged() // missing file -> log + return\n\n\temptyPath := filepath.Join(tmpDir, \"empty.yaml\")\n\tif err := os.WriteFile(emptyPath, []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write empty config: %v\", err)\n\t}\n\tw.configPath = emptyPath\n\tw.reloadConfigIfChanged() // empty file -> early return\n}\n\nfunc TestReloadConfigUsesMirroredAuthDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+filepath.Join(tmpDir, \"other\")+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t}\n\n\tw := &Watcher{\n\t\tconfigPath:      configPath,\n\t\tauthDir:         authDir,\n\t\tmirroredAuthDir: authDir,\n\t\tlastAuthHashes:  make(map[string]string),\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tif ok := w.reloadConfig(); !ok {\n\t\tt.Fatal(\"expected reloadConfig to succeed\")\n\t}\n\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tif w.config == nil || w.config.AuthDir != authDir {\n\t\tt.Fatalf(\"expected AuthDir to be overridden by mirroredAuthDir %s, got %+v\", authDir, w.config)\n\t}\n}\n\nfunc TestReloadConfigFiltersAffectedOAuthProviders(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\n\t// Ensure SnapshotCoreAuths yields a provider that is NOT affected, so we can assert it survives.\n\tif err := os.WriteFile(filepath.Join(authDir, \"provider-b.json\"), []byte(`{\"type\":\"provider-b\",\"email\":\"b@example.com\"}`), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write auth file: %v\", err)\n\t}\n\n\toldCfg := &config.Config{\n\t\tAuthDir: authDir,\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"provider-a\": {\"m1\"},\n\t\t},\n\t}\n\tnewCfg := &config.Config{\n\t\tAuthDir: authDir,\n\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\"provider-a\": {\"m2\"},\n\t\t},\n\t}\n\tdata, err := yaml.Marshal(newCfg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to marshal config: %v\", err)\n\t}\n\tif err = os.WriteFile(configPath, data, 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t}\n\n\tw := &Watcher{\n\t\tconfigPath:     configPath,\n\t\tauthDir:        authDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\tcurrentAuths: map[string]*coreauth.Auth{\n\t\t\t\"a\": {ID: \"a\", Provider: \"provider-a\"},\n\t\t},\n\t}\n\tw.SetConfig(oldCfg)\n\n\tif ok := w.reloadConfig(); !ok {\n\t\tt.Fatal(\"expected reloadConfig to succeed\")\n\t}\n\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tfor _, auth := range w.currentAuths {\n\t\tif auth != nil && auth.Provider == \"provider-a\" {\n\t\t\tt.Fatal(\"expected affected provider auth to be filtered\")\n\t\t}\n\t}\n\tfoundB := false\n\tfor _, auth := range w.currentAuths {\n\t\tif auth != nil && auth.Provider == \"provider-b\" {\n\t\t\tfoundB = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundB {\n\t\tt.Fatal(\"expected unaffected provider auth to remain\")\n\t}\n}\n\nfunc TestReloadConfigTriggersCallbackForMaxRetryCredentialsChange(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthDir := filepath.Join(tmpDir, \"auth\")\n\tif err := os.MkdirAll(authDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create auth dir: %v\", err)\n\t}\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\n\toldCfg := &config.Config{\n\t\tAuthDir:             authDir,\n\t\tMaxRetryCredentials: 0,\n\t\tRequestRetry:        1,\n\t\tMaxRetryInterval:    5,\n\t}\n\tnewCfg := &config.Config{\n\t\tAuthDir:             authDir,\n\t\tMaxRetryCredentials: 2,\n\t\tRequestRetry:        1,\n\t\tMaxRetryInterval:    5,\n\t}\n\tdata, errMarshal := yaml.Marshal(newCfg)\n\tif errMarshal != nil {\n\t\tt.Fatalf(\"failed to marshal config: %v\", errMarshal)\n\t}\n\tif errWrite := os.WriteFile(configPath, data, 0o644); errWrite != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", errWrite)\n\t}\n\n\tcallbackCalls := 0\n\tcallbackMaxRetryCredentials := -1\n\tw := &Watcher{\n\t\tconfigPath:     configPath,\n\t\tauthDir:        authDir,\n\t\tlastAuthHashes: make(map[string]string),\n\t\treloadCallback: func(cfg *config.Config) {\n\t\t\tcallbackCalls++\n\t\t\tif cfg != nil {\n\t\t\t\tcallbackMaxRetryCredentials = cfg.MaxRetryCredentials\n\t\t\t}\n\t\t},\n\t}\n\tw.SetConfig(oldCfg)\n\n\tif ok := w.reloadConfig(); !ok {\n\t\tt.Fatal(\"expected reloadConfig to succeed\")\n\t}\n\n\tif callbackCalls != 1 {\n\t\tt.Fatalf(\"expected reload callback to be called once, got %d\", callbackCalls)\n\t}\n\tif callbackMaxRetryCredentials != 2 {\n\t\tt.Fatalf(\"expected callback MaxRetryCredentials=2, got %d\", callbackMaxRetryCredentials)\n\t}\n\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tif w.config == nil || w.config.MaxRetryCredentials != 2 {\n\t\tt.Fatalf(\"expected watcher config MaxRetryCredentials=2, got %+v\", w.config)\n\t}\n}\n\nfunc TestStartFailsWhenAuthDirMissing(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"auth_dir: \"+filepath.Join(tmpDir, \"missing-auth\")+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\tauthDir := filepath.Join(tmpDir, \"missing-auth\")\n\n\tw, err := NewWatcher(configPath, authDir, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create watcher: %v\", err)\n\t}\n\tdefer w.Stop()\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tif err := w.Start(ctx); err == nil {\n\t\tt.Fatal(\"expected Start to fail for missing auth dir\")\n\t}\n}\n\nfunc TestDispatchRuntimeAuthUpdateReturnsFalseWithoutQueue(t *testing.T) {\n\tw := &Watcher{}\n\tif ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionAdd, Auth: &coreauth.Auth{ID: \"a\"}}); ok {\n\t\tt.Fatal(\"expected DispatchRuntimeAuthUpdate to return false when no queue configured\")\n\t}\n\tif ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionDelete, Auth: &coreauth.Auth{ID: \"a\"}}); ok {\n\t\tt.Fatal(\"expected DispatchRuntimeAuthUpdate delete to return false when no queue configured\")\n\t}\n}\n\nfunc TestNormalizeAuthNil(t *testing.T) {\n\tif normalizeAuth(nil) != nil {\n\t\tt.Fatal(\"expected normalizeAuth(nil) to return nil\")\n\t}\n}\n\n// stubStore implements coreauth.Store plus watcher-specific persistence helpers.\ntype stubStore struct {\n\tauthDir         string\n\tcfgPersisted    int32\n\tauthPersisted   int32\n\tlastAuthMessage string\n\tlastAuthPaths   []string\n}\n\nfunc (s *stubStore) List(context.Context) ([]*coreauth.Auth, error) { return nil, nil }\nfunc (s *stubStore) Save(context.Context, *coreauth.Auth) (string, error) {\n\treturn \"\", nil\n}\nfunc (s *stubStore) Delete(context.Context, string) error { return nil }\nfunc (s *stubStore) PersistConfig(context.Context) error {\n\tatomic.AddInt32(&s.cfgPersisted, 1)\n\treturn nil\n}\nfunc (s *stubStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {\n\tatomic.AddInt32(&s.authPersisted, 1)\n\ts.lastAuthMessage = message\n\ts.lastAuthPaths = paths\n\treturn nil\n}\nfunc (s *stubStore) AuthDir() string { return s.authDir }\n\nfunc TestNewWatcherDetectsPersisterAndAuthDir(t *testing.T) {\n\ttmp := t.TempDir()\n\tstore := &stubStore{authDir: tmp}\n\torig := sdkAuth.GetTokenStore()\n\tsdkAuth.RegisterTokenStore(store)\n\tdefer sdkAuth.RegisterTokenStore(orig)\n\n\tw, err := NewWatcher(\"config.yaml\", \"auth\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"NewWatcher failed: %v\", err)\n\t}\n\tif w.storePersister == nil {\n\t\tt.Fatal(\"expected storePersister to be set from token store\")\n\t}\n\tif w.mirroredAuthDir != tmp {\n\t\tt.Fatalf(\"expected mirroredAuthDir %s, got %s\", tmp, w.mirroredAuthDir)\n\t}\n}\n\nfunc TestPersistConfigAndAuthAsyncInvokePersister(t *testing.T) {\n\tw := &Watcher{\n\t\tstorePersister: &stubStore{},\n\t}\n\n\tw.persistConfigAsync()\n\tw.persistAuthAsync(\"msg\", \" a \", \"\", \"b \")\n\n\ttime.Sleep(30 * time.Millisecond)\n\tstore := w.storePersister.(*stubStore)\n\tif atomic.LoadInt32(&store.cfgPersisted) != 1 {\n\t\tt.Fatalf(\"expected PersistConfig to be called once, got %d\", store.cfgPersisted)\n\t}\n\tif atomic.LoadInt32(&store.authPersisted) != 1 {\n\t\tt.Fatalf(\"expected PersistAuthFiles to be called once, got %d\", store.authPersisted)\n\t}\n\tif store.lastAuthMessage != \"msg\" {\n\t\tt.Fatalf(\"unexpected auth message: %s\", store.lastAuthMessage)\n\t}\n\tif len(store.lastAuthPaths) != 2 || store.lastAuthPaths[0] != \"a\" || store.lastAuthPaths[1] != \"b\" {\n\t\tt.Fatalf(\"unexpected filtered paths: %#v\", store.lastAuthPaths)\n\t}\n}\n\nfunc TestScheduleConfigReloadDebounces(t *testing.T) {\n\ttmp := t.TempDir()\n\tauthDir := tmp\n\tcfgPath := tmp + \"/config.yaml\"\n\tif err := os.WriteFile(cfgPath, []byte(\"auth_dir: \"+authDir+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t}\n\n\tvar reloads int32\n\tw := &Watcher{\n\t\tconfigPath:     cfgPath,\n\t\tauthDir:        authDir,\n\t\treloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },\n\t}\n\tw.SetConfig(&config.Config{AuthDir: authDir})\n\n\tw.scheduleConfigReload()\n\tw.scheduleConfigReload()\n\n\ttime.Sleep(400 * time.Millisecond)\n\n\tif atomic.LoadInt32(&reloads) != 1 {\n\t\tt.Fatalf(\"expected single debounced reload, got %d\", reloads)\n\t}\n\tif w.lastConfigHash == \"\" {\n\t\tt.Fatal(\"expected lastConfigHash to be set after reload\")\n\t}\n}\n\nfunc TestPrepareAuthUpdatesLockedForceAndDelete(t *testing.T) {\n\tw := &Watcher{\n\t\tcurrentAuths: map[string]*coreauth.Auth{\n\t\t\t\"a\": {ID: \"a\", Provider: \"p1\"},\n\t\t},\n\t\tauthQueue: make(chan AuthUpdate, 4),\n\t}\n\n\tupdates := w.prepareAuthUpdatesLocked([]*coreauth.Auth{{ID: \"a\", Provider: \"p2\"}}, false)\n\tif len(updates) != 1 || updates[0].Action != AuthUpdateActionModify || updates[0].ID != \"a\" {\n\t\tt.Fatalf(\"unexpected modify updates: %+v\", updates)\n\t}\n\n\tupdates = w.prepareAuthUpdatesLocked([]*coreauth.Auth{{ID: \"a\", Provider: \"p2\"}}, true)\n\tif len(updates) != 1 || updates[0].Action != AuthUpdateActionModify {\n\t\tt.Fatalf(\"expected force modify, got %+v\", updates)\n\t}\n\n\tupdates = w.prepareAuthUpdatesLocked([]*coreauth.Auth{}, false)\n\tif len(updates) != 1 || updates[0].Action != AuthUpdateActionDelete || updates[0].ID != \"a\" {\n\t\tt.Fatalf(\"expected delete for missing auth, got %+v\", updates)\n\t}\n}\n\nfunc TestAuthEqualIgnoresTemporalFields(t *testing.T) {\n\tnow := time.Now()\n\ta := &coreauth.Auth{ID: \"x\", CreatedAt: now}\n\tb := &coreauth.Auth{ID: \"x\", CreatedAt: now.Add(5 * time.Second)}\n\tif !authEqual(a, b) {\n\t\tt.Fatal(\"expected authEqual to ignore temporal differences\")\n\t}\n}\n\nfunc TestDispatchLoopExitsWhenQueueNilAndContextCanceled(t *testing.T) {\n\tw := &Watcher{\n\t\tdispatchCond:   nil,\n\t\tpendingUpdates: map[string]AuthUpdate{\"k\": {ID: \"k\"}},\n\t\tpendingOrder:   []string{\"k\"},\n\t}\n\tw.dispatchCond = sync.NewCond(&w.dispatchMu)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tw.dispatchLoop(ctx)\n\t\tclose(done)\n\t}()\n\n\ttime.Sleep(20 * time.Millisecond)\n\tcancel()\n\tw.dispatchMu.Lock()\n\tw.dispatchCond.Broadcast()\n\tw.dispatchMu.Unlock()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"dispatchLoop did not exit after context cancel\")\n\t}\n}\n\nfunc TestReloadClientsFiltersOAuthProvidersWithoutRescan(t *testing.T) {\n\ttmp := t.TempDir()\n\tw := &Watcher{\n\t\tauthDir: tmp,\n\t\tconfig:  &config.Config{AuthDir: tmp},\n\t\tcurrentAuths: map[string]*coreauth.Auth{\n\t\t\t\"a\": {ID: \"a\", Provider: \"Match\"},\n\t\t\t\"b\": {ID: \"b\", Provider: \"other\"},\n\t\t},\n\t\tlastAuthHashes: map[string]string{\"cached\": \"hash\"},\n\t}\n\n\tw.reloadClients(false, []string{\"match\"}, false)\n\n\tw.clientsMutex.RLock()\n\tdefer w.clientsMutex.RUnlock()\n\tif _, ok := w.currentAuths[\"a\"]; ok {\n\t\tt.Fatal(\"expected filtered provider to be removed\")\n\t}\n\tif len(w.lastAuthHashes) != 1 {\n\t\tt.Fatalf(\"expected existing hash cache to be retained, got %d\", len(w.lastAuthHashes))\n\t}\n}\n\nfunc TestScheduleProcessEventsStopsOnContextDone(t *testing.T) {\n\tw := &Watcher{\n\t\twatcher: &fsnotify.Watcher{\n\t\t\tEvents: make(chan fsnotify.Event, 1),\n\t\t\tErrors: make(chan error, 1),\n\t\t},\n\t\tconfigPath: \"config.yaml\",\n\t\tauthDir:    \"auth\",\n\t}\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tw.processEvents(ctx)\n\t\tclose(done)\n\t}()\n\n\tcancel()\n\tselect {\n\tcase <-done:\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"processEvents did not exit on context cancel\")\n\t}\n}\n\nfunc hexString(data []byte) string {\n\treturn strings.ToLower(fmt.Sprintf(\"%x\", data))\n}\n"
  },
  {
    "path": "internal/wsrelay/http.go",
    "content": "package wsrelay\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// HTTPRequest represents a proxied HTTP request delivered to websocket clients.\ntype HTTPRequest struct {\n\tMethod  string\n\tURL     string\n\tHeaders http.Header\n\tBody    []byte\n}\n\n// HTTPResponse captures the response relayed back from websocket clients.\ntype HTTPResponse struct {\n\tStatus  int\n\tHeaders http.Header\n\tBody    []byte\n}\n\n// StreamEvent represents a streaming response event from clients.\ntype StreamEvent struct {\n\tType    string\n\tPayload []byte\n\tStatus  int\n\tHeaders http.Header\n\tErr     error\n}\n\n// NonStream executes a non-streaming HTTP request using the websocket provider.\nfunc (m *Manager) NonStream(ctx context.Context, provider string, req *HTTPRequest) (*HTTPResponse, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"wsrelay: request is nil\")\n\t}\n\tmsg := Message{ID: uuid.NewString(), Type: MessageTypeHTTPReq, Payload: encodeRequest(req)}\n\trespCh, err := m.Send(ctx, provider, msg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tstreamMode bool\n\t\tstreamResp *HTTPResponse\n\t\tstreamBody bytes.Buffer\n\t)\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, ctx.Err()\n\t\tcase msg, ok := <-respCh:\n\t\t\tif !ok {\n\t\t\t\tif streamMode {\n\t\t\t\t\tif streamResp == nil {\n\t\t\t\t\t\tstreamResp = &HTTPResponse{Status: http.StatusOK, Headers: make(http.Header)}\n\t\t\t\t\t} else if streamResp.Headers == nil {\n\t\t\t\t\t\tstreamResp.Headers = make(http.Header)\n\t\t\t\t\t}\n\t\t\t\t\tstreamResp.Body = append(streamResp.Body[:0], streamBody.Bytes()...)\n\t\t\t\t\treturn streamResp, nil\n\t\t\t\t}\n\t\t\t\treturn nil, errors.New(\"wsrelay: connection closed during response\")\n\t\t\t}\n\t\t\tswitch msg.Type {\n\t\t\tcase MessageTypeHTTPResp:\n\t\t\t\tresp := decodeResponse(msg.Payload)\n\t\t\t\tif streamMode && streamBody.Len() > 0 && len(resp.Body) == 0 {\n\t\t\t\t\tresp.Body = append(resp.Body[:0], streamBody.Bytes()...)\n\t\t\t\t}\n\t\t\t\treturn resp, nil\n\t\t\tcase MessageTypeError:\n\t\t\t\treturn nil, decodeError(msg.Payload)\n\t\t\tcase MessageTypeStreamStart, MessageTypeStreamChunk:\n\t\t\t\tif msg.Type == MessageTypeStreamStart {\n\t\t\t\t\tstreamMode = true\n\t\t\t\t\tstreamResp = decodeResponse(msg.Payload)\n\t\t\t\t\tif streamResp.Headers == nil {\n\t\t\t\t\t\tstreamResp.Headers = make(http.Header)\n\t\t\t\t\t}\n\t\t\t\t\tstreamBody.Reset()\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !streamMode {\n\t\t\t\t\tstreamMode = true\n\t\t\t\t\tstreamResp = &HTTPResponse{Status: http.StatusOK, Headers: make(http.Header)}\n\t\t\t\t}\n\t\t\t\tchunk := decodeChunk(msg.Payload)\n\t\t\t\tif len(chunk) > 0 {\n\t\t\t\t\tstreamBody.Write(chunk)\n\t\t\t\t}\n\t\t\tcase MessageTypeStreamEnd:\n\t\t\t\tif !streamMode {\n\t\t\t\t\treturn &HTTPResponse{Status: http.StatusOK, Headers: make(http.Header)}, nil\n\t\t\t\t}\n\t\t\t\tif streamResp == nil {\n\t\t\t\t\tstreamResp = &HTTPResponse{Status: http.StatusOK, Headers: make(http.Header)}\n\t\t\t\t} else if streamResp.Headers == nil {\n\t\t\t\t\tstreamResp.Headers = make(http.Header)\n\t\t\t\t}\n\t\t\t\tstreamResp.Body = append(streamResp.Body[:0], streamBody.Bytes()...)\n\t\t\t\treturn streamResp, nil\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Stream executes a streaming HTTP request and returns channel with stream events.\nfunc (m *Manager) Stream(ctx context.Context, provider string, req *HTTPRequest) (<-chan StreamEvent, error) {\n\tif req == nil {\n\t\treturn nil, fmt.Errorf(\"wsrelay: request is nil\")\n\t}\n\tmsg := Message{ID: uuid.NewString(), Type: MessageTypeHTTPReq, Payload: encodeRequest(req)}\n\trespCh, err := m.Send(ctx, provider, msg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make(chan StreamEvent)\n\tgo func() {\n\t\tdefer close(out)\n\t\tsend := func(ev StreamEvent) bool {\n\t\t\tif ctx == nil {\n\t\t\t\tout <- ev\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\tcase out <- ev:\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase msg, ok := <-respCh:\n\t\t\t\tif !ok {\n\t\t\t\t\t_ = send(StreamEvent{Err: errors.New(\"wsrelay: stream closed\")})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch msg.Type {\n\t\t\t\tcase MessageTypeStreamStart:\n\t\t\t\t\tresp := decodeResponse(msg.Payload)\n\t\t\t\t\tif okSend := send(StreamEvent{Type: MessageTypeStreamStart, Status: resp.Status, Headers: resp.Headers}); !okSend {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\tcase MessageTypeStreamChunk:\n\t\t\t\t\tchunk := decodeChunk(msg.Payload)\n\t\t\t\t\tif okSend := send(StreamEvent{Type: MessageTypeStreamChunk, Payload: chunk}); !okSend {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\tcase MessageTypeStreamEnd:\n\t\t\t\t\t_ = send(StreamEvent{Type: MessageTypeStreamEnd})\n\t\t\t\t\treturn\n\t\t\t\tcase MessageTypeError:\n\t\t\t\t\t_ = send(StreamEvent{Type: MessageTypeError, Err: decodeError(msg.Payload)})\n\t\t\t\t\treturn\n\t\t\t\tcase MessageTypeHTTPResp:\n\t\t\t\t\tresp := decodeResponse(msg.Payload)\n\t\t\t\t\t_ = send(StreamEvent{Type: MessageTypeHTTPResp, Status: resp.Status, Headers: resp.Headers, Payload: resp.Body})\n\t\t\t\t\treturn\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\treturn out, nil\n}\n\nfunc encodeRequest(req *HTTPRequest) map[string]any {\n\theaders := make(map[string]any, len(req.Headers))\n\tfor key, values := range req.Headers {\n\t\tcopyValues := make([]string, len(values))\n\t\tcopy(copyValues, values)\n\t\theaders[key] = copyValues\n\t}\n\treturn map[string]any{\n\t\t\"method\":  req.Method,\n\t\t\"url\":     req.URL,\n\t\t\"headers\": headers,\n\t\t\"body\":    string(req.Body),\n\t\t\"sent_at\": time.Now().UTC().Format(time.RFC3339Nano),\n\t}\n}\n\nfunc decodeResponse(payload map[string]any) *HTTPResponse {\n\tif payload == nil {\n\t\treturn &HTTPResponse{Status: http.StatusBadGateway, Headers: make(http.Header)}\n\t}\n\tresp := &HTTPResponse{Status: http.StatusOK, Headers: make(http.Header)}\n\tif status, ok := payload[\"status\"].(float64); ok {\n\t\tresp.Status = int(status)\n\t}\n\tif headers, ok := payload[\"headers\"].(map[string]any); ok {\n\t\tfor key, raw := range headers {\n\t\t\tswitch v := raw.(type) {\n\t\t\tcase []any:\n\t\t\t\tfor _, item := range v {\n\t\t\t\t\tif str, ok := item.(string); ok {\n\t\t\t\t\t\tresp.Headers.Add(key, str)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase []string:\n\t\t\t\tfor _, str := range v {\n\t\t\t\t\tresp.Headers.Add(key, str)\n\t\t\t\t}\n\t\t\tcase string:\n\t\t\t\tresp.Headers.Set(key, v)\n\t\t\t}\n\t\t}\n\t}\n\tif body, ok := payload[\"body\"].(string); ok {\n\t\tresp.Body = []byte(body)\n\t}\n\treturn resp\n}\n\nfunc decodeChunk(payload map[string]any) []byte {\n\tif payload == nil {\n\t\treturn nil\n\t}\n\tif data, ok := payload[\"data\"].(string); ok {\n\t\treturn []byte(data)\n\t}\n\treturn nil\n}\n\nfunc decodeError(payload map[string]any) error {\n\tif payload == nil {\n\t\treturn errors.New(\"wsrelay: unknown error\")\n\t}\n\tmessage, _ := payload[\"error\"].(string)\n\tstatus := 0\n\tif v, ok := payload[\"status\"].(float64); ok {\n\t\tstatus = int(v)\n\t}\n\tif message == \"\" {\n\t\tmessage = \"wsrelay: upstream error\"\n\t}\n\treturn fmt.Errorf(\"%s (status=%d)\", message, status)\n}\n"
  },
  {
    "path": "internal/wsrelay/manager.go",
    "content": "package wsrelay\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\n// Manager exposes a websocket endpoint that proxies Gemini requests to\n// connected clients.\ntype Manager struct {\n\tpath      string\n\tupgrader  websocket.Upgrader\n\tsessions  map[string]*session\n\tsessMutex sync.RWMutex\n\n\tproviderFactory func(*http.Request) (string, error)\n\tonConnected     func(string)\n\tonDisconnected  func(string, error)\n\n\tlogDebugf func(string, ...any)\n\tlogInfof  func(string, ...any)\n\tlogWarnf  func(string, ...any)\n}\n\n// Options configures a Manager instance.\ntype Options struct {\n\tPath            string\n\tProviderFactory func(*http.Request) (string, error)\n\tOnConnected     func(string)\n\tOnDisconnected  func(string, error)\n\tLogDebugf       func(string, ...any)\n\tLogInfof        func(string, ...any)\n\tLogWarnf        func(string, ...any)\n}\n\n// NewManager builds a websocket relay manager with the supplied options.\nfunc NewManager(opts Options) *Manager {\n\tpath := strings.TrimSpace(opts.Path)\n\tif path == \"\" {\n\t\tpath = \"/v1/ws\"\n\t}\n\tif !strings.HasPrefix(path, \"/\") {\n\t\tpath = \"/\" + path\n\t}\n\tmgr := &Manager{\n\t\tpath:     path,\n\t\tsessions: make(map[string]*session),\n\t\tupgrader: websocket.Upgrader{\n\t\t\tReadBufferSize:  1024,\n\t\t\tWriteBufferSize: 1024,\n\t\t\tCheckOrigin: func(r *http.Request) bool {\n\t\t\t\treturn true\n\t\t\t},\n\t\t},\n\t\tproviderFactory: opts.ProviderFactory,\n\t\tonConnected:     opts.OnConnected,\n\t\tonDisconnected:  opts.OnDisconnected,\n\t\tlogDebugf:       opts.LogDebugf,\n\t\tlogInfof:        opts.LogInfof,\n\t\tlogWarnf:        opts.LogWarnf,\n\t}\n\tif mgr.logDebugf == nil {\n\t\tmgr.logDebugf = func(string, ...any) {}\n\t}\n\tif mgr.logInfof == nil {\n\t\tmgr.logInfof = func(string, ...any) {}\n\t}\n\tif mgr.logWarnf == nil {\n\t\tmgr.logWarnf = func(s string, args ...any) { fmt.Printf(s+\"\\n\", args...) }\n\t}\n\treturn mgr\n}\n\n// Path returns the HTTP path the manager expects for websocket upgrades.\nfunc (m *Manager) Path() string {\n\tif m == nil {\n\t\treturn \"/v1/ws\"\n\t}\n\treturn m.path\n}\n\n// Handler exposes an http.Handler that upgrades connections to websocket sessions.\nfunc (m *Manager) Handler() http.Handler {\n\treturn http.HandlerFunc(m.handleWebsocket)\n}\n\n// Stop gracefully closes all active websocket sessions.\nfunc (m *Manager) Stop(_ context.Context) error {\n\tm.sessMutex.Lock()\n\tsessions := make([]*session, 0, len(m.sessions))\n\tfor _, sess := range m.sessions {\n\t\tsessions = append(sessions, sess)\n\t}\n\tm.sessions = make(map[string]*session)\n\tm.sessMutex.Unlock()\n\n\tfor _, sess := range sessions {\n\t\tif sess != nil {\n\t\t\tsess.cleanup(errors.New(\"wsrelay: manager stopped\"))\n\t\t}\n\t}\n\treturn nil\n}\n\n// handleWebsocket upgrades the connection and wires the session into the pool.\nfunc (m *Manager) handleWebsocket(w http.ResponseWriter, r *http.Request) {\n\texpectedPath := m.Path()\n\tif expectedPath != \"\" && r.URL != nil && r.URL.Path != expectedPath {\n\t\thttp.NotFound(w, r)\n\t\treturn\n\t}\n\tif !strings.EqualFold(r.Method, http.MethodGet) {\n\t\tw.Header().Set(\"Allow\", http.MethodGet)\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tconn, err := m.upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\tm.logWarnf(\"wsrelay: upgrade failed: %v\", err)\n\t\treturn\n\t}\n\ts := newSession(conn, m, randomProviderName())\n\tif m.providerFactory != nil {\n\t\tname, err := m.providerFactory(r)\n\t\tif err != nil {\n\t\t\ts.cleanup(err)\n\t\t\treturn\n\t\t}\n\t\tif strings.TrimSpace(name) != \"\" {\n\t\t\ts.provider = strings.ToLower(name)\n\t\t}\n\t}\n\tif s.provider == \"\" {\n\t\ts.provider = strings.ToLower(s.id)\n\t}\n\tm.sessMutex.Lock()\n\tvar replaced *session\n\tif existing, ok := m.sessions[s.provider]; ok {\n\t\treplaced = existing\n\t}\n\tm.sessions[s.provider] = s\n\tm.sessMutex.Unlock()\n\n\tif replaced != nil {\n\t\treplaced.cleanup(errors.New(\"replaced by new connection\"))\n\t}\n\tif m.onConnected != nil {\n\t\tm.onConnected(s.provider)\n\t}\n\n\tgo s.run(context.Background())\n}\n\n// Send forwards the message to the specific provider connection and returns a channel\n// yielding response messages.\nfunc (m *Manager) Send(ctx context.Context, provider string, msg Message) (<-chan Message, error) {\n\ts := m.session(provider)\n\tif s == nil {\n\t\treturn nil, fmt.Errorf(\"wsrelay: provider %s not connected\", provider)\n\t}\n\treturn s.request(ctx, msg)\n}\n\nfunc (m *Manager) session(provider string) *session {\n\tkey := strings.ToLower(strings.TrimSpace(provider))\n\tm.sessMutex.RLock()\n\ts := m.sessions[key]\n\tm.sessMutex.RUnlock()\n\treturn s\n}\n\nfunc (m *Manager) handleSessionClosed(s *session, cause error) {\n\tif s == nil {\n\t\treturn\n\t}\n\tkey := strings.ToLower(strings.TrimSpace(s.provider))\n\tm.sessMutex.Lock()\n\tif cur, ok := m.sessions[key]; ok && cur == s {\n\t\tdelete(m.sessions, key)\n\t}\n\tm.sessMutex.Unlock()\n\tif m.onDisconnected != nil {\n\t\tm.onDisconnected(s.provider, cause)\n\t}\n}\n\nfunc randomProviderName() string {\n\tconst alphabet = \"abcdefghijklmnopqrstuvwxyz0123456789\"\n\tbuf := make([]byte, 16)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn fmt.Sprintf(\"aistudio-%x\", time.Now().UnixNano())\n\t}\n\tfor i := range buf {\n\t\tbuf[i] = alphabet[int(buf[i])%len(alphabet)]\n\t}\n\treturn \"aistudio-\" + string(buf)\n}\n"
  },
  {
    "path": "internal/wsrelay/message.go",
    "content": "package wsrelay\n\n// Message represents the JSON payload exchanged with websocket clients.\ntype Message struct {\n\tID      string         `json:\"id\"`\n\tType    string         `json:\"type\"`\n\tPayload map[string]any `json:\"payload,omitempty\"`\n}\n\nconst (\n\t// MessageTypeHTTPReq identifies an HTTP-style request envelope.\n\tMessageTypeHTTPReq = \"http_request\"\n\t// MessageTypeHTTPResp identifies a non-streaming HTTP response envelope.\n\tMessageTypeHTTPResp = \"http_response\"\n\t// MessageTypeStreamStart marks the beginning of a streaming response.\n\tMessageTypeStreamStart = \"stream_start\"\n\t// MessageTypeStreamChunk carries a streaming response chunk.\n\tMessageTypeStreamChunk = \"stream_chunk\"\n\t// MessageTypeStreamEnd marks the completion of a streaming response.\n\tMessageTypeStreamEnd = \"stream_end\"\n\t// MessageTypeError carries an error response.\n\tMessageTypeError = \"error\"\n\t// MessageTypePing represents ping messages from clients.\n\tMessageTypePing = \"ping\"\n\t// MessageTypePong represents pong responses back to clients.\n\tMessageTypePong = \"pong\"\n)\n"
  },
  {
    "path": "internal/wsrelay/session.go",
    "content": "package wsrelay\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\nconst (\n\treadTimeout          = 60 * time.Second\n\twriteTimeout         = 10 * time.Second\n\tmaxInboundMessageLen = 64 << 20 // 64 MiB\n\theartbeatInterval    = 30 * time.Second\n)\n\nvar errClosed = errors.New(\"websocket session closed\")\n\ntype pendingRequest struct {\n\tch        chan Message\n\tcloseOnce sync.Once\n}\n\nfunc (pr *pendingRequest) close() {\n\tif pr == nil {\n\t\treturn\n\t}\n\tpr.closeOnce.Do(func() {\n\t\tclose(pr.ch)\n\t})\n}\n\ntype session struct {\n\tconn       *websocket.Conn\n\tmanager    *Manager\n\tprovider   string\n\tid         string\n\tclosed     chan struct{}\n\tcloseOnce  sync.Once\n\twriteMutex sync.Mutex\n\tpending    sync.Map // map[string]*pendingRequest\n}\n\nfunc newSession(conn *websocket.Conn, mgr *Manager, id string) *session {\n\ts := &session{\n\t\tconn:     conn,\n\t\tmanager:  mgr,\n\t\tprovider: \"\",\n\t\tid:       id,\n\t\tclosed:   make(chan struct{}),\n\t}\n\tconn.SetReadLimit(maxInboundMessageLen)\n\tconn.SetReadDeadline(time.Now().Add(readTimeout))\n\tconn.SetPongHandler(func(string) error {\n\t\tconn.SetReadDeadline(time.Now().Add(readTimeout))\n\t\treturn nil\n\t})\n\ts.startHeartbeat()\n\treturn s\n}\n\nfunc (s *session) startHeartbeat() {\n\tif s == nil || s.conn == nil {\n\t\treturn\n\t}\n\tticker := time.NewTicker(heartbeatInterval)\n\tgo func() {\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-s.closed:\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\ts.writeMutex.Lock()\n\t\t\t\terr := s.conn.WriteControl(websocket.PingMessage, []byte(\"ping\"), time.Now().Add(writeTimeout))\n\t\t\t\ts.writeMutex.Unlock()\n\t\t\t\tif err != nil {\n\t\t\t\t\ts.cleanup(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n}\n\nfunc (s *session) run(ctx context.Context) {\n\tdefer s.cleanup(errClosed)\n\tfor {\n\t\tvar msg Message\n\t\tif err := s.conn.ReadJSON(&msg); err != nil {\n\t\t\ts.cleanup(err)\n\t\t\treturn\n\t\t}\n\t\ts.dispatch(msg)\n\t}\n}\n\nfunc (s *session) dispatch(msg Message) {\n\tif msg.Type == MessageTypePing {\n\t\t_ = s.send(context.Background(), Message{ID: msg.ID, Type: MessageTypePong})\n\t\treturn\n\t}\n\tif value, ok := s.pending.Load(msg.ID); ok {\n\t\treq := value.(*pendingRequest)\n\t\tselect {\n\t\tcase req.ch <- msg:\n\t\tdefault:\n\t\t}\n\t\tif msg.Type == MessageTypeHTTPResp || msg.Type == MessageTypeError || msg.Type == MessageTypeStreamEnd {\n\t\t\tif actual, loaded := s.pending.LoadAndDelete(msg.ID); loaded {\n\t\t\t\tactual.(*pendingRequest).close()\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tif msg.Type == MessageTypeHTTPResp || msg.Type == MessageTypeError || msg.Type == MessageTypeStreamEnd {\n\t\ts.manager.logDebugf(\"wsrelay: received terminal message for unknown id %s (provider=%s)\", msg.ID, s.provider)\n\t}\n}\n\nfunc (s *session) send(ctx context.Context, msg Message) error {\n\tselect {\n\tcase <-s.closed:\n\t\treturn errClosed\n\tdefault:\n\t}\n\ts.writeMutex.Lock()\n\tdefer s.writeMutex.Unlock()\n\tif err := s.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {\n\t\treturn fmt.Errorf(\"set write deadline: %w\", err)\n\t}\n\tif err := s.conn.WriteJSON(msg); err != nil {\n\t\treturn fmt.Errorf(\"write json: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *session) request(ctx context.Context, msg Message) (<-chan Message, error) {\n\tif msg.ID == \"\" {\n\t\treturn nil, fmt.Errorf(\"wsrelay: message id is required\")\n\t}\n\tif _, loaded := s.pending.LoadOrStore(msg.ID, &pendingRequest{ch: make(chan Message, 8)}); loaded {\n\t\treturn nil, fmt.Errorf(\"wsrelay: duplicate message id %s\", msg.ID)\n\t}\n\tvalue, _ := s.pending.Load(msg.ID)\n\treq := value.(*pendingRequest)\n\tif err := s.send(ctx, msg); err != nil {\n\t\tif actual, loaded := s.pending.LoadAndDelete(msg.ID); loaded {\n\t\t\treq := actual.(*pendingRequest)\n\t\t\treq.close()\n\t\t}\n\t\treturn nil, err\n\t}\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tif actual, loaded := s.pending.LoadAndDelete(msg.ID); loaded {\n\t\t\t\tactual.(*pendingRequest).close()\n\t\t\t}\n\t\tcase <-s.closed:\n\t\t}\n\t}()\n\treturn req.ch, nil\n}\n\nfunc (s *session) cleanup(cause error) {\n\ts.closeOnce.Do(func() {\n\t\tclose(s.closed)\n\t\ts.pending.Range(func(key, value any) bool {\n\t\t\treq := value.(*pendingRequest)\n\t\t\tmsg := Message{ID: key.(string), Type: MessageTypeError, Payload: map[string]any{\"error\": cause.Error()}}\n\t\t\tselect {\n\t\t\tcase req.ch <- msg:\n\t\t\tdefault:\n\t\t\t}\n\t\t\treq.close()\n\t\t\treturn true\n\t\t})\n\t\ts.pending = sync.Map{}\n\t\t_ = s.conn.Close()\n\t\tif s.manager != nil {\n\t\t\ts.manager.handleSessionClosed(s, cause)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "sdk/access/errors.go",
    "content": "package access\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// AuthErrorCode classifies authentication failures.\ntype AuthErrorCode string\n\nconst (\n\tAuthErrorCodeNoCredentials     AuthErrorCode = \"no_credentials\"\n\tAuthErrorCodeInvalidCredential AuthErrorCode = \"invalid_credential\"\n\tAuthErrorCodeNotHandled        AuthErrorCode = \"not_handled\"\n\tAuthErrorCodeInternal          AuthErrorCode = \"internal_error\"\n)\n\n// AuthError carries authentication failure details and HTTP status.\ntype AuthError struct {\n\tCode       AuthErrorCode\n\tMessage    string\n\tStatusCode int\n\tCause      error\n}\n\nfunc (e *AuthError) Error() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tmessage := strings.TrimSpace(e.Message)\n\tif message == \"\" {\n\t\tmessage = \"authentication error\"\n\t}\n\tif e.Cause != nil {\n\t\treturn fmt.Sprintf(\"%s: %v\", message, e.Cause)\n\t}\n\treturn message\n}\n\nfunc (e *AuthError) Unwrap() error {\n\tif e == nil {\n\t\treturn nil\n\t}\n\treturn e.Cause\n}\n\n// HTTPStatusCode returns a safe fallback for missing status codes.\nfunc (e *AuthError) HTTPStatusCode() int {\n\tif e == nil || e.StatusCode <= 0 {\n\t\treturn http.StatusInternalServerError\n\t}\n\treturn e.StatusCode\n}\n\nfunc newAuthError(code AuthErrorCode, message string, statusCode int, cause error) *AuthError {\n\treturn &AuthError{\n\t\tCode:       code,\n\t\tMessage:    message,\n\t\tStatusCode: statusCode,\n\t\tCause:      cause,\n\t}\n}\n\nfunc NewNoCredentialsError() *AuthError {\n\treturn newAuthError(AuthErrorCodeNoCredentials, \"Missing API key\", http.StatusUnauthorized, nil)\n}\n\nfunc NewInvalidCredentialError() *AuthError {\n\treturn newAuthError(AuthErrorCodeInvalidCredential, \"Invalid API key\", http.StatusUnauthorized, nil)\n}\n\nfunc NewNotHandledError() *AuthError {\n\treturn newAuthError(AuthErrorCodeNotHandled, \"authentication provider did not handle request\", 0, nil)\n}\n\nfunc NewInternalAuthError(message string, cause error) *AuthError {\n\tnormalizedMessage := strings.TrimSpace(message)\n\tif normalizedMessage == \"\" {\n\t\tnormalizedMessage = \"Authentication service error\"\n\t}\n\treturn newAuthError(AuthErrorCodeInternal, normalizedMessage, http.StatusInternalServerError, cause)\n}\n\nfunc IsAuthErrorCode(authErr *AuthError, code AuthErrorCode) bool {\n\tif authErr == nil {\n\t\treturn false\n\t}\n\treturn authErr.Code == code\n}\n"
  },
  {
    "path": "sdk/access/manager.go",
    "content": "package access\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n)\n\n// Manager coordinates authentication providers.\ntype Manager struct {\n\tmu        sync.RWMutex\n\tproviders []Provider\n}\n\n// NewManager constructs an empty manager.\nfunc NewManager() *Manager {\n\treturn &Manager{}\n}\n\n// SetProviders replaces the active provider list.\nfunc (m *Manager) SetProviders(providers []Provider) {\n\tif m == nil {\n\t\treturn\n\t}\n\tcloned := make([]Provider, len(providers))\n\tcopy(cloned, providers)\n\tm.mu.Lock()\n\tm.providers = cloned\n\tm.mu.Unlock()\n}\n\n// Providers returns a snapshot of the active providers.\nfunc (m *Manager) Providers() []Provider {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tsnapshot := make([]Provider, len(m.providers))\n\tcopy(snapshot, m.providers)\n\treturn snapshot\n}\n\n// Authenticate evaluates providers until one succeeds.\nfunc (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError) {\n\tif m == nil {\n\t\treturn nil, nil\n\t}\n\tproviders := m.Providers()\n\tif len(providers) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tvar (\n\t\tmissing bool\n\t\tinvalid bool\n\t)\n\n\tfor _, provider := range providers {\n\t\tif provider == nil {\n\t\t\tcontinue\n\t\t}\n\t\tres, authErr := provider.Authenticate(ctx, r)\n\t\tif authErr == nil {\n\t\t\treturn res, nil\n\t\t}\n\t\tif IsAuthErrorCode(authErr, AuthErrorCodeNotHandled) {\n\t\t\tcontinue\n\t\t}\n\t\tif IsAuthErrorCode(authErr, AuthErrorCodeNoCredentials) {\n\t\t\tmissing = true\n\t\t\tcontinue\n\t\t}\n\t\tif IsAuthErrorCode(authErr, AuthErrorCodeInvalidCredential) {\n\t\t\tinvalid = true\n\t\t\tcontinue\n\t\t}\n\t\treturn nil, authErr\n\t}\n\n\tif invalid {\n\t\treturn nil, NewInvalidCredentialError()\n\t}\n\tif missing {\n\t\treturn nil, NewNoCredentialsError()\n\t}\n\treturn nil, NewNoCredentialsError()\n}\n"
  },
  {
    "path": "sdk/access/registry.go",
    "content": "package access\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n)\n\n// Provider validates credentials for incoming requests.\ntype Provider interface {\n\tIdentifier() string\n\tAuthenticate(ctx context.Context, r *http.Request) (*Result, *AuthError)\n}\n\n// Result conveys authentication outcome.\ntype Result struct {\n\tProvider  string\n\tPrincipal string\n\tMetadata  map[string]string\n}\n\nvar (\n\tregistryMu sync.RWMutex\n\tregistry   = make(map[string]Provider)\n\torder      []string\n)\n\n// RegisterProvider registers a pre-built provider instance for a given type identifier.\nfunc RegisterProvider(typ string, provider Provider) {\n\tnormalizedType := strings.TrimSpace(typ)\n\tif normalizedType == \"\" || provider == nil {\n\t\treturn\n\t}\n\n\tregistryMu.Lock()\n\tif _, exists := registry[normalizedType]; !exists {\n\t\torder = append(order, normalizedType)\n\t}\n\tregistry[normalizedType] = provider\n\tregistryMu.Unlock()\n}\n\n// UnregisterProvider removes a provider by type identifier.\nfunc UnregisterProvider(typ string) {\n\tnormalizedType := strings.TrimSpace(typ)\n\tif normalizedType == \"\" {\n\t\treturn\n\t}\n\tregistryMu.Lock()\n\tif _, exists := registry[normalizedType]; !exists {\n\t\tregistryMu.Unlock()\n\t\treturn\n\t}\n\tdelete(registry, normalizedType)\n\tfor index := range order {\n\t\tif order[index] != normalizedType {\n\t\t\tcontinue\n\t\t}\n\t\torder = append(order[:index], order[index+1:]...)\n\t\tbreak\n\t}\n\tregistryMu.Unlock()\n}\n\n// RegisteredProviders returns the global provider instances in registration order.\nfunc RegisteredProviders() []Provider {\n\tregistryMu.RLock()\n\tif len(order) == 0 {\n\t\tregistryMu.RUnlock()\n\t\treturn nil\n\t}\n\tproviders := make([]Provider, 0, len(order))\n\tfor _, providerType := range order {\n\t\tprovider, exists := registry[providerType]\n\t\tif !exists || provider == nil {\n\t\t\tcontinue\n\t\t}\n\t\tproviders = append(providers, provider)\n\t}\n\tregistryMu.RUnlock()\n\treturn providers\n}\n"
  },
  {
    "path": "sdk/access/types.go",
    "content": "package access\n\n// AccessConfig groups request authentication providers.\ntype AccessConfig struct {\n\t// Providers lists configured authentication providers.\n\tProviders []AccessProvider `yaml:\"providers,omitempty\" json:\"providers,omitempty\"`\n}\n\n// AccessProvider describes a request authentication provider entry.\ntype AccessProvider struct {\n\t// Name is the instance identifier for the provider.\n\tName string `yaml:\"name\" json:\"name\"`\n\n\t// Type selects the provider implementation registered via the SDK.\n\tType string `yaml:\"type\" json:\"type\"`\n\n\t// SDK optionally names a third-party SDK module providing this provider.\n\tSDK string `yaml:\"sdk,omitempty\" json:\"sdk,omitempty\"`\n\n\t// APIKeys lists inline keys for providers that require them.\n\tAPIKeys []string `yaml:\"api-keys,omitempty\" json:\"api-keys,omitempty\"`\n\n\t// Config passes provider-specific options to the implementation.\n\tConfig map[string]any `yaml:\"config,omitempty\" json:\"config,omitempty\"`\n}\n\nconst (\n\t// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.\n\tAccessProviderTypeConfigAPIKey = \"config-api-key\"\n\n\t// DefaultAccessProviderName is applied when no provider name is supplied.\n\tDefaultAccessProviderName = \"config-inline\"\n)\n\n// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.\n// It returns nil when no keys are supplied.\nfunc MakeInlineAPIKeyProvider(keys []string) *AccessProvider {\n\tif len(keys) == 0 {\n\t\treturn nil\n\t}\n\tprovider := &AccessProvider{\n\t\tName:    DefaultAccessProviderName,\n\t\tType:    AccessProviderTypeConfigAPIKey,\n\t\tAPIKeys: append([]string(nil), keys...),\n\t}\n\treturn provider\n}\n"
  },
  {
    "path": "sdk/api/handlers/claude/code_handlers.go",
    "content": "// Package claude provides HTTP handlers for Claude API code-related functionality.\n// This package implements Claude-compatible streaming chat completions with sophisticated\n// client rotation and quota management systems to ensure high availability and optimal\n// resource utilization across multiple backend clients. It handles request translation\n// between Claude API format and the underlying Gemini backend, providing seamless\n// API compatibility while maintaining robust error handling and connection management.\npackage claude\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\n// ClaudeCodeAPIHandler contains the handlers for Claude API endpoints.\n// It holds a pool of clients to interact with the backend service.\ntype ClaudeCodeAPIHandler struct {\n\t*handlers.BaseAPIHandler\n}\n\n// NewClaudeCodeAPIHandler creates a new Claude API handlers instance.\n// It takes an BaseAPIHandler instance as input and returns a ClaudeCodeAPIHandler.\n//\n// Parameters:\n//   - apiHandlers: The base API handler instance.\n//\n// Returns:\n//   - *ClaudeCodeAPIHandler: A new Claude code API handler instance.\nfunc NewClaudeCodeAPIHandler(apiHandlers *handlers.BaseAPIHandler) *ClaudeCodeAPIHandler {\n\treturn &ClaudeCodeAPIHandler{\n\t\tBaseAPIHandler: apiHandlers,\n\t}\n}\n\n// HandlerType returns the identifier for this handler implementation.\nfunc (h *ClaudeCodeAPIHandler) HandlerType() string {\n\treturn Claude\n}\n\n// Models returns a list of models supported by this handler.\nfunc (h *ClaudeCodeAPIHandler) Models() []map[string]any {\n\t// Get dynamic models from the global registry\n\tmodelRegistry := registry.GetGlobalRegistry()\n\treturn modelRegistry.GetAvailableModels(\"claude\")\n}\n\n// ClaudeMessages handles Claude-compatible streaming chat completions.\n// This function implements a sophisticated client rotation and quota management system\n// to ensure high availability and optimal resource utilization across multiple backend clients.\n//\n// Parameters:\n//   - c: The Gin context for the request.\nfunc (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) {\n\t// Extract raw JSON data from the incoming request\n\trawJSON, err := c.GetRawData()\n\t// If data retrieval fails, return a 400 Bad Request error.\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if the client requested a streaming response.\n\tstreamResult := gjson.GetBytes(rawJSON, \"stream\")\n\tif !streamResult.Exists() || streamResult.Type == gjson.False {\n\t\th.handleNonStreamingResponse(c, rawJSON)\n\t} else {\n\t\th.handleStreamingResponse(c, rawJSON)\n\t}\n}\n\n// ClaudeMessages handles Claude-compatible streaming chat completions.\n// This function implements a sophisticated client rotation and quota management system\n// to ensure high availability and optimal resource utilization across multiple backend clients.\n//\n// Parameters:\n//   - c: The Gin context for the request.\nfunc (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) {\n\t// Extract raw JSON data from the incoming request\n\trawJSON, err := c.GetRawData()\n\t// If data retrieval fails, return a 400 Bad Request error.\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tc.Header(\"Content-Type\", \"application/json\")\n\n\talt := h.GetAlt(c)\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\n\tresp, upstreamHeaders, errMsg := h.ExecuteCountWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// ClaudeModels handles the Claude models listing endpoint.\n// It returns a JSON response containing available Claude models and their specifications.\n//\n// Parameters:\n//   - c: The Gin context for the request.\nfunc (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) {\n\tmodels := h.Models()\n\tfirstID := \"\"\n\tlastID := \"\"\n\tif len(models) > 0 {\n\t\tif id, ok := models[0][\"id\"].(string); ok {\n\t\t\tfirstID = id\n\t\t}\n\t\tif id, ok := models[len(models)-1][\"id\"].(string); ok {\n\t\t\tlastID = id\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"data\":     models,\n\t\t\"has_more\": false,\n\t\t\"first_id\": firstID,\n\t\t\"last_id\":  lastID,\n\t})\n}\n\n// handleNonStreamingResponse handles non-streaming content generation requests for Claude models.\n// This function processes the request synchronously and returns the complete generated\n// response in a single API call. It supports various generation parameters and\n// response formats.\n//\n// Parameters:\n//   - c: The Gin context for the request\n//   - modelName: The name of the Gemini model to use for content generation\n//   - rawJSON: The raw JSON request body containing generation parameters and content\nfunc (h *ClaudeCodeAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\talt := h.GetAlt(c)\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tstopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)\n\tstopKeepAlive()\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\n\t// Decompress gzipped responses - Claude API sometimes returns gzip without Content-Encoding header\n\t// This fixes title generation and other non-streaming responses that arrive compressed\n\tif len(resp) >= 2 && resp[0] == 0x1f && resp[1] == 0x8b {\n\t\tgzReader, errGzip := gzip.NewReader(bytes.NewReader(resp))\n\t\tif errGzip != nil {\n\t\t\tlog.Warnf(\"failed to decompress gzipped Claude response: %v\", errGzip)\n\t\t} else {\n\t\t\tdefer func() {\n\t\t\t\tif errClose := gzReader.Close(); errClose != nil {\n\t\t\t\t\tlog.Warnf(\"failed to close Claude gzip reader: %v\", errClose)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tdecompressed, errRead := io.ReadAll(gzReader)\n\t\t\tif errRead != nil {\n\t\t\t\tlog.Warnf(\"failed to read decompressed Claude response: %v\", errRead)\n\t\t\t} else {\n\t\t\t\tresp = decompressed\n\t\t\t}\n\t\t}\n\t}\n\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// handleStreamingResponse streams Claude-compatible responses backed by Gemini.\n// It sets up SSE, selects a backend client with rotation/quota logic,\n// forwards chunks, and translates them to Claude CLI format.\n//\n// Parameters:\n//   - c: The Gin context for the request.\n//   - rawJSON: The raw JSON request body.\nfunc (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {\n\t// Get the http.Flusher interface to manually flush the response.\n\t// This is crucial for streaming as it allows immediate sending of data chunks\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\n\t// Create a cancellable context for the backend client request\n\t// This allows proper cleanup and cancellation of ongoing requests\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"\")\n\tsetSSEHeaders := func() {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Peek at the first chunk to determine success or failure before setting headers\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcliCancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase errMsg, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\t// Err channel closed cleanly; wait for data channel.\n\t\t\t\terrChan = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Upstream failed immediately. Return proper error status and JSON.\n\t\t\th.WriteErrorResponse(c, errMsg)\n\t\t\tif errMsg != nil {\n\t\t\t\tcliCancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcliCancel(nil)\n\t\t\t}\n\t\t\treturn\n\t\tcase chunk, ok := <-dataChan:\n\t\t\tif !ok {\n\t\t\t\t// Stream closed without data? Send DONE or just headers.\n\t\t\t\tsetSSEHeaders()\n\t\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t\t\t\tflusher.Flush()\n\t\t\t\tcliCancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Success! Set headers now.\n\t\t\tsetSSEHeaders()\n\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\n\t\t\t// Write the first chunk\n\t\t\tif len(chunk) > 0 {\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\n\t\t\t// Continue streaming the rest\n\t\t\th.forwardClaudeStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {\n\th.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{\n\t\tWriteChunk: func(chunk []byte) {\n\t\t\tif len(chunk) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t},\n\t\tWriteTerminalError: func(errMsg *interfaces.ErrorMessage) {\n\t\t\tif errMsg == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatus := http.StatusInternalServerError\n\t\t\tif errMsg.StatusCode > 0 {\n\t\t\t\tstatus = errMsg.StatusCode\n\t\t\t}\n\t\t\tc.Status(status)\n\n\t\t\terrorBytes, _ := json.Marshal(h.toClaudeError(errMsg))\n\t\t\t_, _ = fmt.Fprintf(c.Writer, \"event: error\\ndata: %s\\n\\n\", errorBytes)\n\t\t},\n\t})\n}\n\ntype claudeErrorDetail struct {\n\tType    string `json:\"type\"`\n\tMessage string `json:\"message\"`\n}\n\ntype claudeErrorResponse struct {\n\tType  string            `json:\"type\"`\n\tError claudeErrorDetail `json:\"error\"`\n}\n\nfunc (h *ClaudeCodeAPIHandler) toClaudeError(msg *interfaces.ErrorMessage) claudeErrorResponse {\n\treturn claudeErrorResponse{\n\t\tType: \"error\",\n\t\tError: claudeErrorDetail{\n\t\t\tType:    \"api_error\",\n\t\t\tMessage: msg.Error.Error(),\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/gemini/gemini-cli_handlers.go",
    "content": "// Package gemini provides HTTP handlers for Gemini CLI API functionality.\n// This package implements handlers that process CLI-specific requests for Gemini API operations,\n// including content generation and streaming content generation endpoints.\n// The handlers restrict access to localhost only and manage communication with the backend service.\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n)\n\n// GeminiCLIAPIHandler contains the handlers for Gemini CLI API endpoints.\n// It holds a pool of clients to interact with the backend service.\ntype GeminiCLIAPIHandler struct {\n\t*handlers.BaseAPIHandler\n}\n\n// NewGeminiCLIAPIHandler creates a new Gemini CLI API handlers instance.\n// It takes an BaseAPIHandler instance as input and returns a GeminiCLIAPIHandler.\nfunc NewGeminiCLIAPIHandler(apiHandlers *handlers.BaseAPIHandler) *GeminiCLIAPIHandler {\n\treturn &GeminiCLIAPIHandler{\n\t\tBaseAPIHandler: apiHandlers,\n\t}\n}\n\n// HandlerType returns the type of this handler.\nfunc (h *GeminiCLIAPIHandler) HandlerType() string {\n\treturn GeminiCLI\n}\n\n// Models returns a list of models supported by this handler.\nfunc (h *GeminiCLIAPIHandler) Models() []map[string]any {\n\treturn make([]map[string]any, 0)\n}\n\n// CLIHandler handles CLI-specific requests for Gemini API operations.\n// It restricts access to localhost only and routes requests to appropriate internal handlers.\nfunc (h *GeminiCLIAPIHandler) CLIHandler(c *gin.Context) {\n\tif !strings.HasPrefix(c.Request.RemoteAddr, \"127.0.0.1:\") {\n\t\tc.JSON(http.StatusForbidden, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"CLI reply only allow local access\",\n\t\t\t\tType:    \"forbidden\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\trawJSON, _ := c.GetRawData()\n\trequestRawURI := c.Request.URL.Path\n\n\tif requestRawURI == \"/v1internal:generateContent\" {\n\t\th.handleInternalGenerateContent(c, rawJSON)\n\t} else if requestRawURI == \"/v1internal:streamGenerateContent\" {\n\t\th.handleInternalStreamGenerateContent(c, rawJSON)\n\t} else {\n\t\treqBody := bytes.NewBuffer(rawJSON)\n\t\treq, err := http.NewRequest(\"POST\", fmt.Sprintf(\"https://cloudcode-pa.googleapis.com%s\", c.Request.URL.RequestURI()), reqBody)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\t\tError: handlers.ErrorDetail{\n\t\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tfor key, value := range c.Request.Header {\n\t\t\treq.Header[key] = value\n\t\t}\n\n\t\thttpClient := util.SetProxy(h.Cfg, &http.Client{})\n\n\t\tresp, err := httpClient.Do(req)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\t\tError: handlers.ErrorDetail{\n\t\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t\tdefer func() {\n\t\t\t\tif err = resp.Body.Close(); err != nil {\n\t\t\t\t\tlog.Printf(\"warn: failed to close response body: %v\", err)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\n\t\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\t\tError: handlers.ErrorDetail{\n\t\t\t\t\tMessage: string(bodyBytes),\n\t\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tdefer func() {\n\t\t\t_ = resp.Body.Close()\n\t\t}()\n\n\t\tfor key, value := range resp.Header {\n\t\t\tc.Header(key, value[0])\n\t\t}\n\t\toutput, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to read response body: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tc.Set(\"API_RESPONSE_TIMESTAMP\", time.Now())\n\t\t_, _ = c.Writer.Write(output)\n\t\tc.Set(\"API_RESPONSE\", output)\n\t}\n}\n\n// handleInternalStreamGenerateContent handles streaming content generation requests.\n// It sets up a server-sent event stream and forwards the request to the backend client.\n// The function continuously proxies response chunks from the backend to the client.\nfunc (h *GeminiCLIAPIHandler) handleInternalStreamGenerateContent(c *gin.Context, rawJSON []byte) {\n\talt := h.GetAlt(c)\n\n\tif alt == \"\" {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Get the http.Flusher interface to manually flush the response.\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tmodelResult := gjson.GetBytes(rawJSON, \"model\")\n\tmodelName := modelResult.String()\n\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"\")\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\th.forwardCLIStream(c, flusher, \"\", func(err error) { cliCancel(err) }, dataChan, errChan)\n\treturn\n}\n\n// handleInternalGenerateContent handles non-streaming content generation requests.\n// It sends a request to the backend client and proxies the entire response back to the client at once.\nfunc (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\tmodelResult := gjson.GetBytes(rawJSON, \"model\")\n\tmodelName := modelResult.String()\n\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"\")\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\nfunc (h *GeminiCLIAPIHandler) forwardCLIStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {\n\tvar keepAliveInterval *time.Duration\n\tif alt != \"\" {\n\t\tkeepAliveInterval = new(time.Duration(0))\n\t}\n\n\th.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{\n\t\tKeepAliveInterval: keepAliveInterval,\n\t\tWriteChunk: func(chunk []byte) {\n\t\t\tif alt == \"\" {\n\t\t\t\tif bytes.Equal(chunk, []byte(\"data: [DONE]\")) || bytes.Equal(chunk, []byte(\"[DONE]\")) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif !bytes.HasPrefix(chunk, []byte(\"data:\")) {\n\t\t\t\t\t_, _ = c.Writer.Write([]byte(\"data: \"))\n\t\t\t\t}\n\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\\n\"))\n\t\t\t} else {\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t}\n\t\t},\n\t\tWriteTerminalError: func(errMsg *interfaces.ErrorMessage) {\n\t\t\tif errMsg == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatus := http.StatusInternalServerError\n\t\t\tif errMsg.StatusCode > 0 {\n\t\t\t\tstatus = errMsg.StatusCode\n\t\t\t}\n\t\t\terrText := http.StatusText(status)\n\t\t\tif errMsg.Error != nil && errMsg.Error.Error() != \"\" {\n\t\t\t\terrText = errMsg.Error.Error()\n\t\t\t}\n\t\t\tbody := handlers.BuildErrorResponseBody(status, errText)\n\t\t\tif alt == \"\" {\n\t\t\t\t_, _ = fmt.Fprintf(c.Writer, \"event: error\\ndata: %s\\n\\n\", string(body))\n\t\t\t} else {\n\t\t\t\t_, _ = c.Writer.Write(body)\n\t\t\t}\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "sdk/api/handlers/gemini/gemini_handlers.go",
    "content": "// Package gemini provides HTTP handlers for Gemini API endpoints.\n// This package implements handlers for managing Gemini model operations including\n// model listing, content generation, streaming content generation, and token counting.\n// It serves as a proxy layer between clients and the Gemini backend service,\n// handling request translation, client management, and response processing.\npackage gemini\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n)\n\n// GeminiAPIHandler contains the handlers for Gemini API endpoints.\n// It holds a pool of clients to interact with the backend service.\ntype GeminiAPIHandler struct {\n\t*handlers.BaseAPIHandler\n}\n\n// NewGeminiAPIHandler creates a new Gemini API handlers instance.\n// It takes an BaseAPIHandler instance as input and returns a GeminiAPIHandler.\nfunc NewGeminiAPIHandler(apiHandlers *handlers.BaseAPIHandler) *GeminiAPIHandler {\n\treturn &GeminiAPIHandler{\n\t\tBaseAPIHandler: apiHandlers,\n\t}\n}\n\n// HandlerType returns the identifier for this handler implementation.\nfunc (h *GeminiAPIHandler) HandlerType() string {\n\treturn Gemini\n}\n\n// Models returns the Gemini-compatible model metadata supported by this handler.\nfunc (h *GeminiAPIHandler) Models() []map[string]any {\n\t// Get dynamic models from the global registry\n\tmodelRegistry := registry.GetGlobalRegistry()\n\treturn modelRegistry.GetAvailableModels(\"gemini\")\n}\n\n// GeminiModels handles the Gemini models listing endpoint.\n// It returns a JSON response containing available Gemini models and their specifications.\nfunc (h *GeminiAPIHandler) GeminiModels(c *gin.Context) {\n\trawModels := h.Models()\n\tnormalizedModels := make([]map[string]any, 0, len(rawModels))\n\tdefaultMethods := []string{\"generateContent\"}\n\tfor _, model := range rawModels {\n\t\tnormalizedModel := make(map[string]any, len(model))\n\t\tfor k, v := range model {\n\t\t\tnormalizedModel[k] = v\n\t\t}\n\t\tif name, ok := normalizedModel[\"name\"].(string); ok && name != \"\" {\n\t\t\tif !strings.HasPrefix(name, \"models/\") {\n\t\t\t\tnormalizedModel[\"name\"] = \"models/\" + name\n\t\t\t}\n\t\t\tif displayName, _ := normalizedModel[\"displayName\"].(string); displayName == \"\" {\n\t\t\t\tnormalizedModel[\"displayName\"] = name\n\t\t\t}\n\t\t\tif description, _ := normalizedModel[\"description\"].(string); description == \"\" {\n\t\t\t\tnormalizedModel[\"description\"] = name\n\t\t\t}\n\t\t}\n\t\tif _, ok := normalizedModel[\"supportedGenerationMethods\"]; !ok {\n\t\t\tnormalizedModel[\"supportedGenerationMethods\"] = defaultMethods\n\t\t}\n\t\tnormalizedModels = append(normalizedModels, normalizedModel)\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"models\": normalizedModels,\n\t})\n}\n\n// GeminiGetHandler handles GET requests for specific Gemini model information.\n// It returns detailed information about a specific Gemini model based on the action parameter.\nfunc (h *GeminiAPIHandler) GeminiGetHandler(c *gin.Context) {\n\tvar request struct {\n\t\tAction string `uri:\"action\" binding:\"required\"`\n\t}\n\tif err := c.ShouldBindUri(&request); err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\taction := strings.TrimPrefix(request.Action, \"/\")\n\n\t// Get dynamic models from the global registry and find the matching one\n\tavailableModels := h.Models()\n\tvar targetModel map[string]any\n\n\tfor _, model := range availableModels {\n\t\tname, _ := model[\"name\"].(string)\n\t\t// Match name with or without 'models/' prefix\n\t\tif name == action || name == \"models/\"+action {\n\t\t\ttargetModel = model\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif targetModel != nil {\n\t\t// Ensure the name has 'models/' prefix in the output if it's a Gemini model\n\t\tif name, ok := targetModel[\"name\"].(string); ok && name != \"\" && !strings.HasPrefix(name, \"models/\") {\n\t\t\ttargetModel[\"name\"] = \"models/\" + name\n\t\t}\n\t\tc.JSON(http.StatusOK, targetModel)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusNotFound, handlers.ErrorResponse{\n\t\tError: handlers.ErrorDetail{\n\t\t\tMessage: \"Not Found\",\n\t\t\tType:    \"not_found\",\n\t\t},\n\t})\n}\n\n// GeminiHandler handles POST requests for Gemini API operations.\n// It routes requests to appropriate handlers based on the action parameter (model:method format).\nfunc (h *GeminiAPIHandler) GeminiHandler(c *gin.Context) {\n\tvar request struct {\n\t\tAction string `uri:\"action\" binding:\"required\"`\n\t}\n\tif err := c.ShouldBindUri(&request); err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\taction := strings.Split(strings.TrimPrefix(request.Action, \"/\"), \":\")\n\tif len(action) != 2 {\n\t\tc.JSON(http.StatusNotFound, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"%s not found.\", c.Request.URL.Path),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tmethod := action[1]\n\trawJSON, _ := c.GetRawData()\n\n\tswitch method {\n\tcase \"generateContent\":\n\t\th.handleGenerateContent(c, action[0], rawJSON)\n\tcase \"streamGenerateContent\":\n\t\th.handleStreamGenerateContent(c, action[0], rawJSON)\n\tcase \"countTokens\":\n\t\th.handleCountTokens(c, action[0], rawJSON)\n\t}\n}\n\n// handleStreamGenerateContent handles streaming content generation requests for Gemini models.\n// This function establishes a Server-Sent Events connection and streams the generated content\n// back to the client in real-time. It supports both SSE format and direct streaming based\n// on the 'alt' query parameter.\n//\n// Parameters:\n//   - c: The Gin context for the request\n//   - modelName: The name of the Gemini model to use for content generation\n//   - rawJSON: The raw JSON request body containing generation parameters\nfunc (h *GeminiAPIHandler) handleStreamGenerateContent(c *gin.Context, modelName string, rawJSON []byte) {\n\talt := h.GetAlt(c)\n\n\t// Get the http.Flusher interface to manually flush the response.\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)\n\n\tsetSSEHeaders := func() {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Peek at the first chunk\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcliCancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase errMsg, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\t// Err channel closed cleanly; wait for data channel.\n\t\t\t\terrChan = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Upstream failed immediately. Return proper error status and JSON.\n\t\t\th.WriteErrorResponse(c, errMsg)\n\t\t\tif errMsg != nil {\n\t\t\t\tcliCancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcliCancel(nil)\n\t\t\t}\n\t\t\treturn\n\t\tcase chunk, ok := <-dataChan:\n\t\t\tif !ok {\n\t\t\t\t// Closed without data\n\t\t\t\tif alt == \"\" {\n\t\t\t\t\tsetSSEHeaders()\n\t\t\t\t}\n\t\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t\t\t\tflusher.Flush()\n\t\t\t\tcliCancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Success! Set headers.\n\t\t\tif alt == \"\" {\n\t\t\t\tsetSSEHeaders()\n\t\t\t}\n\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\n\t\t\t// Write first chunk\n\t\t\tif alt == \"\" {\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"data: \"))\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\\n\"))\n\t\t\t} else {\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t}\n\t\t\tflusher.Flush()\n\n\t\t\t// Continue\n\t\t\th.forwardGeminiStream(c, flusher, alt, func(err error) { cliCancel(err) }, dataChan, errChan)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// handleCountTokens handles token counting requests for Gemini models.\n// This function counts the number of tokens in the provided content without\n// generating a response. It's useful for quota management and content validation.\n//\n// Parameters:\n//   - c: The Gin context for the request\n//   - modelName: The name of the Gemini model to use for token counting\n//   - rawJSON: The raw JSON request body containing the content to count\nfunc (h *GeminiAPIHandler) handleCountTokens(c *gin.Context, modelName string, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\talt := h.GetAlt(c)\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tresp, upstreamHeaders, errMsg := h.ExecuteCountWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// handleGenerateContent handles non-streaming content generation requests for Gemini models.\n// This function processes the request synchronously and returns the complete generated\n// response in a single API call. It supports various generation parameters and\n// response formats.\n//\n// Parameters:\n//   - c: The Gin context for the request\n//   - modelName: The name of the Gemini model to use for content generation\n//   - rawJSON: The raw JSON request body containing generation parameters and content\nfunc (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName string, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\talt := h.GetAlt(c)\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tstopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)\n\tstopKeepAlive()\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\nfunc (h *GeminiAPIHandler) forwardGeminiStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {\n\tvar keepAliveInterval *time.Duration\n\tif alt != \"\" {\n\t\tkeepAliveInterval = new(time.Duration(0))\n\t}\n\n\th.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{\n\t\tKeepAliveInterval: keepAliveInterval,\n\t\tWriteChunk: func(chunk []byte) {\n\t\t\tif alt == \"\" {\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"data: \"))\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\\n\"))\n\t\t\t} else {\n\t\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t}\n\t\t},\n\t\tWriteTerminalError: func(errMsg *interfaces.ErrorMessage) {\n\t\t\tif errMsg == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatus := http.StatusInternalServerError\n\t\t\tif errMsg.StatusCode > 0 {\n\t\t\t\tstatus = errMsg.StatusCode\n\t\t\t}\n\t\t\terrText := http.StatusText(status)\n\t\t\tif errMsg.Error != nil && errMsg.Error.Error() != \"\" {\n\t\t\t\terrText = errMsg.Error.Error()\n\t\t\t}\n\t\t\tbody := handlers.BuildErrorResponseBody(status, errText)\n\t\t\tif alt == \"\" {\n\t\t\t\t_, _ = fmt.Fprintf(c.Writer, \"event: error\\ndata: %s\\n\\n\", string(body))\n\t\t\t} else {\n\t\t\t\t_, _ = c.Writer.Write(body)\n\t\t\t}\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "sdk/api/handlers/handlers.go",
    "content": "// Package handlers provides core API handler functionality for the CLI Proxy API server.\n// It includes common types, client management, load balancing, and error handling\n// shared across all API endpoint handlers (OpenAI, Claude, Gemini).\npackage handlers\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcoreexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"golang.org/x/net/context\"\n)\n\n// ErrorResponse represents a standard error response format for the API.\n// It contains a single ErrorDetail field.\ntype ErrorResponse struct {\n\t// Error contains detailed information about the error that occurred.\n\tError ErrorDetail `json:\"error\"`\n}\n\n// ErrorDetail provides specific information about an error that occurred.\n// It includes a human-readable message, an error type, and an optional error code.\ntype ErrorDetail struct {\n\t// Message is a human-readable message providing more details about the error.\n\tMessage string `json:\"message\"`\n\n\t// Type is the category of error that occurred (e.g., \"invalid_request_error\").\n\tType string `json:\"type\"`\n\n\t// Code is a short code identifying the error, if applicable.\n\tCode string `json:\"code,omitempty\"`\n}\n\nconst idempotencyKeyMetadataKey = \"idempotency_key\"\n\nconst (\n\tdefaultStreamingKeepAliveSeconds = 0\n\tdefaultStreamingBootstrapRetries = 0\n)\n\ntype pinnedAuthContextKey struct{}\ntype selectedAuthCallbackContextKey struct{}\ntype executionSessionContextKey struct{}\n\n// WithPinnedAuthID returns a child context that requests execution on a specific auth ID.\nfunc WithPinnedAuthID(ctx context.Context, authID string) context.Context {\n\tauthID = strings.TrimSpace(authID)\n\tif authID == \"\" {\n\t\treturn ctx\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\treturn context.WithValue(ctx, pinnedAuthContextKey{}, authID)\n}\n\n// WithSelectedAuthIDCallback returns a child context that receives the selected auth ID.\nfunc WithSelectedAuthIDCallback(ctx context.Context, callback func(string)) context.Context {\n\tif callback == nil {\n\t\treturn ctx\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\treturn context.WithValue(ctx, selectedAuthCallbackContextKey{}, callback)\n}\n\n// WithExecutionSessionID returns a child context tagged with a long-lived execution session ID.\nfunc WithExecutionSessionID(ctx context.Context, sessionID string) context.Context {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn ctx\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\treturn context.WithValue(ctx, executionSessionContextKey{}, sessionID)\n}\n\n// BuildErrorResponseBody builds an OpenAI-compatible JSON error response body.\n// If errText is already valid JSON, it is returned as-is to preserve upstream error payloads.\nfunc BuildErrorResponseBody(status int, errText string) []byte {\n\tif status <= 0 {\n\t\tstatus = http.StatusInternalServerError\n\t}\n\tif strings.TrimSpace(errText) == \"\" {\n\t\terrText = http.StatusText(status)\n\t}\n\n\ttrimmed := strings.TrimSpace(errText)\n\tif trimmed != \"\" && json.Valid([]byte(trimmed)) {\n\t\treturn []byte(trimmed)\n\t}\n\n\terrType := \"invalid_request_error\"\n\tvar code string\n\tswitch status {\n\tcase http.StatusUnauthorized:\n\t\terrType = \"authentication_error\"\n\t\tcode = \"invalid_api_key\"\n\tcase http.StatusForbidden:\n\t\terrType = \"permission_error\"\n\t\tcode = \"insufficient_quota\"\n\tcase http.StatusTooManyRequests:\n\t\terrType = \"rate_limit_error\"\n\t\tcode = \"rate_limit_exceeded\"\n\tcase http.StatusNotFound:\n\t\terrType = \"invalid_request_error\"\n\t\tcode = \"model_not_found\"\n\tdefault:\n\t\tif status >= http.StatusInternalServerError {\n\t\t\terrType = \"server_error\"\n\t\t\tcode = \"internal_server_error\"\n\t\t}\n\t}\n\n\tpayload, err := json.Marshal(ErrorResponse{\n\t\tError: ErrorDetail{\n\t\t\tMessage: errText,\n\t\t\tType:    errType,\n\t\t\tCode:    code,\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn []byte(fmt.Sprintf(`{\"error\":{\"message\":%q,\"type\":\"server_error\",\"code\":\"internal_server_error\"}}`, errText))\n\t}\n\treturn payload\n}\n\n// StreamingKeepAliveInterval returns the SSE keep-alive interval for this server.\n// Returning 0 disables keep-alives (default when unset).\nfunc StreamingKeepAliveInterval(cfg *config.SDKConfig) time.Duration {\n\tseconds := defaultStreamingKeepAliveSeconds\n\tif cfg != nil {\n\t\tseconds = cfg.Streaming.KeepAliveSeconds\n\t}\n\tif seconds <= 0 {\n\t\treturn 0\n\t}\n\treturn time.Duration(seconds) * time.Second\n}\n\n// NonStreamingKeepAliveInterval returns the keep-alive interval for non-streaming responses.\n// Returning 0 disables keep-alives (default when unset).\nfunc NonStreamingKeepAliveInterval(cfg *config.SDKConfig) time.Duration {\n\tseconds := 0\n\tif cfg != nil {\n\t\tseconds = cfg.NonStreamKeepAliveInterval\n\t}\n\tif seconds <= 0 {\n\t\treturn 0\n\t}\n\treturn time.Duration(seconds) * time.Second\n}\n\n// StreamingBootstrapRetries returns how many times a streaming request may be retried before any bytes are sent.\nfunc StreamingBootstrapRetries(cfg *config.SDKConfig) int {\n\tretries := defaultStreamingBootstrapRetries\n\tif cfg != nil {\n\t\tretries = cfg.Streaming.BootstrapRetries\n\t}\n\tif retries < 0 {\n\t\tretries = 0\n\t}\n\treturn retries\n}\n\n// PassthroughHeadersEnabled returns whether upstream response headers should be forwarded to clients.\n// Default is false.\nfunc PassthroughHeadersEnabled(cfg *config.SDKConfig) bool {\n\treturn cfg != nil && cfg.PassthroughHeaders\n}\n\nfunc requestExecutionMetadata(ctx context.Context) map[string]any {\n\t// Idempotency-Key is an optional client-supplied header used to correlate retries.\n\t// It is forwarded as execution metadata; when absent we generate a UUID.\n\tkey := \"\"\n\tif ctx != nil {\n\t\tif ginCtx, ok := ctx.Value(\"gin\").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {\n\t\t\tkey = strings.TrimSpace(ginCtx.GetHeader(\"Idempotency-Key\"))\n\t\t}\n\t}\n\tif key == \"\" {\n\t\tkey = uuid.NewString()\n\t}\n\n\tmeta := map[string]any{idempotencyKeyMetadataKey: key}\n\tif pinnedAuthID := pinnedAuthIDFromContext(ctx); pinnedAuthID != \"\" {\n\t\tmeta[coreexecutor.PinnedAuthMetadataKey] = pinnedAuthID\n\t}\n\tif selectedCallback := selectedAuthIDCallbackFromContext(ctx); selectedCallback != nil {\n\t\tmeta[coreexecutor.SelectedAuthCallbackMetadataKey] = selectedCallback\n\t}\n\tif executionSessionID := executionSessionIDFromContext(ctx); executionSessionID != \"\" {\n\t\tmeta[coreexecutor.ExecutionSessionMetadataKey] = executionSessionID\n\t}\n\treturn meta\n}\n\nfunc pinnedAuthIDFromContext(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn \"\"\n\t}\n\traw := ctx.Value(pinnedAuthContextKey{})\n\tswitch v := raw.(type) {\n\tcase string:\n\t\treturn strings.TrimSpace(v)\n\tcase []byte:\n\t\treturn strings.TrimSpace(string(v))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc selectedAuthIDCallbackFromContext(ctx context.Context) func(string) {\n\tif ctx == nil {\n\t\treturn nil\n\t}\n\traw := ctx.Value(selectedAuthCallbackContextKey{})\n\tif callback, ok := raw.(func(string)); ok && callback != nil {\n\t\treturn callback\n\t}\n\treturn nil\n}\n\nfunc executionSessionIDFromContext(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn \"\"\n\t}\n\traw := ctx.Value(executionSessionContextKey{})\n\tswitch v := raw.(type) {\n\tcase string:\n\t\treturn strings.TrimSpace(v)\n\tcase []byte:\n\t\treturn strings.TrimSpace(string(v))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// BaseAPIHandler contains the handlers for API endpoints.\n// It holds a pool of clients to interact with the backend service and manages\n// load balancing, client selection, and configuration.\ntype BaseAPIHandler struct {\n\t// AuthManager manages auth lifecycle and execution in the new architecture.\n\tAuthManager *coreauth.Manager\n\n\t// Cfg holds the current application configuration.\n\tCfg *config.SDKConfig\n}\n\n// NewBaseAPIHandlers creates a new API handlers instance.\n// It takes a slice of clients and configuration as input.\n//\n// Parameters:\n//   - cliClients: A slice of AI service clients\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *BaseAPIHandler: A new API handlers instance\nfunc NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager) *BaseAPIHandler {\n\treturn &BaseAPIHandler{\n\t\tCfg:         cfg,\n\t\tAuthManager: authManager,\n\t}\n}\n\n// UpdateClients updates the handlers' client list and configuration.\n// This method is called when the configuration or authentication tokens change.\n//\n// Parameters:\n//   - clients: The new slice of AI service clients\n//   - cfg: The new application configuration\nfunc (h *BaseAPIHandler) UpdateClients(cfg *config.SDKConfig) { h.Cfg = cfg }\n\n// GetAlt extracts the 'alt' parameter from the request query string.\n// It checks both 'alt' and '$alt' parameters and returns the appropriate value.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request\n//\n// Returns:\n//   - string: The alt parameter value, or empty string if it's \"sse\"\nfunc (h *BaseAPIHandler) GetAlt(c *gin.Context) string {\n\tvar alt string\n\tvar hasAlt bool\n\talt, hasAlt = c.GetQuery(\"alt\")\n\tif !hasAlt {\n\t\talt, _ = c.GetQuery(\"$alt\")\n\t}\n\tif alt == \"sse\" {\n\t\treturn \"\"\n\t}\n\treturn alt\n}\n\n// GetContextWithCancel creates a new context with cancellation capabilities.\n// It embeds the Gin context and the API handler into the new context for later use.\n// The returned cancel function also handles logging the API response if request logging is enabled.\n//\n// Parameters:\n//   - handler: The API handler associated with the request.\n//   - c: The Gin context of the current request.\n//   - ctx: The parent context (caller values/deadlines are preserved; request context adds cancellation and request ID).\n//\n// Returns:\n//   - context.Context: The new context with cancellation and embedded values.\n//   - APIHandlerCancelFunc: A function to cancel the context and log the response.\nfunc (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *gin.Context, ctx context.Context) (context.Context, APIHandlerCancelFunc) {\n\tparentCtx := ctx\n\tif parentCtx == nil {\n\t\tparentCtx = context.Background()\n\t}\n\n\tvar requestCtx context.Context\n\tif c != nil && c.Request != nil {\n\t\trequestCtx = c.Request.Context()\n\t}\n\n\tif requestCtx != nil && logging.GetRequestID(parentCtx) == \"\" {\n\t\tif requestID := logging.GetRequestID(requestCtx); requestID != \"\" {\n\t\t\tparentCtx = logging.WithRequestID(parentCtx, requestID)\n\t\t} else if requestID := logging.GetGinRequestID(c); requestID != \"\" {\n\t\t\tparentCtx = logging.WithRequestID(parentCtx, requestID)\n\t\t}\n\t}\n\tnewCtx, cancel := context.WithCancel(parentCtx)\n\tif requestCtx != nil && requestCtx != parentCtx {\n\t\tgo func() {\n\t\t\tselect {\n\t\t\tcase <-requestCtx.Done():\n\t\t\t\tcancel()\n\t\t\tcase <-newCtx.Done():\n\t\t\t}\n\t\t}()\n\t}\n\tnewCtx = context.WithValue(newCtx, \"gin\", c)\n\tnewCtx = context.WithValue(newCtx, \"handler\", handler)\n\treturn newCtx, func(params ...interface{}) {\n\t\tif h.Cfg.RequestLog && len(params) == 1 {\n\t\t\tif existing, exists := c.Get(\"API_RESPONSE\"); exists {\n\t\t\t\tif existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 {\n\t\t\t\t\tswitch params[0].(type) {\n\t\t\t\t\tcase error, string:\n\t\t\t\t\t\tcancel()\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar payload []byte\n\t\t\tswitch data := params[0].(type) {\n\t\t\tcase []byte:\n\t\t\t\tpayload = data\n\t\t\tcase error:\n\t\t\t\tif data != nil {\n\t\t\t\t\tpayload = []byte(data.Error())\n\t\t\t\t}\n\t\t\tcase string:\n\t\t\t\tpayload = []byte(data)\n\t\t\t}\n\t\t\tif len(payload) > 0 {\n\t\t\t\tif existing, exists := c.Get(\"API_RESPONSE\"); exists {\n\t\t\t\t\tif existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {\n\t\t\t\t\t\ttrimmedPayload := bytes.TrimSpace(payload)\n\t\t\t\t\t\tif len(trimmedPayload) > 0 && bytes.Contains(existingBytes, trimmedPayload) {\n\t\t\t\t\t\t\tcancel()\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tappendAPIResponse(c, payload)\n\t\t\t}\n\t\t}\n\n\t\tcancel()\n\t}\n}\n\n// StartNonStreamingKeepAlive emits blank lines every 5 seconds while waiting for a non-streaming response.\n// It returns a stop function that must be called before writing the final response.\nfunc (h *BaseAPIHandler) StartNonStreamingKeepAlive(c *gin.Context, ctx context.Context) func() {\n\tif h == nil || c == nil {\n\t\treturn func() {}\n\t}\n\tinterval := NonStreamingKeepAliveInterval(h.Cfg)\n\tif interval <= 0 {\n\t\treturn func() {}\n\t}\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\treturn func() {}\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\n\tstopChan := make(chan struct{})\n\tvar stopOnce sync.Once\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tticker := time.NewTicker(interval)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-stopChan:\n\t\t\t\treturn\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn func() {\n\t\tstopOnce.Do(func() {\n\t\t\tclose(stopChan)\n\t\t})\n\t\twg.Wait()\n\t}\n}\n\n// appendAPIResponse preserves any previously captured API response and appends new data.\nfunc appendAPIResponse(c *gin.Context, data []byte) {\n\tif c == nil || len(data) == 0 {\n\t\treturn\n\t}\n\n\t// Capture timestamp on first API response\n\tif _, exists := c.Get(\"API_RESPONSE_TIMESTAMP\"); !exists {\n\t\tc.Set(\"API_RESPONSE_TIMESTAMP\", time.Now())\n\t}\n\n\tif existing, exists := c.Get(\"API_RESPONSE\"); exists {\n\t\tif existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {\n\t\t\tcombined := make([]byte, 0, len(existingBytes)+len(data)+1)\n\t\t\tcombined = append(combined, existingBytes...)\n\t\t\tif existingBytes[len(existingBytes)-1] != '\\n' {\n\t\t\t\tcombined = append(combined, '\\n')\n\t\t\t}\n\t\t\tcombined = append(combined, data...)\n\t\t\tc.Set(\"API_RESPONSE\", combined)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.Set(\"API_RESPONSE\", bytes.Clone(data))\n}\n\n// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.\n// This path is the only supported execution route.\nfunc (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, http.Header, *interfaces.ErrorMessage) {\n\tproviders, normalizedModel, errMsg := h.getRequestDetails(modelName)\n\tif errMsg != nil {\n\t\treturn nil, nil, errMsg\n\t}\n\treqMeta := requestExecutionMetadata(ctx)\n\treqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel\n\tpayload := rawJSON\n\tif len(payload) == 0 {\n\t\tpayload = nil\n\t}\n\treq := coreexecutor.Request{\n\t\tModel:   normalizedModel,\n\t\tPayload: payload,\n\t}\n\topts := coreexecutor.Options{\n\t\tStream:          false,\n\t\tAlt:             alt,\n\t\tOriginalRequest: rawJSON,\n\t\tSourceFormat:    sdktranslator.FromString(handlerType),\n\t}\n\topts.Metadata = reqMeta\n\tresp, err := h.AuthManager.Execute(ctx, providers, req, opts)\n\tif err != nil {\n\t\tstatus := http.StatusInternalServerError\n\t\tif se, ok := err.(interface{ StatusCode() int }); ok && se != nil {\n\t\t\tif code := se.StatusCode(); code > 0 {\n\t\t\t\tstatus = code\n\t\t\t}\n\t\t}\n\t\tvar addon http.Header\n\t\tif he, ok := err.(interface{ Headers() http.Header }); ok && he != nil {\n\t\t\tif hdr := he.Headers(); hdr != nil {\n\t\t\t\taddon = hdr.Clone()\n\t\t\t}\n\t\t}\n\t\treturn nil, nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon}\n\t}\n\tif !PassthroughHeadersEnabled(h.Cfg) {\n\t\treturn resp.Payload, nil, nil\n\t}\n\treturn resp.Payload, FilterUpstreamHeaders(resp.Headers), nil\n}\n\n// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.\n// This path is the only supported execution route.\nfunc (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, http.Header, *interfaces.ErrorMessage) {\n\tproviders, normalizedModel, errMsg := h.getRequestDetails(modelName)\n\tif errMsg != nil {\n\t\treturn nil, nil, errMsg\n\t}\n\treqMeta := requestExecutionMetadata(ctx)\n\treqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel\n\tpayload := rawJSON\n\tif len(payload) == 0 {\n\t\tpayload = nil\n\t}\n\treq := coreexecutor.Request{\n\t\tModel:   normalizedModel,\n\t\tPayload: payload,\n\t}\n\topts := coreexecutor.Options{\n\t\tStream:          false,\n\t\tAlt:             alt,\n\t\tOriginalRequest: rawJSON,\n\t\tSourceFormat:    sdktranslator.FromString(handlerType),\n\t}\n\topts.Metadata = reqMeta\n\tresp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)\n\tif err != nil {\n\t\tstatus := http.StatusInternalServerError\n\t\tif se, ok := err.(interface{ StatusCode() int }); ok && se != nil {\n\t\t\tif code := se.StatusCode(); code > 0 {\n\t\t\t\tstatus = code\n\t\t\t}\n\t\t}\n\t\tvar addon http.Header\n\t\tif he, ok := err.(interface{ Headers() http.Header }); ok && he != nil {\n\t\t\tif hdr := he.Headers(); hdr != nil {\n\t\t\t\taddon = hdr.Clone()\n\t\t\t}\n\t\t}\n\t\treturn nil, nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon}\n\t}\n\tif !PassthroughHeadersEnabled(h.Cfg) {\n\t\treturn resp.Payload, nil, nil\n\t}\n\treturn resp.Payload, FilterUpstreamHeaders(resp.Headers), nil\n}\n\n// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.\n// This path is the only supported execution route.\n// The returned http.Header carries upstream response headers captured before streaming begins.\nfunc (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, http.Header, <-chan *interfaces.ErrorMessage) {\n\tproviders, normalizedModel, errMsg := h.getRequestDetails(modelName)\n\tif errMsg != nil {\n\t\terrChan := make(chan *interfaces.ErrorMessage, 1)\n\t\terrChan <- errMsg\n\t\tclose(errChan)\n\t\treturn nil, nil, errChan\n\t}\n\treqMeta := requestExecutionMetadata(ctx)\n\treqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel\n\tpayload := rawJSON\n\tif len(payload) == 0 {\n\t\tpayload = nil\n\t}\n\treq := coreexecutor.Request{\n\t\tModel:   normalizedModel,\n\t\tPayload: payload,\n\t}\n\topts := coreexecutor.Options{\n\t\tStream:          true,\n\t\tAlt:             alt,\n\t\tOriginalRequest: rawJSON,\n\t\tSourceFormat:    sdktranslator.FromString(handlerType),\n\t}\n\topts.Metadata = reqMeta\n\tstreamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)\n\tif err != nil {\n\t\terrChan := make(chan *interfaces.ErrorMessage, 1)\n\t\tstatus := http.StatusInternalServerError\n\t\tif se, ok := err.(interface{ StatusCode() int }); ok && se != nil {\n\t\t\tif code := se.StatusCode(); code > 0 {\n\t\t\t\tstatus = code\n\t\t\t}\n\t\t}\n\t\tvar addon http.Header\n\t\tif he, ok := err.(interface{ Headers() http.Header }); ok && he != nil {\n\t\t\tif hdr := he.Headers(); hdr != nil {\n\t\t\t\taddon = hdr.Clone()\n\t\t\t}\n\t\t}\n\t\terrChan <- &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon}\n\t\tclose(errChan)\n\t\treturn nil, nil, errChan\n\t}\n\tpassthroughHeadersEnabled := PassthroughHeadersEnabled(h.Cfg)\n\t// Capture upstream headers from the initial connection synchronously before the goroutine starts.\n\t// Keep a mutable map so bootstrap retries can replace it before first payload is sent.\n\tvar upstreamHeaders http.Header\n\tif passthroughHeadersEnabled {\n\t\tupstreamHeaders = cloneHeader(FilterUpstreamHeaders(streamResult.Headers))\n\t\tif upstreamHeaders == nil {\n\t\t\tupstreamHeaders = make(http.Header)\n\t\t}\n\t}\n\tchunks := streamResult.Chunks\n\tdataChan := make(chan []byte)\n\terrChan := make(chan *interfaces.ErrorMessage, 1)\n\tgo func() {\n\t\tdefer close(dataChan)\n\t\tdefer close(errChan)\n\t\tsentPayload := false\n\t\tbootstrapRetries := 0\n\t\tmaxBootstrapRetries := StreamingBootstrapRetries(h.Cfg)\n\n\t\tsendErr := func(msg *interfaces.ErrorMessage) bool {\n\t\t\tif ctx == nil {\n\t\t\t\terrChan <- msg\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\tcase errChan <- msg:\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\n\t\tsendData := func(chunk []byte) bool {\n\t\t\tif ctx == nil {\n\t\t\t\tdataChan <- chunk\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\tcase dataChan <- chunk:\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\n\t\tbootstrapEligible := func(err error) bool {\n\t\t\tstatus := statusFromError(err)\n\t\t\tif status == 0 {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tswitch status {\n\t\t\tcase http.StatusUnauthorized, http.StatusForbidden, http.StatusPaymentRequired,\n\t\t\t\thttp.StatusRequestTimeout, http.StatusTooManyRequests:\n\t\t\t\treturn true\n\t\t\tdefault:\n\t\t\t\treturn status >= http.StatusInternalServerError\n\t\t\t}\n\t\t}\n\n\touter:\n\t\tfor {\n\t\t\tfor {\n\t\t\t\tvar chunk coreexecutor.StreamChunk\n\t\t\t\tvar ok bool\n\t\t\t\tif ctx != nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\t\treturn\n\t\t\t\t\tcase chunk, ok = <-chunks:\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tchunk, ok = <-chunks\n\t\t\t\t}\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif chunk.Err != nil {\n\t\t\t\t\tstreamErr := chunk.Err\n\t\t\t\t\t// Safe bootstrap recovery: if the upstream fails before any payload bytes are sent,\n\t\t\t\t\t// retry a few times (to allow auth rotation / transient recovery) and then attempt model fallback.\n\t\t\t\t\tif !sentPayload {\n\t\t\t\t\t\tif bootstrapRetries < maxBootstrapRetries && bootstrapEligible(streamErr) {\n\t\t\t\t\t\t\tbootstrapRetries++\n\t\t\t\t\t\t\tretryResult, retryErr := h.AuthManager.ExecuteStream(ctx, providers, req, opts)\n\t\t\t\t\t\t\tif retryErr == nil {\n\t\t\t\t\t\t\t\tif passthroughHeadersEnabled {\n\t\t\t\t\t\t\t\t\treplaceHeader(upstreamHeaders, FilterUpstreamHeaders(retryResult.Headers))\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tchunks = retryResult.Chunks\n\t\t\t\t\t\t\t\tcontinue outer\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tstreamErr = retryErr\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tstatus := http.StatusInternalServerError\n\t\t\t\t\tif se, ok := streamErr.(interface{ StatusCode() int }); ok && se != nil {\n\t\t\t\t\t\tif code := se.StatusCode(); code > 0 {\n\t\t\t\t\t\t\tstatus = code\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tvar addon http.Header\n\t\t\t\t\tif he, ok := streamErr.(interface{ Headers() http.Header }); ok && he != nil {\n\t\t\t\t\t\tif hdr := he.Headers(); hdr != nil {\n\t\t\t\t\t\t\taddon = hdr.Clone()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t_ = sendErr(&interfaces.ErrorMessage{StatusCode: status, Error: streamErr, Addon: addon})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(chunk.Payload) > 0 {\n\t\t\t\t\tif handlerType == \"openai-response\" {\n\t\t\t\t\t\tif err := validateSSEDataJSON(chunk.Payload); err != nil {\n\t\t\t\t\t\t\t_ = sendErr(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err})\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tsentPayload = true\n\t\t\t\t\tif okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\treturn dataChan, upstreamHeaders, errChan\n}\n\nfunc validateSSEDataJSON(chunk []byte) error {\n\tfor _, line := range bytes.Split(chunk, []byte(\"\\n\")) {\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif !bytes.HasPrefix(line, []byte(\"data:\")) {\n\t\t\tcontinue\n\t\t}\n\t\tdata := bytes.TrimSpace(line[5:])\n\t\tif len(data) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif bytes.Equal(data, []byte(\"[DONE]\")) {\n\t\t\tcontinue\n\t\t}\n\t\tif json.Valid(data) {\n\t\t\tcontinue\n\t\t}\n\t\tconst max = 512\n\t\tpreview := data\n\t\tif len(preview) > max {\n\t\t\tpreview = preview[:max]\n\t\t}\n\t\treturn fmt.Errorf(\"invalid SSE data JSON (len=%d): %q\", len(data), preview)\n\t}\n\treturn nil\n}\n\nfunc statusFromError(err error) int {\n\tif err == nil {\n\t\treturn 0\n\t}\n\tif se, ok := err.(interface{ StatusCode() int }); ok && se != nil {\n\t\tif code := se.StatusCode(); code > 0 {\n\t\t\treturn code\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string, normalizedModel string, err *interfaces.ErrorMessage) {\n\tresolvedModelName := modelName\n\tinitialSuffix := thinking.ParseSuffix(modelName)\n\tif initialSuffix.ModelName == \"auto\" {\n\t\tresolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)\n\t\tif initialSuffix.HasSuffix {\n\t\t\tresolvedModelName = fmt.Sprintf(\"%s(%s)\", resolvedBase, initialSuffix.RawSuffix)\n\t\t} else {\n\t\t\tresolvedModelName = resolvedBase\n\t\t}\n\t} else {\n\t\tresolvedModelName = util.ResolveAutoModel(modelName)\n\t}\n\n\tparsed := thinking.ParseSuffix(resolvedModelName)\n\tbaseModel := strings.TrimSpace(parsed.ModelName)\n\n\tproviders = util.GetProviderName(baseModel)\n\t// Fallback: if baseModel has no provider but differs from resolvedModelName,\n\t// try using the full model name. This handles edge cases where custom models\n\t// may be registered with their full suffixed name (e.g., \"my-model(8192)\").\n\t// Evaluated in Story 11.8: This fallback is intentionally preserved to support\n\t// custom model registrations that include thinking suffixes.\n\tif len(providers) == 0 && baseModel != resolvedModelName {\n\t\tproviders = util.GetProviderName(resolvedModelName)\n\t}\n\n\tif len(providers) == 0 {\n\t\treturn nil, \"\", &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf(\"unknown provider for model %s\", modelName)}\n\t}\n\n\t// The thinking suffix is preserved in the model name itself, so no\n\t// metadata-based configuration passing is needed.\n\treturn providers, resolvedModelName, nil\n}\n\nfunc cloneBytes(src []byte) []byte {\n\tif len(src) == 0 {\n\t\treturn nil\n\t}\n\tdst := make([]byte, len(src))\n\tcopy(dst, src)\n\treturn dst\n}\n\nfunc cloneHeader(src http.Header) http.Header {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := make(http.Header, len(src))\n\tfor key, values := range src {\n\t\tdst[key] = append([]string(nil), values...)\n\t}\n\treturn dst\n}\n\nfunc replaceHeader(dst http.Header, src http.Header) {\n\tfor key := range dst {\n\t\tdelete(dst, key)\n\t}\n\tfor key, values := range src {\n\t\tdst[key] = append([]string(nil), values...)\n\t}\n}\n\n// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.\nfunc (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {\n\tstatus := http.StatusInternalServerError\n\tif msg != nil && msg.StatusCode > 0 {\n\t\tstatus = msg.StatusCode\n\t}\n\tif msg != nil && msg.Addon != nil && PassthroughHeadersEnabled(h.Cfg) {\n\t\tfor key, values := range msg.Addon {\n\t\t\tif len(values) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tc.Writer.Header().Del(key)\n\t\t\tfor _, value := range values {\n\t\t\t\tc.Writer.Header().Add(key, value)\n\t\t\t}\n\t\t}\n\t}\n\n\terrText := http.StatusText(status)\n\tif msg != nil && msg.Error != nil {\n\t\tif v := strings.TrimSpace(msg.Error.Error()); v != \"\" {\n\t\t\terrText = v\n\t\t}\n\t}\n\n\tbody := BuildErrorResponseBody(status, errText)\n\t// Append first to preserve upstream response logs, then drop duplicate payloads if already recorded.\n\tvar previous []byte\n\tif existing, exists := c.Get(\"API_RESPONSE\"); exists {\n\t\tif existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {\n\t\t\tprevious = existingBytes\n\t\t}\n\t}\n\tappendAPIResponse(c, body)\n\ttrimmedErrText := strings.TrimSpace(errText)\n\ttrimmedBody := bytes.TrimSpace(body)\n\tif len(previous) > 0 {\n\t\tif (trimmedErrText != \"\" && bytes.Contains(previous, []byte(trimmedErrText))) ||\n\t\t\t(len(trimmedBody) > 0 && bytes.Contains(previous, trimmedBody)) {\n\t\t\tc.Set(\"API_RESPONSE\", previous)\n\t\t}\n\t}\n\n\tif !c.Writer.Written() {\n\t\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\t}\n\tc.Status(status)\n\t_, _ = c.Writer.Write(body)\n}\n\nfunc (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) {\n\tif h.Cfg.RequestLog {\n\t\tif ginContext, ok := ctx.Value(\"gin\").(*gin.Context); ok {\n\t\t\tif apiResponseErrors, isExist := ginContext.Get(\"API_RESPONSE_ERROR\"); isExist {\n\t\t\t\tif slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk {\n\t\t\t\t\tslicesAPIResponseError = append(slicesAPIResponseError, err)\n\t\t\t\t\tginContext.Set(\"API_RESPONSE_ERROR\", slicesAPIResponseError)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Create new response data entry\n\t\t\t\tginContext.Set(\"API_RESPONSE_ERROR\", []*interfaces.ErrorMessage{err})\n\t\t\t}\n\t\t}\n\t}\n}\n\n// APIHandlerCancelFunc is a function type for canceling an API handler's context.\n// It can optionally accept parameters, which are used for logging the response.\ntype APIHandlerCancelFunc func(params ...interface{})\n"
  },
  {
    "path": "sdk/api/handlers/handlers_error_response_test.go",
    "content": "package handlers\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestWriteErrorResponse_AddonHeadersDisabledByDefault(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\tc.Request = httptest.NewRequest(http.MethodGet, \"/\", nil)\n\n\thandler := NewBaseAPIHandlers(nil, nil)\n\thandler.WriteErrorResponse(c, &interfaces.ErrorMessage{\n\t\tStatusCode: http.StatusTooManyRequests,\n\t\tError:      errors.New(\"rate limit\"),\n\t\tAddon: http.Header{\n\t\t\t\"Retry-After\":  {\"30\"},\n\t\t\t\"X-Request-Id\": {\"req-1\"},\n\t\t},\n\t})\n\n\tif recorder.Code != http.StatusTooManyRequests {\n\t\tt.Fatalf(\"status = %d, want %d\", recorder.Code, http.StatusTooManyRequests)\n\t}\n\tif got := recorder.Header().Get(\"Retry-After\"); got != \"\" {\n\t\tt.Fatalf(\"Retry-After should be empty when passthrough is disabled, got %q\", got)\n\t}\n\tif got := recorder.Header().Get(\"X-Request-Id\"); got != \"\" {\n\t\tt.Fatalf(\"X-Request-Id should be empty when passthrough is disabled, got %q\", got)\n\t}\n}\n\nfunc TestWriteErrorResponse_AddonHeadersEnabled(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\tc.Request = httptest.NewRequest(http.MethodGet, \"/\", nil)\n\tc.Writer.Header().Set(\"X-Request-Id\", \"old-value\")\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{PassthroughHeaders: true}, nil)\n\thandler.WriteErrorResponse(c, &interfaces.ErrorMessage{\n\t\tStatusCode: http.StatusTooManyRequests,\n\t\tError:      errors.New(\"rate limit\"),\n\t\tAddon: http.Header{\n\t\t\t\"Retry-After\":  {\"30\"},\n\t\t\t\"X-Request-Id\": {\"new-1\", \"new-2\"},\n\t\t},\n\t})\n\n\tif recorder.Code != http.StatusTooManyRequests {\n\t\tt.Fatalf(\"status = %d, want %d\", recorder.Code, http.StatusTooManyRequests)\n\t}\n\tif got := recorder.Header().Get(\"Retry-After\"); got != \"30\" {\n\t\tt.Fatalf(\"Retry-After = %q, want %q\", got, \"30\")\n\t}\n\tif got := recorder.Header().Values(\"X-Request-Id\"); !reflect.DeepEqual(got, []string{\"new-1\", \"new-2\"}) {\n\t\tt.Fatalf(\"X-Request-Id = %#v, want %#v\", got, []string{\"new-1\", \"new-2\"})\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/handlers_request_details_test.go",
    "content": "package handlers\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestGetRequestDetails_PreservesSuffix(t *testing.T) {\n\tmodelRegistry := registry.GetGlobalRegistry()\n\tnow := time.Now().Unix()\n\n\tmodelRegistry.RegisterClient(\"test-request-details-gemini\", \"gemini\", []*registry.ModelInfo{\n\t\t{ID: \"gemini-2.5-pro\", Created: now + 30},\n\t\t{ID: \"gemini-2.5-flash\", Created: now + 25},\n\t})\n\tmodelRegistry.RegisterClient(\"test-request-details-openai\", \"openai\", []*registry.ModelInfo{\n\t\t{ID: \"gpt-5.2\", Created: now + 20},\n\t})\n\tmodelRegistry.RegisterClient(\"test-request-details-claude\", \"claude\", []*registry.ModelInfo{\n\t\t{ID: \"claude-sonnet-4-5\", Created: now + 5},\n\t})\n\n\t// Ensure cleanup of all test registrations.\n\tclientIDs := []string{\n\t\t\"test-request-details-gemini\",\n\t\t\"test-request-details-openai\",\n\t\t\"test-request-details-claude\",\n\t}\n\tfor _, clientID := range clientIDs {\n\t\tid := clientID\n\t\tt.Cleanup(func() {\n\t\t\tmodelRegistry.UnregisterClient(id)\n\t\t})\n\t}\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil))\n\n\ttests := []struct {\n\t\tname          string\n\t\tinputModel    string\n\t\twantProviders []string\n\t\twantModel     string\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\tname:          \"numeric suffix preserved\",\n\t\t\tinputModel:    \"gemini-2.5-pro(8192)\",\n\t\t\twantProviders: []string{\"gemini\"},\n\t\t\twantModel:     \"gemini-2.5-pro(8192)\",\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:          \"level suffix preserved\",\n\t\t\tinputModel:    \"gpt-5.2(high)\",\n\t\t\twantProviders: []string{\"openai\"},\n\t\t\twantModel:     \"gpt-5.2(high)\",\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:          \"no suffix unchanged\",\n\t\t\tinputModel:    \"claude-sonnet-4-5\",\n\t\t\twantProviders: []string{\"claude\"},\n\t\t\twantModel:     \"claude-sonnet-4-5\",\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:          \"unknown model with suffix\",\n\t\t\tinputModel:    \"unknown-model(8192)\",\n\t\t\twantProviders: nil,\n\t\t\twantModel:     \"\",\n\t\t\twantErr:       true,\n\t\t},\n\t\t{\n\t\t\tname:          \"auto suffix resolved\",\n\t\t\tinputModel:    \"auto(high)\",\n\t\t\twantProviders: []string{\"gemini\"},\n\t\t\twantModel:     \"gemini-2.5-pro(high)\",\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:          \"special suffix none preserved\",\n\t\t\tinputModel:    \"gemini-2.5-flash(none)\",\n\t\t\twantProviders: []string{\"gemini\"},\n\t\t\twantModel:     \"gemini-2.5-flash(none)\",\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:          \"special suffix auto preserved\",\n\t\t\tinputModel:    \"claude-sonnet-4-5(auto)\",\n\t\t\twantProviders: []string{\"claude\"},\n\t\t\twantModel:     \"claude-sonnet-4-5(auto)\",\n\t\t\twantErr:       false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tproviders, model, errMsg := handler.getRequestDetails(tt.inputModel)\n\t\t\tif (errMsg != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"getRequestDetails() error = %v, wantErr %v\", errMsg, tt.wantErr)\n\t\t\t}\n\t\t\tif errMsg != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(providers, tt.wantProviders) {\n\t\t\t\tt.Fatalf(\"getRequestDetails() providers = %v, want %v\", providers, tt.wantProviders)\n\t\t\t}\n\t\t\tif model != tt.wantModel {\n\t\t\t\tt.Fatalf(\"getRequestDetails() model = %v, want %v\", model, tt.wantModel)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/handlers_stream_bootstrap_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcoreexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\ntype failOnceStreamExecutor struct {\n\tmu    sync.Mutex\n\tcalls int\n}\n\nfunc (e *failOnceStreamExecutor) Identifier() string { return \"codex\" }\n\nfunc (e *failOnceStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"Execute not implemented\"}\n}\n\nfunc (e *failOnceStreamExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\te.mu.Lock()\n\te.calls++\n\tcall := e.calls\n\te.mu.Unlock()\n\n\tch := make(chan coreexecutor.StreamChunk, 1)\n\tif call == 1 {\n\t\tch <- coreexecutor.StreamChunk{\n\t\t\tErr: &coreauth.Error{\n\t\t\t\tCode:       \"unauthorized\",\n\t\t\t\tMessage:    \"unauthorized\",\n\t\t\t\tRetryable:  false,\n\t\t\t\tHTTPStatus: http.StatusUnauthorized,\n\t\t\t},\n\t\t}\n\t\tclose(ch)\n\t\treturn &coreexecutor.StreamResult{\n\t\t\tHeaders: http.Header{\"X-Upstream-Attempt\": {\"1\"}},\n\t\t\tChunks:  ch,\n\t\t}, nil\n\t}\n\n\tch <- coreexecutor.StreamChunk{Payload: []byte(\"ok\")}\n\tclose(ch)\n\treturn &coreexecutor.StreamResult{\n\t\tHeaders: http.Header{\"X-Upstream-Attempt\": {\"2\"}},\n\t\tChunks:  ch,\n\t}, nil\n}\n\nfunc (e *failOnceStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *failOnceStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"CountTokens not implemented\"}\n}\n\nfunc (e *failOnceStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, &coreauth.Error{\n\t\tCode:       \"not_implemented\",\n\t\tMessage:    \"HttpRequest not implemented\",\n\t\tHTTPStatus: http.StatusNotImplemented,\n\t}\n}\n\nfunc (e *failOnceStreamExecutor) Calls() int {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn e.calls\n}\n\ntype payloadThenErrorStreamExecutor struct {\n\tmu    sync.Mutex\n\tcalls int\n}\n\nfunc (e *payloadThenErrorStreamExecutor) Identifier() string { return \"codex\" }\n\nfunc (e *payloadThenErrorStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"Execute not implemented\"}\n}\n\nfunc (e *payloadThenErrorStreamExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\te.mu.Lock()\n\te.calls++\n\te.mu.Unlock()\n\n\tch := make(chan coreexecutor.StreamChunk, 2)\n\tch <- coreexecutor.StreamChunk{Payload: []byte(\"partial\")}\n\tch <- coreexecutor.StreamChunk{\n\t\tErr: &coreauth.Error{\n\t\t\tCode:       \"upstream_closed\",\n\t\t\tMessage:    \"upstream closed\",\n\t\t\tRetryable:  false,\n\t\t\tHTTPStatus: http.StatusBadGateway,\n\t\t},\n\t}\n\tclose(ch)\n\treturn &coreexecutor.StreamResult{Chunks: ch}, nil\n}\n\nfunc (e *payloadThenErrorStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *payloadThenErrorStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"CountTokens not implemented\"}\n}\n\nfunc (e *payloadThenErrorStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, &coreauth.Error{\n\t\tCode:       \"not_implemented\",\n\t\tMessage:    \"HttpRequest not implemented\",\n\t\tHTTPStatus: http.StatusNotImplemented,\n\t}\n}\n\nfunc (e *payloadThenErrorStreamExecutor) Calls() int {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn e.calls\n}\n\ntype authAwareStreamExecutor struct {\n\tmu      sync.Mutex\n\tcalls   int\n\tauthIDs []string\n}\n\ntype invalidJSONStreamExecutor struct{}\n\nfunc (e *invalidJSONStreamExecutor) Identifier() string { return \"codex\" }\n\nfunc (e *invalidJSONStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"Execute not implemented\"}\n}\n\nfunc (e *invalidJSONStreamExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\tch := make(chan coreexecutor.StreamChunk, 1)\n\tch <- coreexecutor.StreamChunk{Payload: []byte(\"event: response.completed\\ndata: {\\\"type\\\"\")}\n\tclose(ch)\n\treturn &coreexecutor.StreamResult{Chunks: ch}, nil\n}\n\nfunc (e *invalidJSONStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *invalidJSONStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"CountTokens not implemented\"}\n}\n\nfunc (e *invalidJSONStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, &coreauth.Error{\n\t\tCode:       \"not_implemented\",\n\t\tMessage:    \"HttpRequest not implemented\",\n\t\tHTTPStatus: http.StatusNotImplemented,\n\t}\n}\n\nfunc (e *authAwareStreamExecutor) Identifier() string { return \"codex\" }\n\nfunc (e *authAwareStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"Execute not implemented\"}\n}\n\nfunc (e *authAwareStreamExecutor) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\t_ = ctx\n\t_ = req\n\t_ = opts\n\tch := make(chan coreexecutor.StreamChunk, 1)\n\n\tauthID := \"\"\n\tif auth != nil {\n\t\tauthID = auth.ID\n\t}\n\n\te.mu.Lock()\n\te.calls++\n\te.authIDs = append(e.authIDs, authID)\n\te.mu.Unlock()\n\n\tif authID == \"auth1\" {\n\t\tch <- coreexecutor.StreamChunk{\n\t\t\tErr: &coreauth.Error{\n\t\t\t\tCode:       \"unauthorized\",\n\t\t\t\tMessage:    \"unauthorized\",\n\t\t\t\tRetryable:  false,\n\t\t\t\tHTTPStatus: http.StatusUnauthorized,\n\t\t\t},\n\t\t}\n\t\tclose(ch)\n\t\treturn &coreexecutor.StreamResult{Chunks: ch}, nil\n\t}\n\n\tch <- coreexecutor.StreamChunk{Payload: []byte(\"ok\")}\n\tclose(ch)\n\treturn &coreexecutor.StreamResult{Chunks: ch}, nil\n}\n\nfunc (e *authAwareStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *authAwareStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, &coreauth.Error{Code: \"not_implemented\", Message: \"CountTokens not implemented\"}\n}\n\nfunc (e *authAwareStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, &coreauth.Error{\n\t\tCode:       \"not_implemented\",\n\t\tMessage:    \"HttpRequest not implemented\",\n\t\tHTTPStatus: http.StatusNotImplemented,\n\t}\n}\n\nfunc (e *authAwareStreamExecutor) Calls() int {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn e.calls\n}\n\nfunc (e *authAwareStreamExecutor) AuthIDs() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tout := make([]string, len(e.authIDs))\n\tcopy(out, e.authIDs)\n\treturn out\n}\n\nfunc TestExecuteStreamWithAuthManager_RetriesBeforeFirstByte(t *testing.T) {\n\texecutor := &failOnceStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth1 := &coreauth.Auth{\n\t\tID:       \"auth1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test1@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth1); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth1): %v\", err)\n\t}\n\n\tauth2 := &coreauth.Auth{\n\t\tID:       \"auth2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test2@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth2); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth2): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tregistry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth1.ID)\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth2.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{\n\t\tPassthroughHeaders: true,\n\t\tStreaming: sdkconfig.StreamingConfig{\n\t\t\tBootstrapRetries: 1,\n\t\t},\n\t}, manager)\n\tdataChan, upstreamHeaders, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), \"openai\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\n\tfor msg := range errChan {\n\t\tif msg != nil {\n\t\t\tt.Fatalf(\"unexpected error: %+v\", msg)\n\t\t}\n\t}\n\n\tif string(got) != \"ok\" {\n\t\tt.Fatalf(\"expected payload ok, got %q\", string(got))\n\t}\n\tif executor.Calls() != 2 {\n\t\tt.Fatalf(\"expected 2 stream attempts, got %d\", executor.Calls())\n\t}\n\tupstreamAttemptHeader := upstreamHeaders.Get(\"X-Upstream-Attempt\")\n\tif upstreamAttemptHeader != \"2\" {\n\t\tt.Fatalf(\"expected upstream header from retry attempt, got %q\", upstreamAttemptHeader)\n\t}\n}\n\nfunc TestExecuteStreamWithAuthManager_HeaderPassthroughDisabledByDefault(t *testing.T) {\n\texecutor := &failOnceStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth1 := &coreauth.Auth{\n\t\tID:       \"auth1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test1@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth1); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth1): %v\", err)\n\t}\n\n\tauth2 := &coreauth.Auth{\n\t\tID:       \"auth2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test2@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth2); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth2): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tregistry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth1.ID)\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth2.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{\n\t\tStreaming: sdkconfig.StreamingConfig{\n\t\t\tBootstrapRetries: 1,\n\t\t},\n\t}, manager)\n\tdataChan, upstreamHeaders, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), \"openai\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\tfor msg := range errChan {\n\t\tif msg != nil {\n\t\t\tt.Fatalf(\"unexpected error: %+v\", msg)\n\t\t}\n\t}\n\n\tif string(got) != \"ok\" {\n\t\tt.Fatalf(\"expected payload ok, got %q\", string(got))\n\t}\n\tif upstreamHeaders != nil {\n\t\tt.Fatalf(\"expected nil upstream headers when passthrough is disabled, got %#v\", upstreamHeaders)\n\t}\n}\n\nfunc TestExecuteStreamWithAuthManager_DoesNotRetryAfterFirstByte(t *testing.T) {\n\texecutor := &payloadThenErrorStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth1 := &coreauth.Auth{\n\t\tID:       \"auth1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test1@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth1); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth1): %v\", err)\n\t}\n\n\tauth2 := &coreauth.Auth{\n\t\tID:       \"auth2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test2@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth2); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth2): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tregistry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth1.ID)\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth2.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{\n\t\tStreaming: sdkconfig.StreamingConfig{\n\t\t\tBootstrapRetries: 1,\n\t\t},\n\t}, manager)\n\tdataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), \"openai\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\n\tvar gotErr error\n\tvar gotStatus int\n\tfor msg := range errChan {\n\t\tif msg != nil && msg.Error != nil {\n\t\t\tgotErr = msg.Error\n\t\t\tgotStatus = msg.StatusCode\n\t\t}\n\t}\n\n\tif string(got) != \"partial\" {\n\t\tt.Fatalf(\"expected payload partial, got %q\", string(got))\n\t}\n\tif gotErr == nil {\n\t\tt.Fatalf(\"expected terminal error, got nil\")\n\t}\n\tif gotStatus != http.StatusBadGateway {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusBadGateway, gotStatus)\n\t}\n\tif executor.Calls() != 1 {\n\t\tt.Fatalf(\"expected 1 stream attempt, got %d\", executor.Calls())\n\t}\n}\n\nfunc TestExecuteStreamWithAuthManager_PinnedAuthKeepsSameUpstream(t *testing.T) {\n\texecutor := &authAwareStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth1 := &coreauth.Auth{\n\t\tID:       \"auth1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test1@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth1); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth1): %v\", err)\n\t}\n\n\tauth2 := &coreauth.Auth{\n\t\tID:       \"auth2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test2@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth2); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth2): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tregistry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth1.ID)\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth2.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{\n\t\tStreaming: sdkconfig.StreamingConfig{\n\t\t\tBootstrapRetries: 1,\n\t\t},\n\t}, manager)\n\tctx := WithPinnedAuthID(context.Background(), \"auth1\")\n\tdataChan, _, errChan := handler.ExecuteStreamWithAuthManager(ctx, \"openai\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\n\tvar gotErr error\n\tfor msg := range errChan {\n\t\tif msg != nil && msg.Error != nil {\n\t\t\tgotErr = msg.Error\n\t\t}\n\t}\n\n\tif len(got) != 0 {\n\t\tt.Fatalf(\"expected empty payload, got %q\", string(got))\n\t}\n\tif gotErr == nil {\n\t\tt.Fatalf(\"expected terminal error, got nil\")\n\t}\n\tauthIDs := executor.AuthIDs()\n\tif len(authIDs) == 0 {\n\t\tt.Fatalf(\"expected at least one upstream attempt\")\n\t}\n\tfor _, authID := range authIDs {\n\t\tif authID != \"auth1\" {\n\t\t\tt.Fatalf(\"expected all attempts on auth1, got sequence %v\", authIDs)\n\t\t}\n\t}\n}\n\nfunc TestExecuteStreamWithAuthManager_SelectedAuthCallbackReceivesAuthID(t *testing.T) {\n\texecutor := &authAwareStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth2 := &coreauth.Auth{\n\t\tID:       \"auth2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test2@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth2); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth2): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth2.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{\n\t\tStreaming: sdkconfig.StreamingConfig{\n\t\t\tBootstrapRetries: 0,\n\t\t},\n\t}, manager)\n\n\tselectedAuthID := \"\"\n\tctx := WithSelectedAuthIDCallback(context.Background(), func(authID string) {\n\t\tselectedAuthID = authID\n\t})\n\tdataChan, _, errChan := handler.ExecuteStreamWithAuthManager(ctx, \"openai\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\tfor msg := range errChan {\n\t\tif msg != nil {\n\t\t\tt.Fatalf(\"unexpected error: %+v\", msg)\n\t\t}\n\t}\n\n\tif string(got) != \"ok\" {\n\t\tt.Fatalf(\"expected payload ok, got %q\", string(got))\n\t}\n\tif selectedAuthID != \"auth2\" {\n\t\tt.Fatalf(\"selectedAuthID = %q, want %q\", selectedAuthID, \"auth2\")\n\t}\n}\n\nfunc TestExecuteStreamWithAuthManager_ValidatesOpenAIResponsesStreamDataJSON(t *testing.T) {\n\texecutor := &invalidJSONStreamExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth1 := &coreauth.Auth{\n\t\tID:       \"auth1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tMetadata: map[string]any{\"email\": \"test1@example.com\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth1); err != nil {\n\t\tt.Fatalf(\"manager.Register(auth1): %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth1.ID)\n\t})\n\n\thandler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\tdataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), \"openai-response\", \"test-model\", []byte(`{\"model\":\"test-model\"}`), \"\")\n\tif dataChan == nil || errChan == nil {\n\t\tt.Fatalf(\"expected non-nil channels\")\n\t}\n\n\tvar got []byte\n\tfor chunk := range dataChan {\n\t\tgot = append(got, chunk...)\n\t}\n\tif len(got) != 0 {\n\t\tt.Fatalf(\"expected empty payload, got %q\", string(got))\n\t}\n\n\tgotErr := false\n\tfor msg := range errChan {\n\t\tif msg == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif msg.StatusCode != http.StatusBadGateway {\n\t\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusBadGateway, msg.StatusCode)\n\t\t}\n\t\tif msg.Error == nil {\n\t\t\tt.Fatalf(\"expected error\")\n\t\t}\n\t\tgotErr = true\n\t}\n\tif !gotErr {\n\t\tt.Fatalf(\"expected terminal error\")\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/header_filter.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n)\n\n// hopByHopHeaders lists RFC 7230 Section 6.1 hop-by-hop headers that MUST NOT\n// be forwarded by proxies, plus security-sensitive headers that should not leak.\nvar hopByHopHeaders = map[string]struct{}{\n\t// RFC 7230 hop-by-hop\n\t\"Connection\":          {},\n\t\"Keep-Alive\":          {},\n\t\"Proxy-Authenticate\":  {},\n\t\"Proxy-Authorization\": {},\n\t\"Te\":                  {},\n\t\"Trailer\":             {},\n\t\"Transfer-Encoding\":   {},\n\t\"Upgrade\":             {},\n\t// Security-sensitive\n\t\"Set-Cookie\": {},\n\t// CPA-managed (set by handlers, not upstream)\n\t\"Content-Length\":   {},\n\t\"Content-Encoding\": {},\n}\n\n// FilterUpstreamHeaders returns a copy of src with hop-by-hop and security-sensitive\n// headers removed. Returns nil if src is nil or empty after filtering.\nfunc FilterUpstreamHeaders(src http.Header) http.Header {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tconnectionScoped := connectionScopedHeaders(src)\n\tdst := make(http.Header)\n\tfor key, values := range src {\n\t\tcanonicalKey := http.CanonicalHeaderKey(key)\n\t\tif _, blocked := hopByHopHeaders[canonicalKey]; blocked {\n\t\t\tcontinue\n\t\t}\n\t\tif _, scoped := connectionScoped[canonicalKey]; scoped {\n\t\t\tcontinue\n\t\t}\n\t\tdst[key] = values\n\t}\n\tif len(dst) == 0 {\n\t\treturn nil\n\t}\n\treturn dst\n}\n\nfunc connectionScopedHeaders(src http.Header) map[string]struct{} {\n\tscoped := make(map[string]struct{})\n\tfor _, rawValue := range src.Values(\"Connection\") {\n\t\tfor _, token := range strings.Split(rawValue, \",\") {\n\t\t\theaderName := strings.TrimSpace(token)\n\t\t\tif headerName == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tscoped[http.CanonicalHeaderKey(headerName)] = struct{}{}\n\t\t}\n\t}\n\treturn scoped\n}\n\n// WriteUpstreamHeaders writes filtered upstream headers to the gin response writer.\n// Headers already set by CPA (e.g., Content-Type) are NOT overwritten.\nfunc WriteUpstreamHeaders(dst http.Header, src http.Header) {\n\tif src == nil {\n\t\treturn\n\t}\n\tfor key, values := range src {\n\t\t// Don't overwrite headers already set by CPA handlers\n\t\tif dst.Get(key) != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, v := range values {\n\t\t\tdst.Add(key, v)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/header_filter_test.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestFilterUpstreamHeaders_RemovesConnectionScopedHeaders(t *testing.T) {\n\tsrc := http.Header{}\n\tsrc.Add(\"Connection\", \"keep-alive, x-hop-a, x-hop-b\")\n\tsrc.Add(\"Connection\", \"x-hop-c\")\n\tsrc.Set(\"Keep-Alive\", \"timeout=5\")\n\tsrc.Set(\"X-Hop-A\", \"a\")\n\tsrc.Set(\"X-Hop-B\", \"b\")\n\tsrc.Set(\"X-Hop-C\", \"c\")\n\tsrc.Set(\"X-Request-Id\", \"req-1\")\n\tsrc.Set(\"Set-Cookie\", \"session=secret\")\n\n\tfiltered := FilterUpstreamHeaders(src)\n\tif filtered == nil {\n\t\tt.Fatalf(\"expected filtered headers, got nil\")\n\t}\n\n\trequestID := filtered.Get(\"X-Request-Id\")\n\tif requestID != \"req-1\" {\n\t\tt.Fatalf(\"expected X-Request-Id to be preserved, got %q\", requestID)\n\t}\n\n\tblockedHeaderKeys := []string{\n\t\t\"Connection\",\n\t\t\"Keep-Alive\",\n\t\t\"X-Hop-A\",\n\t\t\"X-Hop-B\",\n\t\t\"X-Hop-C\",\n\t\t\"Set-Cookie\",\n\t}\n\tfor _, key := range blockedHeaderKeys {\n\t\tvalue := filtered.Get(key)\n\t\tif value != \"\" {\n\t\t\tt.Fatalf(\"expected %s to be removed, got %q\", key, value)\n\t\t}\n\t}\n}\n\nfunc TestFilterUpstreamHeaders_ReturnsNilWhenAllHeadersBlocked(t *testing.T) {\n\tsrc := http.Header{}\n\tsrc.Add(\"Connection\", \"x-hop-a\")\n\tsrc.Set(\"X-Hop-A\", \"a\")\n\tsrc.Set(\"Set-Cookie\", \"session=secret\")\n\n\tfiltered := FilterUpstreamHeaders(src)\n\tif filtered != nil {\n\t\tt.Fatalf(\"expected nil when all headers are filtered, got %#v\", filtered)\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_handlers.go",
    "content": "// Package openai provides HTTP handlers for OpenAI API endpoints.\n// This package implements the OpenAI-compatible API interface, including model listing\n// and chat completion functionality. It supports both streaming and non-streaming responses,\n// and manages a pool of clients to interact with backend services.\n// The handlers translate OpenAI API requests to the appropriate backend format and\n// convert responses back to OpenAI-compatible format.\npackage openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sync\"\n\n\t\"github.com/gin-gonic/gin\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tresponsesconverter \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// OpenAIAPIHandler contains the handlers for OpenAI API endpoints.\n// It holds a pool of clients to interact with the backend service.\ntype OpenAIAPIHandler struct {\n\t*handlers.BaseAPIHandler\n}\n\n// NewOpenAIAPIHandler creates a new OpenAI API handlers instance.\n// It takes an BaseAPIHandler instance as input and returns an OpenAIAPIHandler.\n//\n// Parameters:\n//   - apiHandlers: The base API handlers instance\n//\n// Returns:\n//   - *OpenAIAPIHandler: A new OpenAI API handlers instance\nfunc NewOpenAIAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIAPIHandler {\n\treturn &OpenAIAPIHandler{\n\t\tBaseAPIHandler: apiHandlers,\n\t}\n}\n\n// HandlerType returns the identifier for this handler implementation.\nfunc (h *OpenAIAPIHandler) HandlerType() string {\n\treturn OpenAI\n}\n\n// Models returns the OpenAI-compatible model metadata supported by this handler.\nfunc (h *OpenAIAPIHandler) Models() []map[string]any {\n\t// Get dynamic models from the global registry\n\tmodelRegistry := registry.GetGlobalRegistry()\n\treturn modelRegistry.GetAvailableModels(\"openai\")\n}\n\n// OpenAIModels handles the /v1/models endpoint.\n// It returns a list of available AI models with their capabilities\n// and specifications in OpenAI-compatible format.\nfunc (h *OpenAIAPIHandler) OpenAIModels(c *gin.Context) {\n\t// Get all available models\n\tallModels := h.Models()\n\n\t// Filter to only include the 4 required fields: id, object, created, owned_by\n\tfilteredModels := make([]map[string]any, len(allModels))\n\tfor i, model := range allModels {\n\t\tfilteredModel := map[string]any{\n\t\t\t\"id\":     model[\"id\"],\n\t\t\t\"object\": model[\"object\"],\n\t\t}\n\n\t\t// Add created field if it exists\n\t\tif created, exists := model[\"created\"]; exists {\n\t\t\tfilteredModel[\"created\"] = created\n\t\t}\n\n\t\t// Add owned_by field if it exists\n\t\tif ownedBy, exists := model[\"owned_by\"]; exists {\n\t\t\tfilteredModel[\"owned_by\"] = ownedBy\n\t\t}\n\n\t\tfilteredModels[i] = filteredModel\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"object\": \"list\",\n\t\t\"data\":   filteredModels,\n\t})\n}\n\n// ChatCompletions handles the /v1/chat/completions endpoint.\n// It determines whether the request is for a streaming or non-streaming response\n// and calls the appropriate handler based on the model provider.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\nfunc (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {\n\trawJSON, err := c.GetRawData()\n\t// If data retrieval fails, return a 400 Bad Request error.\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if the client requested a streaming response.\n\tstreamResult := gjson.GetBytes(rawJSON, \"stream\")\n\tstream := streamResult.Type == gjson.True\n\n\t// Some clients send OpenAI Responses-format payloads to /v1/chat/completions.\n\t// Convert them to Chat Completions so downstream translators preserve tool metadata.\n\tif shouldTreatAsResponsesFormat(rawJSON) {\n\t\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\t\trawJSON = responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName, rawJSON, stream)\n\t\tstream = gjson.GetBytes(rawJSON, \"stream\").Bool()\n\t}\n\n\tif stream {\n\t\th.handleStreamingResponse(c, rawJSON)\n\t} else {\n\t\th.handleNonStreamingResponse(c, rawJSON)\n\t}\n\n}\n\n// shouldTreatAsResponsesFormat detects OpenAI Responses-style payloads that are\n// accidentally sent to the Chat Completions endpoint.\nfunc shouldTreatAsResponsesFormat(rawJSON []byte) bool {\n\tif gjson.GetBytes(rawJSON, \"messages\").Exists() {\n\t\treturn false\n\t}\n\tif gjson.GetBytes(rawJSON, \"input\").Exists() {\n\t\treturn true\n\t}\n\tif gjson.GetBytes(rawJSON, \"instructions\").Exists() {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// Completions handles the /v1/completions endpoint.\n// It determines whether the request is for a streaming or non-streaming response\n// and calls the appropriate handler based on the model provider.\n// This endpoint follows the OpenAI completions API specification.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\nfunc (h *OpenAIAPIHandler) Completions(c *gin.Context) {\n\trawJSON, err := c.GetRawData()\n\t// If data retrieval fails, return a 400 Bad Request error.\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if the client requested a streaming response.\n\tstreamResult := gjson.GetBytes(rawJSON, \"stream\")\n\tif streamResult.Type == gjson.True {\n\t\th.handleCompletionsStreamingResponse(c, rawJSON)\n\t} else {\n\t\th.handleCompletionsNonStreamingResponse(c, rawJSON)\n\t}\n\n}\n\n// convertCompletionsRequestToChatCompletions converts OpenAI completions API request to chat completions format.\n// This allows the completions endpoint to use the existing chat completions infrastructure.\n//\n// Parameters:\n//   - rawJSON: The raw JSON bytes of the completions request\n//\n// Returns:\n//   - []byte: The converted chat completions request\nfunc convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Extract prompt from completions request\n\tprompt := root.Get(\"prompt\").String()\n\tif prompt == \"\" {\n\t\tprompt = \"Complete this:\"\n\t}\n\n\t// Create chat completions structure\n\tout := `{\"model\":\"\",\"messages\":[{\"role\":\"user\",\"content\":\"\"}]}`\n\n\t// Set model\n\tif model := root.Get(\"model\"); model.Exists() {\n\t\tout, _ = sjson.Set(out, \"model\", model.String())\n\t}\n\n\t// Set the prompt as user message content\n\tout, _ = sjson.Set(out, \"messages.0.content\", prompt)\n\n\t// Copy other parameters from completions to chat completions\n\tif maxTokens := root.Get(\"max_tokens\"); maxTokens.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t}\n\n\tif temperature := root.Get(\"temperature\"); temperature.Exists() {\n\t\tout, _ = sjson.Set(out, \"temperature\", temperature.Float())\n\t}\n\n\tif topP := root.Get(\"top_p\"); topP.Exists() {\n\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t}\n\n\tif frequencyPenalty := root.Get(\"frequency_penalty\"); frequencyPenalty.Exists() {\n\t\tout, _ = sjson.Set(out, \"frequency_penalty\", frequencyPenalty.Float())\n\t}\n\n\tif presencePenalty := root.Get(\"presence_penalty\"); presencePenalty.Exists() {\n\t\tout, _ = sjson.Set(out, \"presence_penalty\", presencePenalty.Float())\n\t}\n\n\tif stop := root.Get(\"stop\"); stop.Exists() {\n\t\tout, _ = sjson.SetRaw(out, \"stop\", stop.Raw)\n\t}\n\n\tif stream := root.Get(\"stream\"); stream.Exists() {\n\t\tout, _ = sjson.Set(out, \"stream\", stream.Bool())\n\t}\n\n\tif logprobs := root.Get(\"logprobs\"); logprobs.Exists() {\n\t\tout, _ = sjson.Set(out, \"logprobs\", logprobs.Bool())\n\t}\n\n\tif topLogprobs := root.Get(\"top_logprobs\"); topLogprobs.Exists() {\n\t\tout, _ = sjson.Set(out, \"top_logprobs\", topLogprobs.Int())\n\t}\n\n\tif echo := root.Get(\"echo\"); echo.Exists() {\n\t\tout, _ = sjson.Set(out, \"echo\", echo.Bool())\n\t}\n\n\treturn []byte(out)\n}\n\n// convertChatCompletionsResponseToCompletions converts chat completions API response back to completions format.\n// This ensures the completions endpoint returns data in the expected format.\n//\n// Parameters:\n//   - rawJSON: The raw JSON bytes of the chat completions response\n//\n// Returns:\n//   - []byte: The converted completions response\nfunc convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// Base completions response structure\n\tout := `{\"id\":\"\",\"object\":\"text_completion\",\"created\":0,\"model\":\"\",\"choices\":[]}`\n\n\t// Copy basic fields\n\tif id := root.Get(\"id\"); id.Exists() {\n\t\tout, _ = sjson.Set(out, \"id\", id.String())\n\t}\n\n\tif created := root.Get(\"created\"); created.Exists() {\n\t\tout, _ = sjson.Set(out, \"created\", created.Int())\n\t}\n\n\tif model := root.Get(\"model\"); model.Exists() {\n\t\tout, _ = sjson.Set(out, \"model\", model.String())\n\t}\n\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tout, _ = sjson.SetRaw(out, \"usage\", usage.Raw)\n\t}\n\n\t// Convert choices from chat completions to completions format\n\tvar choices []interface{}\n\tif chatChoices := root.Get(\"choices\"); chatChoices.Exists() && chatChoices.IsArray() {\n\t\tchatChoices.ForEach(func(_, choice gjson.Result) bool {\n\t\t\tcompletionsChoice := map[string]interface{}{\n\t\t\t\t\"index\": choice.Get(\"index\").Int(),\n\t\t\t}\n\n\t\t\t// Extract text content from message.content\n\t\t\tif message := choice.Get(\"message\"); message.Exists() {\n\t\t\t\tif content := message.Get(\"content\"); content.Exists() {\n\t\t\t\t\tcompletionsChoice[\"text\"] = content.String()\n\t\t\t\t}\n\t\t\t} else if delta := choice.Get(\"delta\"); delta.Exists() {\n\t\t\t\t// For streaming responses, use delta.content\n\t\t\t\tif content := delta.Get(\"content\"); content.Exists() {\n\t\t\t\t\tcompletionsChoice[\"text\"] = content.String()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Copy finish_reason\n\t\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() {\n\t\t\t\tcompletionsChoice[\"finish_reason\"] = finishReason.String()\n\t\t\t}\n\n\t\t\t// Copy logprobs if present\n\t\t\tif logprobs := choice.Get(\"logprobs\"); logprobs.Exists() {\n\t\t\t\tcompletionsChoice[\"logprobs\"] = logprobs.Value()\n\t\t\t}\n\n\t\t\tchoices = append(choices, completionsChoice)\n\t\t\treturn true\n\t\t})\n\t}\n\n\tif len(choices) > 0 {\n\t\tchoicesJSON, _ := json.Marshal(choices)\n\t\tout, _ = sjson.SetRaw(out, \"choices\", string(choicesJSON))\n\t}\n\n\treturn []byte(out)\n}\n\n// convertChatCompletionsStreamChunkToCompletions converts a streaming chat completions chunk to completions format.\n// This handles the real-time conversion of streaming response chunks and filters out empty text responses.\n//\n// Parameters:\n//   - chunkData: The raw JSON bytes of a single chat completions stream chunk\n//\n// Returns:\n//   - []byte: The converted completions stream chunk, or nil if should be filtered out\nfunc convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {\n\troot := gjson.ParseBytes(chunkData)\n\n\t// Check if this chunk has any meaningful content\n\thasContent := false\n\thasUsage := root.Get(\"usage\").Exists()\n\tif chatChoices := root.Get(\"choices\"); chatChoices.Exists() && chatChoices.IsArray() {\n\t\tchatChoices.ForEach(func(_, choice gjson.Result) bool {\n\t\t\t// Check if delta has content or finish_reason\n\t\t\tif delta := choice.Get(\"delta\"); delta.Exists() {\n\t\t\t\tif content := delta.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\t\t\thasContent = true\n\t\t\t\t\treturn false // Break out of forEach\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Also check for finish_reason to ensure we don't skip final chunks\n\t\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() && finishReason.String() != \"\" && finishReason.String() != \"null\" {\n\t\t\t\thasContent = true\n\t\t\t\treturn false // Break out of forEach\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// If no meaningful content and no usage, return nil to indicate this chunk should be skipped\n\tif !hasContent && !hasUsage {\n\t\treturn nil\n\t}\n\n\t// Base completions stream response structure\n\tout := `{\"id\":\"\",\"object\":\"text_completion\",\"created\":0,\"model\":\"\",\"choices\":[]}`\n\n\t// Copy basic fields\n\tif id := root.Get(\"id\"); id.Exists() {\n\t\tout, _ = sjson.Set(out, \"id\", id.String())\n\t}\n\n\tif created := root.Get(\"created\"); created.Exists() {\n\t\tout, _ = sjson.Set(out, \"created\", created.Int())\n\t}\n\n\tif model := root.Get(\"model\"); model.Exists() {\n\t\tout, _ = sjson.Set(out, \"model\", model.String())\n\t}\n\n\t// Convert choices from chat completions delta to completions format\n\tvar choices []interface{}\n\tif chatChoices := root.Get(\"choices\"); chatChoices.Exists() && chatChoices.IsArray() {\n\t\tchatChoices.ForEach(func(_, choice gjson.Result) bool {\n\t\t\tcompletionsChoice := map[string]interface{}{\n\t\t\t\t\"index\": choice.Get(\"index\").Int(),\n\t\t\t}\n\n\t\t\t// Extract text content from delta.content\n\t\t\tif delta := choice.Get(\"delta\"); delta.Exists() {\n\t\t\t\tif content := delta.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\t\t\tcompletionsChoice[\"text\"] = content.String()\n\t\t\t\t} else {\n\t\t\t\t\tcompletionsChoice[\"text\"] = \"\"\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tcompletionsChoice[\"text\"] = \"\"\n\t\t\t}\n\n\t\t\t// Copy finish_reason\n\t\t\tif finishReason := choice.Get(\"finish_reason\"); finishReason.Exists() && finishReason.String() != \"null\" {\n\t\t\t\tcompletionsChoice[\"finish_reason\"] = finishReason.String()\n\t\t\t}\n\n\t\t\t// Copy logprobs if present\n\t\t\tif logprobs := choice.Get(\"logprobs\"); logprobs.Exists() {\n\t\t\t\tcompletionsChoice[\"logprobs\"] = logprobs.Value()\n\t\t\t}\n\n\t\t\tchoices = append(choices, completionsChoice)\n\t\t\treturn true\n\t\t})\n\t}\n\n\tif len(choices) > 0 {\n\t\tchoicesJSON, _ := json.Marshal(choices)\n\t\tout, _ = sjson.SetRaw(out, \"choices\", string(choicesJSON))\n\t}\n\n\t// Copy usage if present\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tout, _ = sjson.SetRaw(out, \"usage\", usage.Raw)\n\t}\n\n\treturn []byte(out)\n}\n\n// handleNonStreamingResponse handles non-streaming chat completion responses\n// for Gemini models. It selects a client from the pool, sends the request, and\n// aggregates the response before sending it back to the client in OpenAI format.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAI-compatible request\nfunc (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// handleStreamingResponse handles streaming responses for Gemini models.\n// It establishes a streaming connection with the backend service and forwards\n// the response chunks to the client in real-time using Server-Sent Events.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAI-compatible request\nfunc (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {\n\t// Get the http.Flusher interface to manually flush the response.\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))\n\n\tsetSSEHeaders := func() {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Peek at the first chunk to determine success or failure before setting headers\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcliCancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase errMsg, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\t// Err channel closed cleanly; wait for data channel.\n\t\t\t\terrChan = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Upstream failed immediately. Return proper error status and JSON.\n\t\t\th.WriteErrorResponse(c, errMsg)\n\t\t\tif errMsg != nil {\n\t\t\t\tcliCancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcliCancel(nil)\n\t\t\t}\n\t\t\treturn\n\t\tcase chunk, ok := <-dataChan:\n\t\t\tif !ok {\n\t\t\t\t// Stream closed without data? Send DONE or just headers.\n\t\t\t\tsetSSEHeaders()\n\t\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: [DONE]\\n\\n\")\n\t\t\t\tflusher.Flush()\n\t\t\t\tcliCancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Success! Commit to streaming headers.\n\t\t\tsetSSEHeaders()\n\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\n\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunk))\n\t\t\tflusher.Flush()\n\n\t\t\t// Continue streaming the rest\n\t\t\th.handleStreamResult(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// handleCompletionsNonStreamingResponse handles non-streaming completions responses.\n// It converts completions request to chat completions format, sends to backend,\n// then converts the response back to completions format before sending to client.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request\nfunc (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\n\t// Convert completions request to chat completions format\n\tchatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)\n\n\tmodelName := gjson.GetBytes(chatCompletionsJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tstopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, \"\")\n\tstopKeepAlive()\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\tcompletionsResp := convertChatCompletionsResponseToCompletions(resp)\n\t_, _ = c.Writer.Write(completionsResp)\n\tcliCancel()\n}\n\n// handleCompletionsStreamingResponse handles streaming completions responses.\n// It converts completions request to chat completions format, streams from backend,\n// then converts each response chunk back to completions format before sending to client.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request\nfunc (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, rawJSON []byte) {\n\t// Get the http.Flusher interface to manually flush the response.\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// Convert completions request to chat completions format\n\tchatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)\n\n\tmodelName := gjson.GetBytes(chatCompletionsJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, \"\")\n\n\tsetSSEHeaders := func() {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Peek at the first chunk\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcliCancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase errMsg, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\t// Err channel closed cleanly; wait for data channel.\n\t\t\t\terrChan = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\th.WriteErrorResponse(c, errMsg)\n\t\t\tif errMsg != nil {\n\t\t\t\tcliCancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcliCancel(nil)\n\t\t\t}\n\t\t\treturn\n\t\tcase chunk, ok := <-dataChan:\n\t\t\tif !ok {\n\t\t\t\tsetSSEHeaders()\n\t\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: [DONE]\\n\\n\")\n\t\t\t\tflusher.Flush()\n\t\t\t\tcliCancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Success! Set headers.\n\t\t\tsetSSEHeaders()\n\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\n\t\t\t// Write the first chunk\n\t\t\tconverted := convertChatCompletionsStreamChunkToCompletions(chunk)\n\t\t\tif converted != nil {\n\t\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(converted))\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\n\t\t\tdone := make(chan struct{})\n\t\t\tvar doneOnce sync.Once\n\t\t\tstop := func() { doneOnce.Do(func() { close(done) }) }\n\n\t\t\tconvertedChan := make(chan []byte)\n\t\t\tgo func() {\n\t\t\t\tdefer close(convertedChan)\n\t\t\t\tfor {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-done:\n\t\t\t\t\t\treturn\n\t\t\t\t\tcase chunk, ok := <-dataChan:\n\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconverted := convertChatCompletionsStreamChunkToCompletions(chunk)\n\t\t\t\t\t\tif converted == nil {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase <-done:\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\tcase convertedChan <- converted:\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\th.handleStreamResult(c, flusher, func(err error) {\n\t\t\t\tstop()\n\t\t\t\tcliCancel(err)\n\t\t\t}, convertedChan, errChan)\n\t\t\treturn\n\t\t}\n\t}\n}\nfunc (h *OpenAIAPIHandler) handleStreamResult(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {\n\th.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{\n\t\tWriteChunk: func(chunk []byte) {\n\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunk))\n\t\t},\n\t\tWriteTerminalError: func(errMsg *interfaces.ErrorMessage) {\n\t\t\tif errMsg == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatus := http.StatusInternalServerError\n\t\t\tif errMsg.StatusCode > 0 {\n\t\t\t\tstatus = errMsg.StatusCode\n\t\t\t}\n\t\t\terrText := http.StatusText(status)\n\t\t\tif errMsg.Error != nil && errMsg.Error.Error() != \"\" {\n\t\t\t\terrText = errMsg.Error.Error()\n\t\t\t}\n\t\t\tbody := handlers.BuildErrorResponseBody(status, errText)\n\t\t\t_, _ = fmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(body))\n\t\t},\n\t\tWriteDone: func() {\n\t\t\t_, _ = fmt.Fprint(c.Writer, \"data: [DONE]\\n\\n\")\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_responses_compact_test.go",
    "content": "package openai\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcoreexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\ntype compactCaptureExecutor struct {\n\talt          string\n\tsourceFormat string\n\tcalls        int\n}\n\nfunc (e *compactCaptureExecutor) Identifier() string { return \"test-provider\" }\n\nfunc (e *compactCaptureExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {\n\te.calls++\n\te.alt = opts.Alt\n\te.sourceFormat = opts.SourceFormat.String()\n\treturn coreexecutor.Response{Payload: []byte(`{\"ok\":true}`)}, nil\n}\n\nfunc (e *compactCaptureExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\nfunc (e *compactCaptureExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *compactCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, errors.New(\"not implemented\")\n}\n\nfunc (e *compactCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\nfunc TestOpenAIResponsesCompactRejectsStream(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\texecutor := &compactCaptureExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth := &coreauth.Auth{ID: \"auth1\", Provider: executor.Identifier(), Status: coreauth.StatusActive}\n\tif _, err := manager.Register(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"Register auth: %v\", err)\n\t}\n\tregistry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth.ID)\n\t})\n\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\th := NewOpenAIResponsesAPIHandler(base)\n\trouter := gin.New()\n\trouter.POST(\"/v1/responses/compact\", h.Compact)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/v1/responses/compact\", strings.NewReader(`{\"model\":\"test-model\",\"stream\":true}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d\", resp.Code, http.StatusBadRequest)\n\t}\n\tif executor.calls != 0 {\n\t\tt.Fatalf(\"executor calls = %d, want 0\", executor.calls)\n\t}\n}\n\nfunc TestOpenAIResponsesCompactExecute(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\texecutor := &compactCaptureExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauth := &coreauth.Auth{ID: \"auth2\", Provider: executor.Identifier(), Status: coreauth.StatusActive}\n\tif _, err := manager.Register(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"Register auth: %v\", err)\n\t}\n\tregistry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth.ID)\n\t})\n\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\th := NewOpenAIResponsesAPIHandler(base)\n\trouter := gin.New()\n\trouter.POST(\"/v1/responses/compact\", h.Compact)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/v1/responses/compact\", strings.NewReader(`{\"model\":\"test-model\",\"input\":\"hello\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", resp.Code, http.StatusOK)\n\t}\n\tif executor.alt != \"responses/compact\" {\n\t\tt.Fatalf(\"alt = %q, want %q\", executor.alt, \"responses/compact\")\n\t}\n\tif executor.sourceFormat != \"openai-response\" {\n\t\tt.Fatalf(\"source format = %q, want %q\", executor.sourceFormat, \"openai-response\")\n\t}\n\tif strings.TrimSpace(resp.Body.String()) != `{\"ok\":true}` {\n\t\tt.Fatalf(\"body = %s\", resp.Body.String())\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_responses_handlers.go",
    "content": "// Package openai provides HTTP handlers for OpenAIResponses API endpoints.\n// This package implements the OpenAIResponses-compatible API interface, including model listing\n// and chat completion functionality. It supports both streaming and non-streaming responses,\n// and manages a pool of clients to interact with backend services.\n// The handlers translate OpenAIResponses API requests to the appropriate backend format and\n// convert responses back to OpenAIResponses-compatible format.\npackage openai\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t. \"github.com/router-for-me/CLIProxyAPI/v6/internal/constant\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints.\n// It holds a pool of clients to interact with the backend service.\ntype OpenAIResponsesAPIHandler struct {\n\t*handlers.BaseAPIHandler\n}\n\n// NewOpenAIResponsesAPIHandler creates a new OpenAIResponses API handlers instance.\n// It takes an BaseAPIHandler instance as input and returns an OpenAIResponsesAPIHandler.\n//\n// Parameters:\n//   - apiHandlers: The base API handlers instance\n//\n// Returns:\n//   - *OpenAIResponsesAPIHandler: A new OpenAIResponses API handlers instance\nfunc NewOpenAIResponsesAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIResponsesAPIHandler {\n\treturn &OpenAIResponsesAPIHandler{\n\t\tBaseAPIHandler: apiHandlers,\n\t}\n}\n\n// HandlerType returns the identifier for this handler implementation.\nfunc (h *OpenAIResponsesAPIHandler) HandlerType() string {\n\treturn OpenaiResponse\n}\n\n// Models returns the OpenAIResponses-compatible model metadata supported by this handler.\nfunc (h *OpenAIResponsesAPIHandler) Models() []map[string]any {\n\t// Get dynamic models from the global registry\n\tmodelRegistry := registry.GetGlobalRegistry()\n\treturn modelRegistry.GetAvailableModels(\"openai\")\n}\n\n// OpenAIResponsesModels handles the /v1/models endpoint.\n// It returns a list of available AI models with their capabilities\n// and specifications in OpenAIResponses-compatible format.\nfunc (h *OpenAIResponsesAPIHandler) OpenAIResponsesModels(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"object\": \"list\",\n\t\t\"data\":   h.Models(),\n\t})\n}\n\n// Responses handles the /v1/responses endpoint.\n// It determines whether the request is for a streaming or non-streaming response\n// and calls the appropriate handler based on the model provider.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\nfunc (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {\n\trawJSON, err := c.GetRawData()\n\t// If data retrieval fails, return a 400 Bad Request error.\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if the client requested a streaming response.\n\tstreamResult := gjson.GetBytes(rawJSON, \"stream\")\n\tif streamResult.Type == gjson.True {\n\t\th.handleStreamingResponse(c, rawJSON)\n\t} else {\n\t\th.handleNonStreamingResponse(c, rawJSON)\n\t}\n\n}\n\nfunc (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {\n\trawJSON, err := c.GetRawData()\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: fmt.Sprintf(\"Invalid request: %v\", err),\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tstreamResult := gjson.GetBytes(rawJSON, \"stream\")\n\tif streamResult.Type == gjson.True {\n\t\tc.JSON(http.StatusBadRequest, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported for compact responses\",\n\t\t\t\tType:    \"invalid_request_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\tif streamResult.Exists() {\n\t\tif updated, err := sjson.DeleteBytes(rawJSON, \"stream\"); err == nil {\n\t\t\trawJSON = updated\n\t\t}\n\t}\n\n\tc.Header(\"Content-Type\", \"application/json\")\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tstopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"responses/compact\")\n\tstopKeepAlive()\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// handleNonStreamingResponse handles non-streaming chat completion responses\n// for Gemini models. It selects a client from the pool, sends the request, and\n// aggregates the response before sending it back to the client in OpenAIResponses format.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request\nfunc (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {\n\tc.Header(\"Content-Type\", \"application/json\")\n\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tstopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)\n\n\tresp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"\")\n\tstopKeepAlive()\n\tif errMsg != nil {\n\t\th.WriteErrorResponse(c, errMsg)\n\t\tcliCancel(errMsg.Error)\n\t\treturn\n\t}\n\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t_, _ = c.Writer.Write(resp)\n\tcliCancel()\n}\n\n// handleStreamingResponse handles streaming responses for Gemini models.\n// It establishes a streaming connection with the backend service and forwards\n// the response chunks to the client in real-time using Server-Sent Events.\n//\n// Parameters:\n//   - c: The Gin context containing the HTTP request and response\n//   - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request\nfunc (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {\n\t// Get the http.Flusher interface to manually flush the response.\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tc.JSON(http.StatusInternalServerError, handlers.ErrorResponse{\n\t\t\tError: handlers.ErrorDetail{\n\t\t\t\tMessage: \"Streaming not supported\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// New core execution path\n\tmodelName := gjson.GetBytes(rawJSON, \"model\").String()\n\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\tdataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, \"\")\n\n\tsetSSEHeaders := func() {\n\t\tc.Header(\"Content-Type\", \"text/event-stream\")\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Header(\"Connection\", \"keep-alive\")\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t}\n\n\t// Peek at the first chunk\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcliCancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase errMsg, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\t// Err channel closed cleanly; wait for data channel.\n\t\t\t\terrChan = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Upstream failed immediately. Return proper error status and JSON.\n\t\t\th.WriteErrorResponse(c, errMsg)\n\t\t\tif errMsg != nil {\n\t\t\t\tcliCancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcliCancel(nil)\n\t\t\t}\n\t\t\treturn\n\t\tcase chunk, ok := <-dataChan:\n\t\t\tif !ok {\n\t\t\t\t// Stream closed without data? Send headers and done.\n\t\t\t\tsetSSEHeaders()\n\t\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t\t\tflusher.Flush()\n\t\t\t\tcliCancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Success! Set headers.\n\t\t\tsetSSEHeaders()\n\t\t\thandlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)\n\n\t\t\t// Write first chunk logic (matching forwardResponsesStream)\n\t\t\tif bytes.HasPrefix(chunk, []byte(\"event:\")) {\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t\t}\n\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t\tflusher.Flush()\n\n\t\t\t// Continue\n\t\t\th.forwardResponsesStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {\n\th.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{\n\t\tWriteChunk: func(chunk []byte) {\n\t\t\tif bytes.HasPrefix(chunk, []byte(\"event:\")) {\n\t\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t\t}\n\t\t\t_, _ = c.Writer.Write(chunk)\n\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t},\n\t\tWriteTerminalError: func(errMsg *interfaces.ErrorMessage) {\n\t\t\tif errMsg == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatus := http.StatusInternalServerError\n\t\t\tif errMsg.StatusCode > 0 {\n\t\t\t\tstatus = errMsg.StatusCode\n\t\t\t}\n\t\t\terrText := http.StatusText(status)\n\t\t\tif errMsg.Error != nil && errMsg.Error.Error() != \"\" {\n\t\t\t\terrText = errMsg.Error.Error()\n\t\t\t}\n\t\t\tchunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0)\n\t\t\t_, _ = fmt.Fprintf(c.Writer, \"\\nevent: error\\ndata: %s\\n\\n\", string(chunk))\n\t\t},\n\t\tWriteDone: func() {\n\t\t\t_, _ = c.Writer.Write([]byte(\"\\n\"))\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go",
    "content": "package openai\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)\n\th := NewOpenAIResponsesAPIHandler(base)\n\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\tc.Request = httptest.NewRequest(http.MethodPost, \"/v1/responses\", nil)\n\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tt.Fatalf(\"expected gin writer to implement http.Flusher\")\n\t}\n\n\tdata := make(chan []byte)\n\terrs := make(chan *interfaces.ErrorMessage, 1)\n\terrs <- &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: errors.New(\"unexpected EOF\")}\n\tclose(errs)\n\n\th.forwardResponsesStream(c, flusher, func(error) {}, data, errs)\n\tbody := recorder.Body.String()\n\tif !strings.Contains(body, `\"type\":\"error\"`) {\n\t\tt.Fatalf(\"expected responses error chunk, got: %q\", body)\n\t}\n\tif strings.Contains(body, `\"error\":{`) {\n\t\tt.Fatalf(\"expected streaming error chunk (top-level type), got HTTP error body: %q\", body)\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_responses_websocket.go",
    "content": "package openai\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\nconst (\n\twsRequestTypeCreate  = \"response.create\"\n\twsRequestTypeAppend  = \"response.append\"\n\twsEventTypeError     = \"error\"\n\twsEventTypeCompleted = \"response.completed\"\n\twsDoneMarker         = \"[DONE]\"\n\twsTurnStateHeader    = \"x-codex-turn-state\"\n\twsRequestBodyKey     = \"REQUEST_BODY_OVERRIDE\"\n\twsPayloadLogMaxSize  = 2048\n\twsBodyLogMaxSize     = 64 * 1024\n\twsBodyLogTruncated   = \"\\n[websocket log truncated]\\n\"\n)\n\nvar responsesWebsocketUpgrader = websocket.Upgrader{\n\tReadBufferSize:  4096,\n\tWriteBufferSize: 4096,\n\tCheckOrigin: func(r *http.Request) bool {\n\t\treturn true\n\t},\n}\n\n// ResponsesWebsocket handles websocket requests for /v1/responses.\n// It accepts `response.create` and `response.append` requests and streams\n// response events back as JSON websocket text messages.\nfunc (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {\n\tconn, err := responsesWebsocketUpgrader.Upgrade(c.Writer, c.Request, websocketUpgradeHeaders(c.Request))\n\tif err != nil {\n\t\treturn\n\t}\n\tpassthroughSessionID := uuid.NewString()\n\tclientRemoteAddr := \"\"\n\tif c != nil && c.Request != nil {\n\t\tclientRemoteAddr = strings.TrimSpace(c.Request.RemoteAddr)\n\t}\n\tlog.Infof(\"responses websocket: client connected id=%s remote=%s\", passthroughSessionID, clientRemoteAddr)\n\tvar wsTerminateErr error\n\tvar wsBodyLog strings.Builder\n\tdefer func() {\n\t\tif wsTerminateErr != nil {\n\t\t\t// log.Infof(\"responses websocket: session closing id=%s reason=%v\", passthroughSessionID, wsTerminateErr)\n\t\t} else {\n\t\t\tlog.Infof(\"responses websocket: session closing id=%s\", passthroughSessionID)\n\t\t}\n\t\tif h != nil && h.AuthManager != nil {\n\t\t\th.AuthManager.CloseExecutionSession(passthroughSessionID)\n\t\t\tlog.Infof(\"responses websocket: upstream execution session closed id=%s\", passthroughSessionID)\n\t\t}\n\t\tsetWebsocketRequestBody(c, wsBodyLog.String())\n\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\tlog.Warnf(\"responses websocket: close connection error: %v\", errClose)\n\t\t}\n\t}()\n\n\tvar lastRequest []byte\n\tlastResponseOutput := []byte(\"[]\")\n\tpinnedAuthID := \"\"\n\n\tfor {\n\t\tmsgType, payload, errReadMessage := conn.ReadMessage()\n\t\tif errReadMessage != nil {\n\t\t\twsTerminateErr = errReadMessage\n\t\t\tappendWebsocketEvent(&wsBodyLog, \"disconnect\", []byte(errReadMessage.Error()))\n\t\t\tif websocket.IsCloseError(errReadMessage, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {\n\t\t\t\tlog.Infof(\"responses websocket: client disconnected id=%s error=%v\", passthroughSessionID, errReadMessage)\n\t\t\t} else {\n\t\t\t\t// log.Warnf(\"responses websocket: read message failed id=%s error=%v\", passthroughSessionID, errReadMessage)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tif msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {\n\t\t\tcontinue\n\t\t}\n\t\t// log.Infof(\n\t\t// \t\"responses websocket: downstream_in id=%s type=%d event=%s payload=%s\",\n\t\t// \tpassthroughSessionID,\n\t\t// \tmsgType,\n\t\t// \twebsocketPayloadEventType(payload),\n\t\t// \twebsocketPayloadPreview(payload),\n\t\t// )\n\t\tappendWebsocketEvent(&wsBodyLog, \"request\", payload)\n\n\t\tallowIncrementalInputWithPreviousResponseID := false\n\t\tif pinnedAuthID != \"\" && h != nil && h.AuthManager != nil {\n\t\t\tif pinnedAuth, ok := h.AuthManager.GetByID(pinnedAuthID); ok && pinnedAuth != nil {\n\t\t\t\tallowIncrementalInputWithPreviousResponseID = websocketUpstreamSupportsIncrementalInput(pinnedAuth.Attributes, pinnedAuth.Metadata)\n\t\t\t}\n\t\t} else {\n\t\t\trequestModelName := strings.TrimSpace(gjson.GetBytes(payload, \"model\").String())\n\t\t\tif requestModelName == \"\" {\n\t\t\t\trequestModelName = strings.TrimSpace(gjson.GetBytes(lastRequest, \"model\").String())\n\t\t\t}\n\t\t\tallowIncrementalInputWithPreviousResponseID = h.websocketUpstreamSupportsIncrementalInputForModel(requestModelName)\n\t\t}\n\n\t\tvar requestJSON []byte\n\t\tvar updatedLastRequest []byte\n\t\tvar errMsg *interfaces.ErrorMessage\n\t\trequestJSON, updatedLastRequest, errMsg = normalizeResponsesWebsocketRequestWithMode(\n\t\t\tpayload,\n\t\t\tlastRequest,\n\t\t\tlastResponseOutput,\n\t\t\tallowIncrementalInputWithPreviousResponseID,\n\t\t)\n\t\tif errMsg != nil {\n\t\t\th.LoggingAPIResponseError(context.WithValue(context.Background(), \"gin\", c), errMsg)\n\t\t\tmarkAPIResponseTimestamp(c)\n\t\t\terrorPayload, errWrite := writeResponsesWebsocketError(conn, errMsg)\n\t\t\tappendWebsocketEvent(&wsBodyLog, \"response\", errorPayload)\n\t\t\tlog.Infof(\n\t\t\t\t\"responses websocket: downstream_out id=%s type=%d event=%s payload=%s\",\n\t\t\t\tpassthroughSessionID,\n\t\t\t\twebsocket.TextMessage,\n\t\t\t\twebsocketPayloadEventType(errorPayload),\n\t\t\t\twebsocketPayloadPreview(errorPayload),\n\t\t\t)\n\t\t\tif errWrite != nil {\n\t\t\t\tlog.Warnf(\n\t\t\t\t\t\"responses websocket: downstream_out write failed id=%s event=%s error=%v\",\n\t\t\t\t\tpassthroughSessionID,\n\t\t\t\t\twebsocketPayloadEventType(errorPayload),\n\t\t\t\t\terrWrite,\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, allowIncrementalInputWithPreviousResponseID) {\n\t\t\tif updated, errDelete := sjson.DeleteBytes(requestJSON, \"generate\"); errDelete == nil {\n\t\t\t\trequestJSON = updated\n\t\t\t}\n\t\t\tif updated, errDelete := sjson.DeleteBytes(updatedLastRequest, \"generate\"); errDelete == nil {\n\t\t\t\tupdatedLastRequest = updated\n\t\t\t}\n\t\t\tlastRequest = updatedLastRequest\n\t\t\tlastResponseOutput = []byte(\"[]\")\n\t\t\tif errWrite := writeResponsesWebsocketSyntheticPrewarm(c, conn, requestJSON, &wsBodyLog, passthroughSessionID); errWrite != nil {\n\t\t\t\twsTerminateErr = errWrite\n\t\t\t\tappendWebsocketEvent(&wsBodyLog, \"disconnect\", []byte(errWrite.Error()))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tlastRequest = updatedLastRequest\n\n\t\tmodelName := gjson.GetBytes(requestJSON, \"model\").String()\n\t\tcliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())\n\t\tcliCtx = cliproxyexecutor.WithDownstreamWebsocket(cliCtx)\n\t\tcliCtx = handlers.WithExecutionSessionID(cliCtx, passthroughSessionID)\n\t\tif pinnedAuthID != \"\" {\n\t\t\tcliCtx = handlers.WithPinnedAuthID(cliCtx, pinnedAuthID)\n\t\t} else {\n\t\t\tcliCtx = handlers.WithSelectedAuthIDCallback(cliCtx, func(authID string) {\n\t\t\t\tauthID = strings.TrimSpace(authID)\n\t\t\t\tif authID == \"\" || h == nil || h.AuthManager == nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tselectedAuth, ok := h.AuthManager.GetByID(authID)\n\t\t\t\tif !ok || selectedAuth == nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif websocketUpstreamSupportsIncrementalInput(selectedAuth.Attributes, selectedAuth.Metadata) {\n\t\t\t\t\tpinnedAuthID = authID\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t\tdataChan, _, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, requestJSON, \"\")\n\n\t\tcompletedOutput, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsBodyLog, passthroughSessionID)\n\t\tif errForward != nil {\n\t\t\twsTerminateErr = errForward\n\t\t\tappendWebsocketEvent(&wsBodyLog, \"disconnect\", []byte(errForward.Error()))\n\t\t\tlog.Warnf(\"responses websocket: forward failed id=%s error=%v\", passthroughSessionID, errForward)\n\t\t\treturn\n\t\t}\n\t\tlastResponseOutput = completedOutput\n\t}\n}\n\nfunc websocketUpgradeHeaders(req *http.Request) http.Header {\n\theaders := http.Header{}\n\tif req == nil {\n\t\treturn headers\n\t}\n\n\t// Keep the same sticky turn-state across reconnects when provided by the client.\n\tturnState := strings.TrimSpace(req.Header.Get(wsTurnStateHeader))\n\tif turnState != \"\" {\n\t\theaders.Set(wsTurnStateHeader, turnState)\n\t}\n\treturn headers\n}\n\nfunc normalizeResponsesWebsocketRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte) ([]byte, []byte, *interfaces.ErrorMessage) {\n\treturn normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true)\n}\n\nfunc normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) {\n\trequestType := strings.TrimSpace(gjson.GetBytes(rawJSON, \"type\").String())\n\tswitch requestType {\n\tcase wsRequestTypeCreate:\n\t\t// log.Infof(\"responses websocket: response.create request\")\n\t\tif len(lastRequest) == 0 {\n\t\t\treturn normalizeResponseCreateRequest(rawJSON)\n\t\t}\n\t\treturn normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID)\n\tcase wsRequestTypeAppend:\n\t\t// log.Infof(\"responses websocket: response.append request\")\n\t\treturn normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID)\n\tdefault:\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"unsupported websocket request type: %s\", requestType),\n\t\t}\n\t}\n}\n\nfunc normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces.ErrorMessage) {\n\tnormalized, errDelete := sjson.DeleteBytes(rawJSON, \"type\")\n\tif errDelete != nil {\n\t\tnormalized = bytes.Clone(rawJSON)\n\t}\n\tnormalized, _ = sjson.SetBytes(normalized, \"stream\", true)\n\tif !gjson.GetBytes(normalized, \"input\").Exists() {\n\t\tnormalized, _ = sjson.SetRawBytes(normalized, \"input\", []byte(\"[]\"))\n\t}\n\n\tmodelName := strings.TrimSpace(gjson.GetBytes(normalized, \"model\").String())\n\tif modelName == \"\" {\n\t\treturn nil, nil, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"missing model in response.create request\"),\n\t\t}\n\t}\n\treturn normalized, bytes.Clone(normalized), nil\n}\n\nfunc normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) {\n\tif len(lastRequest) == 0 {\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"websocket request received before response.create\"),\n\t\t}\n\t}\n\n\tnextInput := gjson.GetBytes(rawJSON, \"input\")\n\tif !nextInput.Exists() || !nextInput.IsArray() {\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"websocket request requires array field: input\"),\n\t\t}\n\t}\n\n\t// Websocket v2 mode uses response.create with previous_response_id + incremental input.\n\t// Do not expand it into a full input transcript; upstream expects the incremental payload.\n\tif allowIncrementalInputWithPreviousResponseID {\n\t\tif prev := strings.TrimSpace(gjson.GetBytes(rawJSON, \"previous_response_id\").String()); prev != \"\" {\n\t\t\tnormalized, errDelete := sjson.DeleteBytes(rawJSON, \"type\")\n\t\t\tif errDelete != nil {\n\t\t\t\tnormalized = bytes.Clone(rawJSON)\n\t\t\t}\n\t\t\tif !gjson.GetBytes(normalized, \"model\").Exists() {\n\t\t\t\tmodelName := strings.TrimSpace(gjson.GetBytes(lastRequest, \"model\").String())\n\t\t\t\tif modelName != \"\" {\n\t\t\t\t\tnormalized, _ = sjson.SetBytes(normalized, \"model\", modelName)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !gjson.GetBytes(normalized, \"instructions\").Exists() {\n\t\t\t\tinstructions := gjson.GetBytes(lastRequest, \"instructions\")\n\t\t\t\tif instructions.Exists() {\n\t\t\t\t\tnormalized, _ = sjson.SetRawBytes(normalized, \"instructions\", []byte(instructions.Raw))\n\t\t\t\t}\n\t\t\t}\n\t\t\tnormalized, _ = sjson.SetBytes(normalized, \"stream\", true)\n\t\t\treturn normalized, bytes.Clone(normalized), nil\n\t\t}\n\t}\n\n\texistingInput := gjson.GetBytes(lastRequest, \"input\")\n\tmergedInput, errMerge := mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput))\n\tif errMerge != nil {\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"invalid previous response output: %w\", errMerge),\n\t\t}\n\t}\n\n\tmergedInput, errMerge = mergeJSONArrayRaw(mergedInput, nextInput.Raw)\n\tif errMerge != nil {\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"invalid request input: %w\", errMerge),\n\t\t}\n\t}\n\n\tnormalized, errDelete := sjson.DeleteBytes(rawJSON, \"type\")\n\tif errDelete != nil {\n\t\tnormalized = bytes.Clone(rawJSON)\n\t}\n\tnormalized, _ = sjson.DeleteBytes(normalized, \"previous_response_id\")\n\tvar errSet error\n\tnormalized, errSet = sjson.SetRawBytes(normalized, \"input\", []byte(mergedInput))\n\tif errSet != nil {\n\t\treturn nil, lastRequest, &interfaces.ErrorMessage{\n\t\t\tStatusCode: http.StatusBadRequest,\n\t\t\tError:      fmt.Errorf(\"failed to merge websocket input: %w\", errSet),\n\t\t}\n\t}\n\tif !gjson.GetBytes(normalized, \"model\").Exists() {\n\t\tmodelName := strings.TrimSpace(gjson.GetBytes(lastRequest, \"model\").String())\n\t\tif modelName != \"\" {\n\t\t\tnormalized, _ = sjson.SetBytes(normalized, \"model\", modelName)\n\t\t}\n\t}\n\tif !gjson.GetBytes(normalized, \"instructions\").Exists() {\n\t\tinstructions := gjson.GetBytes(lastRequest, \"instructions\")\n\t\tif instructions.Exists() {\n\t\t\tnormalized, _ = sjson.SetRawBytes(normalized, \"instructions\", []byte(instructions.Raw))\n\t\t}\n\t}\n\tnormalized, _ = sjson.SetBytes(normalized, \"stream\", true)\n\treturn normalized, bytes.Clone(normalized), nil\n}\n\nfunc websocketUpstreamSupportsIncrementalInput(attributes map[string]string, metadata map[string]any) bool {\n\tif len(attributes) > 0 {\n\t\tif raw := strings.TrimSpace(attributes[\"websockets\"]); raw != \"\" {\n\t\t\tparsed, errParse := strconv.ParseBool(raw)\n\t\t\tif errParse == nil {\n\t\t\t\treturn parsed\n\t\t\t}\n\t\t}\n\t}\n\tif len(metadata) == 0 {\n\t\treturn false\n\t}\n\traw, ok := metadata[\"websockets\"]\n\tif !ok || raw == nil {\n\t\treturn false\n\t}\n\tswitch value := raw.(type) {\n\tcase bool:\n\t\treturn value\n\tcase string:\n\t\tparsed, errParse := strconv.ParseBool(strings.TrimSpace(value))\n\t\tif errParse == nil {\n\t\t\treturn parsed\n\t\t}\n\tdefault:\n\t}\n\treturn false\n}\n\nfunc (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsIncrementalInputForModel(modelName string) bool {\n\tif h == nil || h.AuthManager == nil {\n\t\treturn false\n\t}\n\n\tresolvedModelName := modelName\n\tinitialSuffix := thinking.ParseSuffix(modelName)\n\tif initialSuffix.ModelName == \"auto\" {\n\t\tresolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)\n\t\tif initialSuffix.HasSuffix {\n\t\t\tresolvedModelName = fmt.Sprintf(\"%s(%s)\", resolvedBase, initialSuffix.RawSuffix)\n\t\t} else {\n\t\t\tresolvedModelName = resolvedBase\n\t\t}\n\t} else {\n\t\tresolvedModelName = util.ResolveAutoModel(modelName)\n\t}\n\n\tparsed := thinking.ParseSuffix(resolvedModelName)\n\tbaseModel := strings.TrimSpace(parsed.ModelName)\n\tproviders := util.GetProviderName(baseModel)\n\tif len(providers) == 0 && baseModel != resolvedModelName {\n\t\tproviders = util.GetProviderName(resolvedModelName)\n\t}\n\tif len(providers) == 0 {\n\t\treturn false\n\t}\n\n\tproviderSet := make(map[string]struct{}, len(providers))\n\tfor i := 0; i < len(providers); i++ {\n\t\tproviderKey := strings.TrimSpace(strings.ToLower(providers[i]))\n\t\tif providerKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tproviderSet[providerKey] = struct{}{}\n\t}\n\tif len(providerSet) == 0 {\n\t\treturn false\n\t}\n\n\tmodelKey := baseModel\n\tif modelKey == \"\" {\n\t\tmodelKey = strings.TrimSpace(resolvedModelName)\n\t}\n\tregistryRef := registry.GetGlobalRegistry()\n\tnow := time.Now()\n\tauths := h.AuthManager.List()\n\tfor i := 0; i < len(auths); i++ {\n\t\tauth := auths[i]\n\t\tif auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tproviderKey := strings.TrimSpace(strings.ToLower(auth.Provider))\n\t\tif _, ok := providerSet[providerKey]; !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif modelKey != \"\" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) {\n\t\t\tcontinue\n\t\t}\n\t\tif !responsesWebsocketAuthAvailableForModel(auth, modelKey, now) {\n\t\t\tcontinue\n\t\t}\n\t\tif websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc responsesWebsocketAuthAvailableForModel(auth *coreauth.Auth, modelName string, now time.Time) bool {\n\tif auth == nil {\n\t\treturn false\n\t}\n\tif auth.Disabled || auth.Status == coreauth.StatusDisabled {\n\t\treturn false\n\t}\n\tif modelName != \"\" && len(auth.ModelStates) > 0 {\n\t\tstate, ok := auth.ModelStates[modelName]\n\t\tif (!ok || state == nil) && modelName != \"\" {\n\t\t\tbaseModel := strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName)\n\t\t\tif baseModel != \"\" && baseModel != modelName {\n\t\t\t\tstate, ok = auth.ModelStates[baseModel]\n\t\t\t}\n\t\t}\n\t\tif ok && state != nil {\n\t\t\tif state.Status == coreauth.StatusDisabled {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif state.Unavailable && !state.NextRetryAfter.IsZero() && state.NextRetryAfter.After(now) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t}\n\tif auth.Unavailable && !auth.NextRetryAfter.IsZero() && auth.NextRetryAfter.After(now) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc shouldHandleResponsesWebsocketPrewarmLocally(rawJSON []byte, lastRequest []byte, allowIncrementalInputWithPreviousResponseID bool) bool {\n\tif allowIncrementalInputWithPreviousResponseID || len(lastRequest) != 0 {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(gjson.GetBytes(rawJSON, \"type\").String()) != wsRequestTypeCreate {\n\t\treturn false\n\t}\n\tgenerateResult := gjson.GetBytes(rawJSON, \"generate\")\n\treturn generateResult.Exists() && !generateResult.Bool()\n}\n\nfunc writeResponsesWebsocketSyntheticPrewarm(\n\tc *gin.Context,\n\tconn *websocket.Conn,\n\trequestJSON []byte,\n\twsBodyLog *strings.Builder,\n\tsessionID string,\n) error {\n\tpayloads, errPayloads := syntheticResponsesWebsocketPrewarmPayloads(requestJSON)\n\tif errPayloads != nil {\n\t\treturn errPayloads\n\t}\n\tfor i := 0; i < len(payloads); i++ {\n\t\tmarkAPIResponseTimestamp(c)\n\t\tappendWebsocketEvent(wsBodyLog, \"response\", payloads[i])\n\t\t// log.Infof(\n\t\t// \t\"responses websocket: downstream_out id=%s type=%d event=%s payload=%s\",\n\t\t// \tsessionID,\n\t\t// \twebsocket.TextMessage,\n\t\t// \twebsocketPayloadEventType(payloads[i]),\n\t\t// \twebsocketPayloadPreview(payloads[i]),\n\t\t// )\n\t\tif errWrite := conn.WriteMessage(websocket.TextMessage, payloads[i]); errWrite != nil {\n\t\t\tlog.Warnf(\n\t\t\t\t\"responses websocket: downstream_out write failed id=%s event=%s error=%v\",\n\t\t\t\tsessionID,\n\t\t\t\twebsocketPayloadEventType(payloads[i]),\n\t\t\t\terrWrite,\n\t\t\t)\n\t\t\treturn errWrite\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc syntheticResponsesWebsocketPrewarmPayloads(requestJSON []byte) ([][]byte, error) {\n\tresponseID := \"resp_prewarm_\" + uuid.NewString()\n\tcreatedAt := time.Now().Unix()\n\tmodelName := strings.TrimSpace(gjson.GetBytes(requestJSON, \"model\").String())\n\n\tcreatedPayload := []byte(`{\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"output\":[]}}`)\n\tvar errSet error\n\tcreatedPayload, errSet = sjson.SetBytes(createdPayload, \"response.id\", responseID)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\tcreatedPayload, errSet = sjson.SetBytes(createdPayload, \"response.created_at\", createdAt)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\tif modelName != \"\" {\n\t\tcreatedPayload, errSet = sjson.SetBytes(createdPayload, \"response.model\", modelName)\n\t\tif errSet != nil {\n\t\t\treturn nil, errSet\n\t\t}\n\t}\n\n\tcompletedPayload := []byte(`{\"type\":\"response.completed\",\"sequence_number\":1,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}`)\n\tcompletedPayload, errSet = sjson.SetBytes(completedPayload, \"response.id\", responseID)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\tcompletedPayload, errSet = sjson.SetBytes(completedPayload, \"response.created_at\", createdAt)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\tif modelName != \"\" {\n\t\tcompletedPayload, errSet = sjson.SetBytes(completedPayload, \"response.model\", modelName)\n\t\tif errSet != nil {\n\t\t\treturn nil, errSet\n\t\t}\n\t}\n\n\treturn [][]byte{createdPayload, completedPayload}, nil\n}\n\nfunc mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) {\n\texistingRaw = strings.TrimSpace(existingRaw)\n\tappendRaw = strings.TrimSpace(appendRaw)\n\tif existingRaw == \"\" {\n\t\texistingRaw = \"[]\"\n\t}\n\tif appendRaw == \"\" {\n\t\tappendRaw = \"[]\"\n\t}\n\n\tvar existing []json.RawMessage\n\tif err := json.Unmarshal([]byte(existingRaw), &existing); err != nil {\n\t\treturn \"\", err\n\t}\n\tvar appendItems []json.RawMessage\n\tif err := json.Unmarshal([]byte(appendRaw), &appendItems); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmerged := append(existing, appendItems...)\n\tout, err := json.Marshal(merged)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(out), nil\n}\n\nfunc normalizeJSONArrayRaw(raw []byte) string {\n\ttrimmed := strings.TrimSpace(string(raw))\n\tif trimmed == \"\" {\n\t\treturn \"[]\"\n\t}\n\tresult := gjson.Parse(trimmed)\n\tif result.Type == gjson.JSON && result.IsArray() {\n\t\treturn trimmed\n\t}\n\treturn \"[]\"\n}\n\nfunc (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(\n\tc *gin.Context,\n\tconn *websocket.Conn,\n\tcancel handlers.APIHandlerCancelFunc,\n\tdata <-chan []byte,\n\terrs <-chan *interfaces.ErrorMessage,\n\twsBodyLog *strings.Builder,\n\tsessionID string,\n) ([]byte, error) {\n\tcompleted := false\n\tcompletedOutput := []byte(\"[]\")\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcancel(c.Request.Context().Err())\n\t\t\treturn completedOutput, c.Request.Context().Err()\n\t\tcase errMsg, ok := <-errs:\n\t\t\tif !ok {\n\t\t\t\terrs = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif errMsg != nil {\n\t\t\t\th.LoggingAPIResponseError(context.WithValue(context.Background(), \"gin\", c), errMsg)\n\t\t\t\tmarkAPIResponseTimestamp(c)\n\t\t\t\terrorPayload, errWrite := writeResponsesWebsocketError(conn, errMsg)\n\t\t\t\tappendWebsocketEvent(wsBodyLog, \"response\", errorPayload)\n\t\t\t\tlog.Infof(\n\t\t\t\t\t\"responses websocket: downstream_out id=%s type=%d event=%s payload=%s\",\n\t\t\t\t\tsessionID,\n\t\t\t\t\twebsocket.TextMessage,\n\t\t\t\t\twebsocketPayloadEventType(errorPayload),\n\t\t\t\t\twebsocketPayloadPreview(errorPayload),\n\t\t\t\t)\n\t\t\t\tif errWrite != nil {\n\t\t\t\t\t// log.Warnf(\n\t\t\t\t\t// \t\"responses websocket: downstream_out write failed id=%s event=%s error=%v\",\n\t\t\t\t\t// \tsessionID,\n\t\t\t\t\t// \twebsocketPayloadEventType(errorPayload),\n\t\t\t\t\t// \terrWrite,\n\t\t\t\t\t// )\n\t\t\t\t\tcancel(errMsg.Error)\n\t\t\t\t\treturn completedOutput, errWrite\n\t\t\t\t}\n\t\t\t}\n\t\t\tif errMsg != nil {\n\t\t\t\tcancel(errMsg.Error)\n\t\t\t} else {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t\treturn completedOutput, nil\n\t\tcase chunk, ok := <-data:\n\t\t\tif !ok {\n\t\t\t\tif !completed {\n\t\t\t\t\terrMsg := &interfaces.ErrorMessage{\n\t\t\t\t\t\tStatusCode: http.StatusRequestTimeout,\n\t\t\t\t\t\tError:      fmt.Errorf(\"stream closed before response.completed\"),\n\t\t\t\t\t}\n\t\t\t\t\th.LoggingAPIResponseError(context.WithValue(context.Background(), \"gin\", c), errMsg)\n\t\t\t\t\tmarkAPIResponseTimestamp(c)\n\t\t\t\t\terrorPayload, errWrite := writeResponsesWebsocketError(conn, errMsg)\n\t\t\t\t\tappendWebsocketEvent(wsBodyLog, \"response\", errorPayload)\n\t\t\t\t\tlog.Infof(\n\t\t\t\t\t\t\"responses websocket: downstream_out id=%s type=%d event=%s payload=%s\",\n\t\t\t\t\t\tsessionID,\n\t\t\t\t\t\twebsocket.TextMessage,\n\t\t\t\t\t\twebsocketPayloadEventType(errorPayload),\n\t\t\t\t\t\twebsocketPayloadPreview(errorPayload),\n\t\t\t\t\t)\n\t\t\t\t\tif errWrite != nil {\n\t\t\t\t\t\tlog.Warnf(\n\t\t\t\t\t\t\t\"responses websocket: downstream_out write failed id=%s event=%s error=%v\",\n\t\t\t\t\t\t\tsessionID,\n\t\t\t\t\t\t\twebsocketPayloadEventType(errorPayload),\n\t\t\t\t\t\t\terrWrite,\n\t\t\t\t\t\t)\n\t\t\t\t\t\tcancel(errMsg.Error)\n\t\t\t\t\t\treturn completedOutput, errWrite\n\t\t\t\t\t}\n\t\t\t\t\tcancel(errMsg.Error)\n\t\t\t\t\treturn completedOutput, nil\n\t\t\t\t}\n\t\t\t\tcancel(nil)\n\t\t\t\treturn completedOutput, nil\n\t\t\t}\n\n\t\t\tpayloads := websocketJSONPayloadsFromChunk(chunk)\n\t\t\tfor i := range payloads {\n\t\t\t\teventType := gjson.GetBytes(payloads[i], \"type\").String()\n\t\t\t\tif eventType == wsEventTypeCompleted {\n\t\t\t\t\tcompleted = true\n\t\t\t\t\tcompletedOutput = responseCompletedOutputFromPayload(payloads[i])\n\t\t\t\t}\n\t\t\t\tmarkAPIResponseTimestamp(c)\n\t\t\t\tappendWebsocketEvent(wsBodyLog, \"response\", payloads[i])\n\t\t\t\t// log.Infof(\n\t\t\t\t// \t\"responses websocket: downstream_out id=%s type=%d event=%s payload=%s\",\n\t\t\t\t// \tsessionID,\n\t\t\t\t// \twebsocket.TextMessage,\n\t\t\t\t// \twebsocketPayloadEventType(payloads[i]),\n\t\t\t\t// \twebsocketPayloadPreview(payloads[i]),\n\t\t\t\t// )\n\t\t\t\tif errWrite := conn.WriteMessage(websocket.TextMessage, payloads[i]); errWrite != nil {\n\t\t\t\t\tlog.Warnf(\n\t\t\t\t\t\t\"responses websocket: downstream_out write failed id=%s event=%s error=%v\",\n\t\t\t\t\t\tsessionID,\n\t\t\t\t\t\twebsocketPayloadEventType(payloads[i]),\n\t\t\t\t\t\terrWrite,\n\t\t\t\t\t)\n\t\t\t\t\tcancel(errWrite)\n\t\t\t\t\treturn completedOutput, errWrite\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc responseCompletedOutputFromPayload(payload []byte) []byte {\n\toutput := gjson.GetBytes(payload, \"response.output\")\n\tif output.Exists() && output.IsArray() {\n\t\treturn bytes.Clone([]byte(output.Raw))\n\t}\n\treturn []byte(\"[]\")\n}\n\nfunc websocketJSONPayloadsFromChunk(chunk []byte) [][]byte {\n\tpayloads := make([][]byte, 0, 2)\n\tlines := bytes.Split(chunk, []byte(\"\\n\"))\n\tfor i := range lines {\n\t\tline := bytes.TrimSpace(lines[i])\n\t\tif len(line) == 0 || bytes.HasPrefix(line, []byte(\"event:\")) {\n\t\t\tcontinue\n\t\t}\n\t\tif bytes.HasPrefix(line, []byte(\"data:\")) {\n\t\t\tline = bytes.TrimSpace(line[len(\"data:\"):])\n\t\t}\n\t\tif len(line) == 0 || bytes.Equal(line, []byte(wsDoneMarker)) {\n\t\t\tcontinue\n\t\t}\n\t\tif json.Valid(line) {\n\t\t\tpayloads = append(payloads, bytes.Clone(line))\n\t\t}\n\t}\n\n\tif len(payloads) > 0 {\n\t\treturn payloads\n\t}\n\n\ttrimmed := bytes.TrimSpace(chunk)\n\tif bytes.HasPrefix(trimmed, []byte(\"data:\")) {\n\t\ttrimmed = bytes.TrimSpace(trimmed[len(\"data:\"):])\n\t}\n\tif len(trimmed) > 0 && !bytes.Equal(trimmed, []byte(wsDoneMarker)) && json.Valid(trimmed) {\n\t\tpayloads = append(payloads, bytes.Clone(trimmed))\n\t}\n\treturn payloads\n}\n\nfunc writeResponsesWebsocketError(conn *websocket.Conn, errMsg *interfaces.ErrorMessage) ([]byte, error) {\n\tstatus := http.StatusInternalServerError\n\terrText := http.StatusText(status)\n\tif errMsg != nil {\n\t\tif errMsg.StatusCode > 0 {\n\t\t\tstatus = errMsg.StatusCode\n\t\t\terrText = http.StatusText(status)\n\t\t}\n\t\tif errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != \"\" {\n\t\t\terrText = errMsg.Error.Error()\n\t\t}\n\t}\n\n\tbody := handlers.BuildErrorResponseBody(status, errText)\n\tpayload := []byte(`{}`)\n\tvar errSet error\n\tpayload, errSet = sjson.SetBytes(payload, \"type\", wsEventTypeError)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\tpayload, errSet = sjson.SetBytes(payload, \"status\", status)\n\tif errSet != nil {\n\t\treturn nil, errSet\n\t}\n\n\tif errMsg != nil && errMsg.Addon != nil {\n\t\theaders := []byte(`{}`)\n\t\thasHeaders := false\n\t\tfor key, values := range errMsg.Addon {\n\t\t\tif len(values) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\theaderPath := strings.ReplaceAll(strings.ReplaceAll(key, `\\\\`, `\\\\\\\\`), \".\", `\\\\.`)\n\t\t\theaders, errSet = sjson.SetBytes(headers, headerPath, values[0])\n\t\t\tif errSet != nil {\n\t\t\t\treturn nil, errSet\n\t\t\t}\n\t\t\thasHeaders = true\n\t\t}\n\t\tif hasHeaders {\n\t\t\tpayload, errSet = sjson.SetRawBytes(payload, \"headers\", headers)\n\t\t\tif errSet != nil {\n\t\t\t\treturn nil, errSet\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(body) > 0 && json.Valid(body) {\n\t\terrorNode := gjson.GetBytes(body, \"error\")\n\t\tif errorNode.Exists() {\n\t\t\tpayload, errSet = sjson.SetRawBytes(payload, \"error\", []byte(errorNode.Raw))\n\t\t} else {\n\t\t\tpayload, errSet = sjson.SetRawBytes(payload, \"error\", body)\n\t\t}\n\t\tif errSet != nil {\n\t\t\treturn nil, errSet\n\t\t}\n\t}\n\n\tif !gjson.GetBytes(payload, \"error\").Exists() {\n\t\tpayload, errSet = sjson.SetBytes(payload, \"error.type\", \"server_error\")\n\t\tif errSet != nil {\n\t\t\treturn nil, errSet\n\t\t}\n\t\tpayload, errSet = sjson.SetBytes(payload, \"error.message\", errText)\n\t\tif errSet != nil {\n\t\t\treturn nil, errSet\n\t\t}\n\t}\n\n\treturn payload, conn.WriteMessage(websocket.TextMessage, payload)\n}\n\nfunc appendWebsocketEvent(builder *strings.Builder, eventType string, payload []byte) {\n\tif builder == nil {\n\t\treturn\n\t}\n\tif builder.Len() >= wsBodyLogMaxSize {\n\t\treturn\n\t}\n\ttrimmedPayload := bytes.TrimSpace(payload)\n\tif len(trimmedPayload) == 0 {\n\t\treturn\n\t}\n\tif builder.Len() > 0 {\n\t\tif !appendWebsocketLogString(builder, \"\\n\") {\n\t\t\treturn\n\t\t}\n\t}\n\tif !appendWebsocketLogString(builder, \"websocket.\") {\n\t\treturn\n\t}\n\tif !appendWebsocketLogString(builder, eventType) {\n\t\treturn\n\t}\n\tif !appendWebsocketLogString(builder, \"\\n\") {\n\t\treturn\n\t}\n\tif !appendWebsocketLogBytes(builder, trimmedPayload, len(wsBodyLogTruncated)) {\n\t\tappendWebsocketLogString(builder, wsBodyLogTruncated)\n\t\treturn\n\t}\n\tappendWebsocketLogString(builder, \"\\n\")\n}\n\nfunc appendWebsocketLogString(builder *strings.Builder, value string) bool {\n\tif builder == nil {\n\t\treturn false\n\t}\n\tremaining := wsBodyLogMaxSize - builder.Len()\n\tif remaining <= 0 {\n\t\treturn false\n\t}\n\tif len(value) <= remaining {\n\t\tbuilder.WriteString(value)\n\t\treturn true\n\t}\n\tbuilder.WriteString(value[:remaining])\n\treturn false\n}\n\nfunc appendWebsocketLogBytes(builder *strings.Builder, value []byte, reserveForSuffix int) bool {\n\tif builder == nil {\n\t\treturn false\n\t}\n\tremaining := wsBodyLogMaxSize - builder.Len()\n\tif remaining <= 0 {\n\t\treturn false\n\t}\n\tif len(value) <= remaining {\n\t\tbuilder.Write(value)\n\t\treturn true\n\t}\n\tlimit := remaining - reserveForSuffix\n\tif limit < 0 {\n\t\tlimit = 0\n\t}\n\tif limit > len(value) {\n\t\tlimit = len(value)\n\t}\n\tbuilder.Write(value[:limit])\n\treturn false\n}\n\nfunc websocketPayloadEventType(payload []byte) string {\n\teventType := strings.TrimSpace(gjson.GetBytes(payload, \"type\").String())\n\tif eventType == \"\" {\n\t\treturn \"-\"\n\t}\n\treturn eventType\n}\n\nfunc websocketPayloadPreview(payload []byte) string {\n\ttrimmedPayload := bytes.TrimSpace(payload)\n\tif len(trimmedPayload) == 0 {\n\t\treturn \"<empty>\"\n\t}\n\tpreview := trimmedPayload\n\tif len(preview) > wsPayloadLogMaxSize {\n\t\tpreview = preview[:wsPayloadLogMaxSize]\n\t}\n\tpreviewText := strings.ReplaceAll(string(preview), \"\\n\", \"\\\\n\")\n\tpreviewText = strings.ReplaceAll(previewText, \"\\r\", \"\\\\r\")\n\tif len(trimmedPayload) > wsPayloadLogMaxSize {\n\t\treturn fmt.Sprintf(\"%s...(truncated,total=%d)\", previewText, len(trimmedPayload))\n\t}\n\treturn previewText\n}\n\nfunc setWebsocketRequestBody(c *gin.Context, body string) {\n\tif c == nil {\n\t\treturn\n\t}\n\ttrimmedBody := strings.TrimSpace(body)\n\tif trimmedBody == \"\" {\n\t\treturn\n\t}\n\tc.Set(wsRequestBodyKey, []byte(trimmedBody))\n}\n\nfunc markAPIResponseTimestamp(c *gin.Context) {\n\tif c == nil {\n\t\treturn\n\t}\n\tif _, exists := c.Get(\"API_RESPONSE_TIMESTAMP\"); exists {\n\t\treturn\n\t}\n\tc.Set(\"API_RESPONSE_TIMESTAMP\", time.Now())\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai/openai_responses_websocket_test.go",
    "content": "package openai\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcoreexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdkconfig \"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/tidwall/gjson\"\n)\n\ntype websocketCaptureExecutor struct {\n\tstreamCalls int\n\tpayloads    [][]byte\n}\n\ntype orderedWebsocketSelector struct {\n\tmu     sync.Mutex\n\torder  []string\n\tcursor int\n}\n\nfunc (s *orderedWebsocketSelector) Pick(_ context.Context, _ string, _ string, _ coreexecutor.Options, auths []*coreauth.Auth) (*coreauth.Auth, error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif len(auths) == 0 {\n\t\treturn nil, errors.New(\"no auth available\")\n\t}\n\tfor len(s.order) > 0 && s.cursor < len(s.order) {\n\t\tauthID := strings.TrimSpace(s.order[s.cursor])\n\t\ts.cursor++\n\t\tfor _, auth := range auths {\n\t\t\tif auth != nil && auth.ID == authID {\n\t\t\t\treturn auth, nil\n\t\t\t}\n\t\t}\n\t}\n\tfor _, auth := range auths {\n\t\tif auth != nil {\n\t\t\treturn auth, nil\n\t\t}\n\t}\n\treturn nil, errors.New(\"no auth available\")\n}\n\ntype websocketAuthCaptureExecutor struct {\n\tmu      sync.Mutex\n\tauthIDs []string\n}\n\nfunc (e *websocketAuthCaptureExecutor) Identifier() string { return \"test-provider\" }\n\nfunc (e *websocketAuthCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, errors.New(\"not implemented\")\n}\n\nfunc (e *websocketAuthCaptureExecutor) ExecuteStream(_ context.Context, auth *coreauth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\te.mu.Lock()\n\tif auth != nil {\n\t\te.authIDs = append(e.authIDs, auth.ID)\n\t}\n\te.mu.Unlock()\n\n\tchunks := make(chan coreexecutor.StreamChunk, 1)\n\tchunks <- coreexecutor.StreamChunk{Payload: []byte(`{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-upstream\",\"output\":[{\"type\":\"message\",\"id\":\"out-1\"}]}}`)}\n\tclose(chunks)\n\treturn &coreexecutor.StreamResult{Chunks: chunks}, nil\n}\n\nfunc (e *websocketAuthCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *websocketAuthCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, errors.New(\"not implemented\")\n}\n\nfunc (e *websocketAuthCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\nfunc (e *websocketAuthCaptureExecutor) AuthIDs() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn append([]string(nil), e.authIDs...)\n}\n\nfunc (e *websocketCaptureExecutor) Identifier() string { return \"test-provider\" }\n\nfunc (e *websocketCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, errors.New(\"not implemented\")\n}\n\nfunc (e *websocketCaptureExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {\n\te.streamCalls++\n\te.payloads = append(e.payloads, bytes.Clone(req.Payload))\n\tchunks := make(chan coreexecutor.StreamChunk, 1)\n\tchunks <- coreexecutor.StreamChunk{Payload: []byte(`{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-upstream\",\"output\":[{\"type\":\"message\",\"id\":\"out-1\"}]}}`)}\n\tclose(chunks)\n\treturn &coreexecutor.StreamResult{Chunks: chunks}, nil\n}\n\nfunc (e *websocketCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *websocketCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {\n\treturn coreexecutor.Response{}, errors.New(\"not implemented\")\n}\n\nfunc (e *websocketCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\nfunc TestNormalizeResponsesWebsocketRequestCreate(t *testing.T) {\n\traw := []byte(`{\"type\":\"response.create\",\"model\":\"test-model\",\"stream\":false,\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\n\tnormalized, last, errMsg := normalizeResponsesWebsocketRequest(raw, nil, nil)\n\tif errMsg != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errMsg.Error)\n\t}\n\tif gjson.GetBytes(normalized, \"type\").Exists() {\n\t\tt.Fatalf(\"normalized create request must not include type field\")\n\t}\n\tif !gjson.GetBytes(normalized, \"stream\").Bool() {\n\t\tt.Fatalf(\"normalized create request must force stream=true\")\n\t}\n\tif gjson.GetBytes(normalized, \"model\").String() != \"test-model\" {\n\t\tt.Fatalf(\"unexpected model: %s\", gjson.GetBytes(normalized, \"model\").String())\n\t}\n\tif !bytes.Equal(last, normalized) {\n\t\tt.Fatalf(\"last request snapshot should match normalized request\")\n\t}\n}\n\nfunc TestNormalizeResponsesWebsocketRequestCreateWithHistory(t *testing.T) {\n\tlastRequest := []byte(`{\"model\":\"test-model\",\"stream\":true,\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\tlastResponseOutput := []byte(`[\n\t\t{\"type\":\"function_call\",\"id\":\"fc-1\",\"call_id\":\"call-1\"},\n\t\t{\"type\":\"message\",\"id\":\"assistant-1\"}\n\t]`)\n\traw := []byte(`{\"type\":\"response.create\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call-1\",\"id\":\"tool-out-1\"}]}`)\n\n\tnormalized, next, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)\n\tif errMsg != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errMsg.Error)\n\t}\n\tif gjson.GetBytes(normalized, \"type\").Exists() {\n\t\tt.Fatalf(\"normalized subsequent create request must not include type field\")\n\t}\n\tif gjson.GetBytes(normalized, \"model\").String() != \"test-model\" {\n\t\tt.Fatalf(\"unexpected model: %s\", gjson.GetBytes(normalized, \"model\").String())\n\t}\n\n\tinput := gjson.GetBytes(normalized, \"input\").Array()\n\tif len(input) != 4 {\n\t\tt.Fatalf(\"merged input len = %d, want 4\", len(input))\n\t}\n\tif input[0].Get(\"id\").String() != \"msg-1\" ||\n\t\tinput[1].Get(\"id\").String() != \"fc-1\" ||\n\t\tinput[2].Get(\"id\").String() != \"assistant-1\" ||\n\t\tinput[3].Get(\"id\").String() != \"tool-out-1\" {\n\t\tt.Fatalf(\"unexpected merged input order\")\n\t}\n\tif !bytes.Equal(next, normalized) {\n\t\tt.Fatalf(\"next request snapshot should match normalized request\")\n\t}\n}\n\nfunc TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDIncremental(t *testing.T) {\n\tlastRequest := []byte(`{\"model\":\"test-model\",\"stream\":true,\"instructions\":\"be helpful\",\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\tlastResponseOutput := []byte(`[\n\t\t{\"type\":\"function_call\",\"id\":\"fc-1\",\"call_id\":\"call-1\"},\n\t\t{\"type\":\"message\",\"id\":\"assistant-1\"}\n\t]`)\n\traw := []byte(`{\"type\":\"response.create\",\"previous_response_id\":\"resp-1\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call-1\",\"id\":\"tool-out-1\"}]}`)\n\n\tnormalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, true)\n\tif errMsg != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errMsg.Error)\n\t}\n\tif gjson.GetBytes(normalized, \"type\").Exists() {\n\t\tt.Fatalf(\"normalized request must not include type field\")\n\t}\n\tif gjson.GetBytes(normalized, \"previous_response_id\").String() != \"resp-1\" {\n\t\tt.Fatalf(\"previous_response_id must be preserved in incremental mode\")\n\t}\n\tinput := gjson.GetBytes(normalized, \"input\").Array()\n\tif len(input) != 1 {\n\t\tt.Fatalf(\"incremental input len = %d, want 1\", len(input))\n\t}\n\tif input[0].Get(\"id\").String() != \"tool-out-1\" {\n\t\tt.Fatalf(\"unexpected incremental input item id: %s\", input[0].Get(\"id\").String())\n\t}\n\tif gjson.GetBytes(normalized, \"model\").String() != \"test-model\" {\n\t\tt.Fatalf(\"unexpected model: %s\", gjson.GetBytes(normalized, \"model\").String())\n\t}\n\tif gjson.GetBytes(normalized, \"instructions\").String() != \"be helpful\" {\n\t\tt.Fatalf(\"unexpected instructions: %s\", gjson.GetBytes(normalized, \"instructions\").String())\n\t}\n\tif !bytes.Equal(next, normalized) {\n\t\tt.Fatalf(\"next request snapshot should match normalized request\")\n\t}\n}\n\nfunc TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDMergedWhenIncrementalDisabled(t *testing.T) {\n\tlastRequest := []byte(`{\"model\":\"test-model\",\"stream\":true,\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\tlastResponseOutput := []byte(`[\n\t\t{\"type\":\"function_call\",\"id\":\"fc-1\",\"call_id\":\"call-1\"},\n\t\t{\"type\":\"message\",\"id\":\"assistant-1\"}\n\t]`)\n\traw := []byte(`{\"type\":\"response.create\",\"previous_response_id\":\"resp-1\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call-1\",\"id\":\"tool-out-1\"}]}`)\n\n\tnormalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false)\n\tif errMsg != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errMsg.Error)\n\t}\n\tif gjson.GetBytes(normalized, \"previous_response_id\").Exists() {\n\t\tt.Fatalf(\"previous_response_id must be removed when incremental mode is disabled\")\n\t}\n\tinput := gjson.GetBytes(normalized, \"input\").Array()\n\tif len(input) != 4 {\n\t\tt.Fatalf(\"merged input len = %d, want 4\", len(input))\n\t}\n\tif input[0].Get(\"id\").String() != \"msg-1\" ||\n\t\tinput[1].Get(\"id\").String() != \"fc-1\" ||\n\t\tinput[2].Get(\"id\").String() != \"assistant-1\" ||\n\t\tinput[3].Get(\"id\").String() != \"tool-out-1\" {\n\t\tt.Fatalf(\"unexpected merged input order\")\n\t}\n\tif !bytes.Equal(next, normalized) {\n\t\tt.Fatalf(\"next request snapshot should match normalized request\")\n\t}\n}\n\nfunc TestNormalizeResponsesWebsocketRequestAppend(t *testing.T) {\n\tlastRequest := []byte(`{\"model\":\"test-model\",\"stream\":true,\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`)\n\tlastResponseOutput := []byte(`[\n\t\t{\"type\":\"message\",\"id\":\"assistant-1\"},\n\t\t{\"type\":\"function_call_output\",\"id\":\"tool-out-1\"}\n\t]`)\n\traw := []byte(`{\"type\":\"response.append\",\"input\":[{\"type\":\"message\",\"id\":\"msg-2\"},{\"type\":\"message\",\"id\":\"msg-3\"}]}`)\n\n\tnormalized, next, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)\n\tif errMsg != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", errMsg.Error)\n\t}\n\tinput := gjson.GetBytes(normalized, \"input\").Array()\n\tif len(input) != 5 {\n\t\tt.Fatalf(\"merged input len = %d, want 5\", len(input))\n\t}\n\tif input[0].Get(\"id\").String() != \"msg-1\" ||\n\t\tinput[1].Get(\"id\").String() != \"assistant-1\" ||\n\t\tinput[2].Get(\"id\").String() != \"tool-out-1\" ||\n\t\tinput[3].Get(\"id\").String() != \"msg-2\" ||\n\t\tinput[4].Get(\"id\").String() != \"msg-3\" {\n\t\tt.Fatalf(\"unexpected merged input order\")\n\t}\n\tif !bytes.Equal(next, normalized) {\n\t\tt.Fatalf(\"next request snapshot should match normalized append request\")\n\t}\n}\n\nfunc TestNormalizeResponsesWebsocketRequestAppendWithoutCreate(t *testing.T) {\n\traw := []byte(`{\"type\":\"response.append\",\"input\":[]}`)\n\n\t_, _, errMsg := normalizeResponsesWebsocketRequest(raw, nil, nil)\n\tif errMsg == nil {\n\t\tt.Fatalf(\"expected error for append without previous request\")\n\t}\n\tif errMsg.StatusCode != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d\", errMsg.StatusCode, http.StatusBadRequest)\n\t}\n}\n\nfunc TestWebsocketJSONPayloadsFromChunk(t *testing.T) {\n\tchunk := []byte(\"event: response.created\\n\\ndata: {\\\"type\\\":\\\"response.created\\\",\\\"response\\\":{\\\"id\\\":\\\"resp-1\\\"}}\\n\\ndata: [DONE]\\n\")\n\n\tpayloads := websocketJSONPayloadsFromChunk(chunk)\n\tif len(payloads) != 1 {\n\t\tt.Fatalf(\"payloads len = %d, want 1\", len(payloads))\n\t}\n\tif gjson.GetBytes(payloads[0], \"type\").String() != \"response.created\" {\n\t\tt.Fatalf(\"unexpected payload type: %s\", gjson.GetBytes(payloads[0], \"type\").String())\n\t}\n}\n\nfunc TestWebsocketJSONPayloadsFromPlainJSONChunk(t *testing.T) {\n\tchunk := []byte(`{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\"}}`)\n\n\tpayloads := websocketJSONPayloadsFromChunk(chunk)\n\tif len(payloads) != 1 {\n\t\tt.Fatalf(\"payloads len = %d, want 1\", len(payloads))\n\t}\n\tif gjson.GetBytes(payloads[0], \"type\").String() != \"response.completed\" {\n\t\tt.Fatalf(\"unexpected payload type: %s\", gjson.GetBytes(payloads[0], \"type\").String())\n\t}\n}\n\nfunc TestResponseCompletedOutputFromPayload(t *testing.T) {\n\tpayload := []byte(`{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[{\"type\":\"message\",\"id\":\"out-1\"}]}}`)\n\n\toutput := responseCompletedOutputFromPayload(payload)\n\titems := gjson.ParseBytes(output).Array()\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"output len = %d, want 1\", len(items))\n\t}\n\tif items[0].Get(\"id\").String() != \"out-1\" {\n\t\tt.Fatalf(\"unexpected output id: %s\", items[0].Get(\"id\").String())\n\t}\n}\n\nfunc TestAppendWebsocketEvent(t *testing.T) {\n\tvar builder strings.Builder\n\n\tappendWebsocketEvent(&builder, \"request\", []byte(\"  {\\\"type\\\":\\\"response.create\\\"}\\n\"))\n\tappendWebsocketEvent(&builder, \"response\", []byte(\"{\\\"type\\\":\\\"response.created\\\"}\"))\n\n\tgot := builder.String()\n\tif !strings.Contains(got, \"websocket.request\\n{\\\"type\\\":\\\"response.create\\\"}\\n\") {\n\t\tt.Fatalf(\"request event not found in body: %s\", got)\n\t}\n\tif !strings.Contains(got, \"websocket.response\\n{\\\"type\\\":\\\"response.created\\\"}\\n\") {\n\t\tt.Fatalf(\"response event not found in body: %s\", got)\n\t}\n}\n\nfunc TestAppendWebsocketEventTruncatesAtLimit(t *testing.T) {\n\tvar builder strings.Builder\n\tpayload := bytes.Repeat([]byte(\"x\"), wsBodyLogMaxSize)\n\n\tappendWebsocketEvent(&builder, \"request\", payload)\n\n\tgot := builder.String()\n\tif len(got) > wsBodyLogMaxSize {\n\t\tt.Fatalf(\"body log len = %d, want <= %d\", len(got), wsBodyLogMaxSize)\n\t}\n\tif !strings.Contains(got, wsBodyLogTruncated) {\n\t\tt.Fatalf(\"expected truncation marker in body log\")\n\t}\n}\n\nfunc TestAppendWebsocketEventNoGrowthAfterLimit(t *testing.T) {\n\tvar builder strings.Builder\n\tappendWebsocketEvent(&builder, \"request\", bytes.Repeat([]byte(\"x\"), wsBodyLogMaxSize))\n\tinitial := builder.String()\n\n\tappendWebsocketEvent(&builder, \"response\", []byte(`{\"type\":\"response.completed\"}`))\n\n\tif builder.String() != initial {\n\t\tt.Fatalf(\"builder grew after reaching limit\")\n\t}\n}\n\nfunc TestSetWebsocketRequestBody(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(recorder)\n\n\tsetWebsocketRequestBody(c, \" \\n \")\n\tif _, exists := c.Get(wsRequestBodyKey); exists {\n\t\tt.Fatalf(\"request body key should not be set for empty body\")\n\t}\n\n\tsetWebsocketRequestBody(c, \"event body\")\n\tvalue, exists := c.Get(wsRequestBodyKey)\n\tif !exists {\n\t\tt.Fatalf(\"request body key not set\")\n\t}\n\tbodyBytes, ok := value.([]byte)\n\tif !ok {\n\t\tt.Fatalf(\"request body key type mismatch\")\n\t}\n\tif string(bodyBytes) != \"event body\" {\n\t\tt.Fatalf(\"request body = %q, want %q\", string(bodyBytes), \"event body\")\n\t}\n}\n\nfunc TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tserverErrCh := make(chan error, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tconn, err := responsesWebsocketUpgrader.Upgrade(w, r, nil)\n\t\tif err != nil {\n\t\t\tserverErrCh <- err\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\terrClose := conn.Close()\n\t\t\tif errClose != nil {\n\t\t\t\tserverErrCh <- errClose\n\t\t\t}\n\t\t}()\n\n\t\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tctx.Request = r\n\n\t\tdata := make(chan []byte, 1)\n\t\terrCh := make(chan *interfaces.ErrorMessage)\n\t\tdata <- []byte(\"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp-1\\\",\\\"output\\\":[{\\\"type\\\":\\\"message\\\",\\\"id\\\":\\\"out-1\\\"}]}}\\n\\n\")\n\t\tclose(data)\n\t\tclose(errCh)\n\n\t\tvar bodyLog strings.Builder\n\t\tcompletedOutput, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(\n\t\t\tctx,\n\t\t\tconn,\n\t\t\tfunc(...interface{}) {},\n\t\t\tdata,\n\t\t\terrCh,\n\t\t\t&bodyLog,\n\t\t\t\"session-1\",\n\t\t)\n\t\tif err != nil {\n\t\t\tserverErrCh <- err\n\t\t\treturn\n\t\t}\n\t\tif gjson.GetBytes(completedOutput, \"0.id\").String() != \"out-1\" {\n\t\t\tserverErrCh <- errors.New(\"completed output not captured\")\n\t\t\treturn\n\t\t}\n\t\tserverErrCh <- nil\n\t}))\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\tconn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"dial websocket: %v\", err)\n\t}\n\tdefer func() {\n\t\terrClose := conn.Close()\n\t\tif errClose != nil {\n\t\t\tt.Fatalf(\"close websocket: %v\", errClose)\n\t\t}\n\t}()\n\n\t_, payload, errReadMessage := conn.ReadMessage()\n\tif errReadMessage != nil {\n\t\tt.Fatalf(\"read websocket message: %v\", errReadMessage)\n\t}\n\tif gjson.GetBytes(payload, \"type\").String() != wsEventTypeCompleted {\n\t\tt.Fatalf(\"payload type = %s, want %s\", gjson.GetBytes(payload, \"type\").String(), wsEventTypeCompleted)\n\t}\n\tif strings.Contains(string(payload), \"response.done\") {\n\t\tt.Fatalf(\"payload unexpectedly rewrote completed event: %s\", payload)\n\t}\n\n\tif errServer := <-serverErrCh; errServer != nil {\n\t\tt.Fatalf(\"server error: %v\", errServer)\n\t}\n}\n\nfunc TestWebsocketUpstreamSupportsIncrementalInputForModel(t *testing.T) {\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tauth := &coreauth.Auth{\n\t\tID:         \"auth-ws\",\n\t\tProvider:   \"test-provider\",\n\t\tStatus:     coreauth.StatusActive,\n\t\tAttributes: map[string]string{\"websockets\": \"true\"},\n\t}\n\tif _, err := manager.Register(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"Register auth: %v\", err)\n\t}\n\tregistry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth.ID)\n\t})\n\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\th := NewOpenAIResponsesAPIHandler(base)\n\tif !h.websocketUpstreamSupportsIncrementalInputForModel(\"test-model\") {\n\t\tt.Fatalf(\"expected websocket-capable upstream for test-model\")\n\t}\n}\n\nfunc TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\texecutor := &websocketCaptureExecutor{}\n\tmanager := coreauth.NewManager(nil, nil, nil)\n\tmanager.RegisterExecutor(executor)\n\tauth := &coreauth.Auth{ID: \"auth-sse\", Provider: executor.Identifier(), Status: coreauth.StatusActive}\n\tif _, err := manager.Register(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"Register auth: %v\", err)\n\t}\n\tregistry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(auth.ID)\n\t})\n\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\th := NewOpenAIResponsesAPIHandler(base)\n\trouter := gin.New()\n\trouter.GET(\"/v1/responses/ws\", h.ResponsesWebsocket)\n\n\tserver := httptest.NewServer(router)\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\") + \"/v1/responses/ws\"\n\tconn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"dial websocket: %v\", err)\n\t}\n\tdefer func() {\n\t\terrClose := conn.Close()\n\t\tif errClose != nil {\n\t\t\tt.Fatalf(\"close websocket: %v\", errClose)\n\t\t}\n\t}()\n\n\terrWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{\"type\":\"response.create\",\"model\":\"test-model\",\"generate\":false}`))\n\tif errWrite != nil {\n\t\tt.Fatalf(\"write prewarm websocket message: %v\", errWrite)\n\t}\n\n\t_, createdPayload, errReadMessage := conn.ReadMessage()\n\tif errReadMessage != nil {\n\t\tt.Fatalf(\"read prewarm created message: %v\", errReadMessage)\n\t}\n\tif gjson.GetBytes(createdPayload, \"type\").String() != \"response.created\" {\n\t\tt.Fatalf(\"created payload type = %s, want response.created\", gjson.GetBytes(createdPayload, \"type\").String())\n\t}\n\tprewarmResponseID := gjson.GetBytes(createdPayload, \"response.id\").String()\n\tif prewarmResponseID == \"\" {\n\t\tt.Fatalf(\"prewarm response id is empty\")\n\t}\n\tif executor.streamCalls != 0 {\n\t\tt.Fatalf(\"stream calls after prewarm = %d, want 0\", executor.streamCalls)\n\t}\n\n\t_, completedPayload, errReadMessage := conn.ReadMessage()\n\tif errReadMessage != nil {\n\t\tt.Fatalf(\"read prewarm completed message: %v\", errReadMessage)\n\t}\n\tif gjson.GetBytes(completedPayload, \"type\").String() != wsEventTypeCompleted {\n\t\tt.Fatalf(\"completed payload type = %s, want %s\", gjson.GetBytes(completedPayload, \"type\").String(), wsEventTypeCompleted)\n\t}\n\tif gjson.GetBytes(completedPayload, \"response.id\").String() != prewarmResponseID {\n\t\tt.Fatalf(\"completed response id = %s, want %s\", gjson.GetBytes(completedPayload, \"response.id\").String(), prewarmResponseID)\n\t}\n\tif gjson.GetBytes(completedPayload, \"response.usage.total_tokens\").Int() != 0 {\n\t\tt.Fatalf(\"prewarm total tokens = %d, want 0\", gjson.GetBytes(completedPayload, \"response.usage.total_tokens\").Int())\n\t}\n\n\tsecondRequest := fmt.Sprintf(`{\"type\":\"response.create\",\"previous_response_id\":%q,\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`, prewarmResponseID)\n\terrWrite = conn.WriteMessage(websocket.TextMessage, []byte(secondRequest))\n\tif errWrite != nil {\n\t\tt.Fatalf(\"write follow-up websocket message: %v\", errWrite)\n\t}\n\n\t_, upstreamPayload, errReadMessage := conn.ReadMessage()\n\tif errReadMessage != nil {\n\t\tt.Fatalf(\"read upstream completed message: %v\", errReadMessage)\n\t}\n\tif gjson.GetBytes(upstreamPayload, \"type\").String() != wsEventTypeCompleted {\n\t\tt.Fatalf(\"upstream payload type = %s, want %s\", gjson.GetBytes(upstreamPayload, \"type\").String(), wsEventTypeCompleted)\n\t}\n\tif executor.streamCalls != 1 {\n\t\tt.Fatalf(\"stream calls after follow-up = %d, want 1\", executor.streamCalls)\n\t}\n\tif len(executor.payloads) != 1 {\n\t\tt.Fatalf(\"captured upstream payloads = %d, want 1\", len(executor.payloads))\n\t}\n\tforwarded := executor.payloads[0]\n\tif gjson.GetBytes(forwarded, \"previous_response_id\").Exists() {\n\t\tt.Fatalf(\"previous_response_id leaked upstream: %s\", forwarded)\n\t}\n\tif gjson.GetBytes(forwarded, \"generate\").Exists() {\n\t\tt.Fatalf(\"generate leaked upstream: %s\", forwarded)\n\t}\n\tif gjson.GetBytes(forwarded, \"model\").String() != \"test-model\" {\n\t\tt.Fatalf(\"forwarded model = %s, want test-model\", gjson.GetBytes(forwarded, \"model\").String())\n\t}\n\tinput := gjson.GetBytes(forwarded, \"input\").Array()\n\tif len(input) != 1 || input[0].Get(\"id\").String() != \"msg-1\" {\n\t\tt.Fatalf(\"unexpected forwarded input: %s\", forwarded)\n\t}\n}\n\nfunc TestResponsesWebsocketPinsOnlyWebsocketCapableAuth(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tselector := &orderedWebsocketSelector{order: []string{\"auth-sse\", \"auth-ws\"}}\n\texecutor := &websocketAuthCaptureExecutor{}\n\tmanager := coreauth.NewManager(nil, selector, nil)\n\tmanager.RegisterExecutor(executor)\n\n\tauthSSE := &coreauth.Auth{ID: \"auth-sse\", Provider: executor.Identifier(), Status: coreauth.StatusActive}\n\tif _, err := manager.Register(context.Background(), authSSE); err != nil {\n\t\tt.Fatalf(\"Register SSE auth: %v\", err)\n\t}\n\tauthWS := &coreauth.Auth{\n\t\tID:         \"auth-ws\",\n\t\tProvider:   executor.Identifier(),\n\t\tStatus:     coreauth.StatusActive,\n\t\tAttributes: map[string]string{\"websockets\": \"true\"},\n\t}\n\tif _, err := manager.Register(context.Background(), authWS); err != nil {\n\t\tt.Fatalf(\"Register websocket auth: %v\", err)\n\t}\n\n\tregistry.GetGlobalRegistry().RegisterClient(authSSE.ID, authSSE.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tregistry.GetGlobalRegistry().RegisterClient(authWS.ID, authWS.Provider, []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\tregistry.GetGlobalRegistry().UnregisterClient(authSSE.ID)\n\t\tregistry.GetGlobalRegistry().UnregisterClient(authWS.ID)\n\t})\n\n\tbase := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)\n\th := NewOpenAIResponsesAPIHandler(base)\n\trouter := gin.New()\n\trouter.GET(\"/v1/responses/ws\", h.ResponsesWebsocket)\n\n\tserver := httptest.NewServer(router)\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\") + \"/v1/responses/ws\"\n\tconn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"dial websocket: %v\", err)\n\t}\n\tdefer func() {\n\t\tif errClose := conn.Close(); errClose != nil {\n\t\t\tt.Fatalf(\"close websocket: %v\", errClose)\n\t\t}\n\t}()\n\n\trequests := []string{\n\t\t`{\"type\":\"response.create\",\"model\":\"test-model\",\"input\":[{\"type\":\"message\",\"id\":\"msg-1\"}]}`,\n\t\t`{\"type\":\"response.create\",\"input\":[{\"type\":\"message\",\"id\":\"msg-2\"}]}`,\n\t}\n\tfor i := range requests {\n\t\tif errWrite := conn.WriteMessage(websocket.TextMessage, []byte(requests[i])); errWrite != nil {\n\t\t\tt.Fatalf(\"write websocket message %d: %v\", i+1, errWrite)\n\t\t}\n\t\t_, payload, errReadMessage := conn.ReadMessage()\n\t\tif errReadMessage != nil {\n\t\t\tt.Fatalf(\"read websocket message %d: %v\", i+1, errReadMessage)\n\t\t}\n\t\tif got := gjson.GetBytes(payload, \"type\").String(); got != wsEventTypeCompleted {\n\t\t\tt.Fatalf(\"message %d payload type = %s, want %s\", i+1, got, wsEventTypeCompleted)\n\t\t}\n\t}\n\n\tif got := executor.AuthIDs(); len(got) != 2 || got[0] != \"auth-sse\" || got[1] != \"auth-ws\" {\n\t\tt.Fatalf(\"selected auth IDs = %v, want [auth-sse auth-ws]\", got)\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai_responses_stream_error.go",
    "content": "package handlers\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n)\n\ntype openAIResponsesStreamErrorChunk struct {\n\tType           string `json:\"type\"`\n\tCode           string `json:\"code\"`\n\tMessage        string `json:\"message\"`\n\tSequenceNumber int    `json:\"sequence_number\"`\n}\n\nfunc openAIResponsesStreamErrorCode(status int) string {\n\tswitch status {\n\tcase http.StatusUnauthorized:\n\t\treturn \"invalid_api_key\"\n\tcase http.StatusForbidden:\n\t\treturn \"insufficient_quota\"\n\tcase http.StatusTooManyRequests:\n\t\treturn \"rate_limit_exceeded\"\n\tcase http.StatusNotFound:\n\t\treturn \"model_not_found\"\n\tcase http.StatusRequestTimeout:\n\t\treturn \"request_timeout\"\n\tdefault:\n\t\tif status >= http.StatusInternalServerError {\n\t\t\treturn \"internal_server_error\"\n\t\t}\n\t\tif status >= http.StatusBadRequest {\n\t\t\treturn \"invalid_request_error\"\n\t\t}\n\t\treturn \"unknown_error\"\n\t}\n}\n\n// BuildOpenAIResponsesStreamErrorChunk builds an OpenAI Responses streaming error chunk.\n//\n// Important: OpenAI's HTTP error bodies are shaped like {\"error\":{...}}; those are valid for\n// non-streaming responses, but streaming clients validate SSE `data:` payloads against a union\n// of chunks that requires a top-level `type` field.\nfunc BuildOpenAIResponsesStreamErrorChunk(status int, errText string, sequenceNumber int) []byte {\n\tif status <= 0 {\n\t\tstatus = http.StatusInternalServerError\n\t}\n\tif sequenceNumber < 0 {\n\t\tsequenceNumber = 0\n\t}\n\n\tmessage := strings.TrimSpace(errText)\n\tif message == \"\" {\n\t\tmessage = http.StatusText(status)\n\t}\n\n\tcode := openAIResponsesStreamErrorCode(status)\n\n\ttrimmed := strings.TrimSpace(errText)\n\tif trimmed != \"\" && json.Valid([]byte(trimmed)) {\n\t\tvar payload map[string]any\n\t\tif err := json.Unmarshal([]byte(trimmed), &payload); err == nil {\n\t\t\tif t, ok := payload[\"type\"].(string); ok && strings.TrimSpace(t) == \"error\" {\n\t\t\t\tif m, ok := payload[\"message\"].(string); ok && strings.TrimSpace(m) != \"\" {\n\t\t\t\t\tmessage = strings.TrimSpace(m)\n\t\t\t\t}\n\t\t\t\tif v, ok := payload[\"code\"]; ok && v != nil {\n\t\t\t\t\tif c, ok := v.(string); ok && strings.TrimSpace(c) != \"\" {\n\t\t\t\t\t\tcode = strings.TrimSpace(c)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcode = strings.TrimSpace(fmt.Sprint(v))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif v, ok := payload[\"sequence_number\"].(float64); ok && sequenceNumber == 0 {\n\t\t\t\t\tsequenceNumber = int(v)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif e, ok := payload[\"error\"].(map[string]any); ok {\n\t\t\t\tif m, ok := e[\"message\"].(string); ok && strings.TrimSpace(m) != \"\" {\n\t\t\t\t\tmessage = strings.TrimSpace(m)\n\t\t\t\t}\n\t\t\t\tif v, ok := e[\"code\"]; ok && v != nil {\n\t\t\t\t\tif c, ok := v.(string); ok && strings.TrimSpace(c) != \"\" {\n\t\t\t\t\t\tcode = strings.TrimSpace(c)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcode = strings.TrimSpace(fmt.Sprint(v))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif strings.TrimSpace(code) == \"\" {\n\t\tcode = \"unknown_error\"\n\t}\n\n\tdata, err := json.Marshal(openAIResponsesStreamErrorChunk{\n\t\tType:           \"error\",\n\t\tCode:           code,\n\t\tMessage:        message,\n\t\tSequenceNumber: sequenceNumber,\n\t})\n\tif err == nil {\n\t\treturn data\n\t}\n\n\t// Extremely defensive fallback.\n\tdata, _ = json.Marshal(openAIResponsesStreamErrorChunk{\n\t\tType:           \"error\",\n\t\tCode:           \"internal_server_error\",\n\t\tMessage:        message,\n\t\tSequenceNumber: sequenceNumber,\n\t})\n\tif len(data) > 0 {\n\t\treturn data\n\t}\n\treturn []byte(`{\"type\":\"error\",\"code\":\"internal_server_error\",\"message\":\"internal error\",\"sequence_number\":0}`)\n}\n"
  },
  {
    "path": "sdk/api/handlers/openai_responses_stream_error_test.go",
    "content": "package handlers\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestBuildOpenAIResponsesStreamErrorChunk(t *testing.T) {\n\tchunk := BuildOpenAIResponsesStreamErrorChunk(http.StatusInternalServerError, \"unexpected EOF\", 0)\n\tvar payload map[string]any\n\tif err := json.Unmarshal(chunk, &payload); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tif payload[\"type\"] != \"error\" {\n\t\tt.Fatalf(\"type = %v, want %q\", payload[\"type\"], \"error\")\n\t}\n\tif payload[\"code\"] != \"internal_server_error\" {\n\t\tt.Fatalf(\"code = %v, want %q\", payload[\"code\"], \"internal_server_error\")\n\t}\n\tif payload[\"message\"] != \"unexpected EOF\" {\n\t\tt.Fatalf(\"message = %v, want %q\", payload[\"message\"], \"unexpected EOF\")\n\t}\n\tif payload[\"sequence_number\"] != float64(0) {\n\t\tt.Fatalf(\"sequence_number = %v, want %v\", payload[\"sequence_number\"], 0)\n\t}\n}\n\nfunc TestBuildOpenAIResponsesStreamErrorChunkExtractsHTTPErrorBody(t *testing.T) {\n\tchunk := BuildOpenAIResponsesStreamErrorChunk(\n\t\thttp.StatusInternalServerError,\n\t\t`{\"error\":{\"message\":\"oops\",\"type\":\"server_error\",\"code\":\"internal_server_error\"}}`,\n\t\t0,\n\t)\n\tvar payload map[string]any\n\tif err := json.Unmarshal(chunk, &payload); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tif payload[\"type\"] != \"error\" {\n\t\tt.Fatalf(\"type = %v, want %q\", payload[\"type\"], \"error\")\n\t}\n\tif payload[\"code\"] != \"internal_server_error\" {\n\t\tt.Fatalf(\"code = %v, want %q\", payload[\"code\"], \"internal_server_error\")\n\t}\n\tif payload[\"message\"] != \"oops\" {\n\t\tt.Fatalf(\"message = %v, want %q\", payload[\"message\"], \"oops\")\n\t}\n}\n"
  },
  {
    "path": "sdk/api/handlers/stream_forwarder.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n)\n\ntype StreamForwardOptions struct {\n\t// KeepAliveInterval overrides the configured streaming keep-alive interval.\n\t// If nil, the configured default is used. If set to <= 0, keep-alives are disabled.\n\tKeepAliveInterval *time.Duration\n\n\t// WriteChunk writes a single data chunk to the response body. It should not flush.\n\tWriteChunk func(chunk []byte)\n\n\t// WriteTerminalError writes an error payload to the response body when streaming fails\n\t// after headers have already been committed. It should not flush.\n\tWriteTerminalError func(errMsg *interfaces.ErrorMessage)\n\n\t// WriteDone optionally writes a terminal marker when the upstream data channel closes\n\t// without an error (e.g. OpenAI's `[DONE]`). It should not flush.\n\tWriteDone func()\n\n\t// WriteKeepAlive optionally writes a keep-alive heartbeat. It should not flush.\n\t// When nil, a standard SSE comment heartbeat is used.\n\tWriteKeepAlive func()\n}\n\nfunc (h *BaseAPIHandler) ForwardStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, opts StreamForwardOptions) {\n\tif c == nil {\n\t\treturn\n\t}\n\tif cancel == nil {\n\t\treturn\n\t}\n\n\twriteChunk := opts.WriteChunk\n\tif writeChunk == nil {\n\t\twriteChunk = func([]byte) {}\n\t}\n\n\twriteKeepAlive := opts.WriteKeepAlive\n\tif writeKeepAlive == nil {\n\t\twriteKeepAlive = func() {\n\t\t\t_, _ = c.Writer.Write([]byte(\": keep-alive\\n\\n\"))\n\t\t}\n\t}\n\n\tkeepAliveInterval := StreamingKeepAliveInterval(h.Cfg)\n\tif opts.KeepAliveInterval != nil {\n\t\tkeepAliveInterval = *opts.KeepAliveInterval\n\t}\n\tvar keepAlive *time.Ticker\n\tvar keepAliveC <-chan time.Time\n\tif keepAliveInterval > 0 {\n\t\tkeepAlive = time.NewTicker(keepAliveInterval)\n\t\tdefer keepAlive.Stop()\n\t\tkeepAliveC = keepAlive.C\n\t}\n\n\tvar terminalErr *interfaces.ErrorMessage\n\tfor {\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tcancel(c.Request.Context().Err())\n\t\t\treturn\n\t\tcase chunk, ok := <-data:\n\t\t\tif !ok {\n\t\t\t\t// Prefer surfacing a terminal error if one is pending.\n\t\t\t\tif terminalErr == nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase errMsg, ok := <-errs:\n\t\t\t\t\t\tif ok && errMsg != nil {\n\t\t\t\t\t\t\tterminalErr = errMsg\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif terminalErr != nil {\n\t\t\t\t\tif opts.WriteTerminalError != nil {\n\t\t\t\t\t\topts.WriteTerminalError(terminalErr)\n\t\t\t\t\t}\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t\tcancel(terminalErr.Error)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif opts.WriteDone != nil {\n\t\t\t\t\topts.WriteDone()\n\t\t\t\t}\n\t\t\t\tflusher.Flush()\n\t\t\t\tcancel(nil)\n\t\t\t\treturn\n\t\t\t}\n\t\t\twriteChunk(chunk)\n\t\t\tflusher.Flush()\n\t\tcase errMsg, ok := <-errs:\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif errMsg != nil {\n\t\t\t\tterminalErr = errMsg\n\t\t\t\tif opts.WriteTerminalError != nil {\n\t\t\t\t\topts.WriteTerminalError(errMsg)\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\t\t\tvar execErr error\n\t\t\tif errMsg != nil {\n\t\t\t\texecErr = errMsg.Error\n\t\t\t}\n\t\t\tcancel(execErr)\n\t\t\treturn\n\t\tcase <-keepAliveC:\n\t\t\twriteKeepAlive()\n\t\t\tflusher.Flush()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "sdk/api/management.go",
    "content": "// Package api exposes helpers for embedding CLIProxyAPI.\n//\n// It wraps internal management handler types so external projects can integrate\n// management endpoints without importing internal packages.\npackage api\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\tinternalmanagement \"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\n// ManagementTokenRequester exposes a limited subset of management endpoints for requesting tokens.\ntype ManagementTokenRequester interface {\n\tRequestAnthropicToken(*gin.Context)\n\tRequestGeminiCLIToken(*gin.Context)\n\tRequestCodexToken(*gin.Context)\n\tRequestAntigravityToken(*gin.Context)\n\tRequestQwenToken(*gin.Context)\n\tRequestKimiToken(*gin.Context)\n\tRequestIFlowToken(*gin.Context)\n\tRequestIFlowCookieToken(*gin.Context)\n\tGetAuthStatus(c *gin.Context)\n\tPostOAuthCallback(c *gin.Context)\n}\n\ntype managementTokenRequester struct {\n\thandler *internalmanagement.Handler\n}\n\n// NewManagementTokenRequester creates a limited management handler exposing only token request endpoints.\nfunc NewManagementTokenRequester(cfg *config.Config, manager *coreauth.Manager) ManagementTokenRequester {\n\treturn &managementTokenRequester{\n\t\thandler: internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager),\n\t}\n}\n\nfunc (m *managementTokenRequester) RequestAnthropicToken(c *gin.Context) {\n\tm.handler.RequestAnthropicToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestGeminiCLIToken(c *gin.Context) {\n\tm.handler.RequestGeminiCLIToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestCodexToken(c *gin.Context) {\n\tm.handler.RequestCodexToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestAntigravityToken(c *gin.Context) {\n\tm.handler.RequestAntigravityToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestQwenToken(c *gin.Context) {\n\tm.handler.RequestQwenToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestKimiToken(c *gin.Context) {\n\tm.handler.RequestKimiToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) {\n\tm.handler.RequestIFlowToken(c)\n}\n\nfunc (m *managementTokenRequester) RequestIFlowCookieToken(c *gin.Context) {\n\tm.handler.RequestIFlowCookieToken(c)\n}\n\nfunc (m *managementTokenRequester) GetAuthStatus(c *gin.Context) {\n\tm.handler.GetAuthStatus(c)\n}\n\nfunc (m *managementTokenRequester) PostOAuthCallback(c *gin.Context) {\n\tm.handler.PostOAuthCallback(c)\n}\n"
  },
  {
    "path": "sdk/api/options.go",
    "content": "// Package api exposes server option helpers for embedding CLIProxyAPI.\n//\n// It wraps internal server option types so external projects can configure the embedded\n// HTTP server without importing internal packages.\npackage api\n\nimport (\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\tinternalapi \"github.com/router-for-me/CLIProxyAPI/v6/internal/api\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging\"\n)\n\n// ServerOption customises HTTP server construction.\ntype ServerOption = internalapi.ServerOption\n\n// WithMiddleware appends additional Gin middleware during server construction.\nfunc WithMiddleware(mw ...gin.HandlerFunc) ServerOption { return internalapi.WithMiddleware(mw...) }\n\n// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.\nfunc WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {\n\treturn internalapi.WithEngineConfigurator(fn)\n}\n\n// WithRouterConfigurator appends a callback after default routes are registered.\nfunc WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {\n\treturn internalapi.WithRouterConfigurator(fn)\n}\n\n// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.\nfunc WithLocalManagementPassword(password string) ServerOption {\n\treturn internalapi.WithLocalManagementPassword(password)\n}\n\n// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.\nfunc WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {\n\treturn internalapi.WithKeepAliveEndpoint(timeout, onTimeout)\n}\n\n// WithRequestLoggerFactory customises request logger creation.\nfunc WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {\n\treturn internalapi.WithRequestLoggerFactory(factory)\n}\n"
  },
  {
    "path": "sdk/auth/antigravity.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// AntigravityAuthenticator implements OAuth login for the antigravity provider.\ntype AntigravityAuthenticator struct{}\n\n// NewAntigravityAuthenticator constructs a new authenticator instance.\nfunc NewAntigravityAuthenticator() Authenticator { return &AntigravityAuthenticator{} }\n\n// Provider returns the provider key for antigravity.\nfunc (AntigravityAuthenticator) Provider() string { return \"antigravity\" }\n\n// RefreshLead instructs the manager to refresh five minutes before expiry.\nfunc (AntigravityAuthenticator) RefreshLead() *time.Duration {\n\treturn new(5 * time.Minute)\n}\n\n// Login launches a local OAuth flow to obtain antigravity tokens and persists them.\nfunc (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tcallbackPort := antigravity.CallbackPort\n\tif opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\n\tauthSvc := antigravity.NewAntigravityAuth(cfg, nil)\n\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity: failed to generate state: %w\", err)\n\t}\n\n\tsrv, port, cbChan, errServer := startAntigravityCallbackServer(callbackPort)\n\tif errServer != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity: failed to start callback server: %w\", errServer)\n\t}\n\tdefer func() {\n\t\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\t_ = srv.Shutdown(shutdownCtx)\n\t}()\n\n\tredirectURI := fmt.Sprintf(\"http://localhost:%d/oauth-callback\", port)\n\tauthURL := authSvc.BuildAuthURL(state, redirectURI)\n\n\tif !opts.NoBrowser {\n\t\tfmt.Println(\"Opening browser for antigravity authentication\")\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the URL manually\")\n\t\t\tutil.PrintSSHTunnelInstructions(port)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t} else if errOpen := browser.OpenURL(authURL); errOpen != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", errOpen)\n\t\t\tutil.PrintSSHTunnelInstructions(port)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t}\n\t} else {\n\t\tutil.PrintSSHTunnelInstructions(port)\n\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for antigravity authentication callback...\")\n\n\tvar cbRes callbackResult\n\ttimeoutTimer := time.NewTimer(5 * time.Minute)\n\tdefer timeoutTimer.Stop()\n\n\tvar manualPromptTimer *time.Timer\n\tvar manualPromptC <-chan time.Time\n\tif opts.Prompt != nil {\n\t\tmanualPromptTimer = time.NewTimer(15 * time.Second)\n\t\tmanualPromptC = manualPromptTimer.C\n\t\tdefer manualPromptTimer.Stop()\n\t}\n\nwaitForCallback:\n\tfor {\n\t\tselect {\n\t\tcase res := <-cbChan:\n\t\t\tcbRes = res\n\t\t\tbreak waitForCallback\n\t\tcase <-manualPromptC:\n\t\t\tmanualPromptC = nil\n\t\t\tif manualPromptTimer != nil {\n\t\t\t\tmanualPromptTimer.Stop()\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase res := <-cbChan:\n\t\t\t\tcbRes = res\n\t\t\t\tbreak waitForCallback\n\t\t\tdefault:\n\t\t\t}\n\t\t\tinput, errPrompt := opts.Prompt(\"Paste the antigravity callback URL (or press Enter to keep waiting): \")\n\t\t\tif errPrompt != nil {\n\t\t\t\treturn nil, errPrompt\n\t\t\t}\n\t\t\tparsed, errParse := misc.ParseOAuthCallback(input)\n\t\t\tif errParse != nil {\n\t\t\t\treturn nil, errParse\n\t\t\t}\n\t\t\tif parsed == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcbRes = callbackResult{\n\t\t\t\tCode:  parsed.Code,\n\t\t\t\tState: parsed.State,\n\t\t\t\tError: parsed.Error,\n\t\t\t}\n\t\t\tbreak waitForCallback\n\t\tcase <-timeoutTimer.C:\n\t\t\treturn nil, fmt.Errorf(\"antigravity: authentication timed out\")\n\t\t}\n\t}\n\n\tif cbRes.Error != \"\" {\n\t\treturn nil, fmt.Errorf(\"antigravity: authentication failed: %s\", cbRes.Error)\n\t}\n\tif cbRes.State != state {\n\t\treturn nil, fmt.Errorf(\"antigravity: invalid state\")\n\t}\n\tif cbRes.Code == \"\" {\n\t\treturn nil, fmt.Errorf(\"antigravity: missing authorization code\")\n\t}\n\n\ttokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, cbRes.Code, redirectURI)\n\tif errToken != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity: token exchange failed: %w\", errToken)\n\t}\n\n\taccessToken := strings.TrimSpace(tokenResp.AccessToken)\n\tif accessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"antigravity: token exchange returned empty access token\")\n\t}\n\n\temail, errInfo := authSvc.FetchUserInfo(ctx, accessToken)\n\tif errInfo != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity: fetch user info failed: %w\", errInfo)\n\t}\n\temail = strings.TrimSpace(email)\n\tif email == \"\" {\n\t\treturn nil, fmt.Errorf(\"antigravity: empty email returned from user info\")\n\t}\n\n\t// Fetch project ID via loadCodeAssist (same approach as Gemini CLI)\n\tprojectID := \"\"\n\tif accessToken != \"\" {\n\t\tfetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)\n\t\tif errProject != nil {\n\t\t\tlog.Warnf(\"antigravity: failed to fetch project ID: %v\", errProject)\n\t\t} else {\n\t\t\tprojectID = fetchedProjectID\n\t\t\tlog.Infof(\"antigravity: obtained project ID %s\", projectID)\n\t\t}\n\t}\n\n\tnow := time.Now()\n\tmetadata := map[string]any{\n\t\t\"type\":          \"antigravity\",\n\t\t\"access_token\":  tokenResp.AccessToken,\n\t\t\"refresh_token\": tokenResp.RefreshToken,\n\t\t\"expires_in\":    tokenResp.ExpiresIn,\n\t\t\"timestamp\":     now.UnixMilli(),\n\t\t\"expired\":       now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),\n\t}\n\tif email != \"\" {\n\t\tmetadata[\"email\"] = email\n\t}\n\tif projectID != \"\" {\n\t\tmetadata[\"project_id\"] = projectID\n\t}\n\n\tfileName := antigravity.CredentialFileName(email)\n\tlabel := email\n\tif label == \"\" {\n\t\tlabel = \"antigravity\"\n\t}\n\n\tfmt.Println(\"Antigravity authentication successful\")\n\tif projectID != \"\" {\n\t\tfmt.Printf(\"Using GCP project: %s\\n\", projectID)\n\t}\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: \"antigravity\",\n\t\tFileName: fileName,\n\t\tLabel:    label,\n\t\tMetadata: metadata,\n\t}, nil\n}\n\ntype callbackResult struct {\n\tCode  string\n\tError string\n\tState string\n}\n\nfunc startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) {\n\tif port <= 0 {\n\t\tport = antigravity.CallbackPort\n\t}\n\taddr := fmt.Sprintf(\":%d\", port)\n\tlistener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn nil, 0, nil, err\n\t}\n\tport = listener.Addr().(*net.TCPAddr).Port\n\tresultCh := make(chan callbackResult, 1)\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/oauth-callback\", func(w http.ResponseWriter, r *http.Request) {\n\t\tq := r.URL.Query()\n\t\tres := callbackResult{\n\t\t\tCode:  strings.TrimSpace(q.Get(\"code\")),\n\t\t\tError: strings.TrimSpace(q.Get(\"error\")),\n\t\t\tState: strings.TrimSpace(q.Get(\"state\")),\n\t\t}\n\t\tresultCh <- res\n\t\tif res.Code != \"\" && res.Error == \"\" {\n\t\t\t_, _ = w.Write([]byte(\"<h1>Login successful</h1><p>You can close this window.</p>\"))\n\t\t} else {\n\t\t\t_, _ = w.Write([]byte(\"<h1>Login failed</h1><p>Please check the CLI output.</p>\"))\n\t\t}\n\t})\n\n\tsrv := &http.Server{Handler: mux}\n\tgo func() {\n\t\tif errServe := srv.Serve(listener); errServe != nil && !strings.Contains(errServe.Error(), \"Server closed\") {\n\t\t\tlog.Warnf(\"antigravity callback server error: %v\", errServe)\n\t\t}\n\t}()\n\n\treturn srv, port, resultCh, nil\n}\n\n// FetchAntigravityProjectID exposes project discovery for external callers.\nfunc FetchAntigravityProjectID(ctx context.Context, accessToken string, httpClient *http.Client) (string, error) {\n\tcfg := &config.Config{}\n\tauthSvc := antigravity.NewAntigravityAuth(cfg, httpClient)\n\treturn authSvc.FetchProjectID(ctx, accessToken)\n}\n"
  },
  {
    "path": "sdk/auth/claude.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t// legacy client removed\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ClaudeAuthenticator implements the OAuth login flow for Anthropic Claude accounts.\ntype ClaudeAuthenticator struct {\n\tCallbackPort int\n}\n\n// NewClaudeAuthenticator constructs a Claude authenticator with default settings.\nfunc NewClaudeAuthenticator() *ClaudeAuthenticator {\n\treturn &ClaudeAuthenticator{CallbackPort: 54545}\n}\n\nfunc (a *ClaudeAuthenticator) Provider() string {\n\treturn \"claude\"\n}\n\nfunc (a *ClaudeAuthenticator) RefreshLead() *time.Duration {\n\treturn new(4 * time.Hour)\n}\n\nfunc (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tcallbackPort := a.CallbackPort\n\tif opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\n\tpkceCodes, err := claude.GeneratePKCECodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"claude pkce generation failed: %w\", err)\n\t}\n\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"claude state generation failed: %w\", err)\n\t}\n\n\toauthServer := claude.NewOAuthServer(callbackPort)\n\tif err = oauthServer.Start(); err != nil {\n\t\tif strings.Contains(err.Error(), \"already in use\") {\n\t\t\treturn nil, claude.NewAuthenticationError(claude.ErrPortInUse, err)\n\t\t}\n\t\treturn nil, claude.NewAuthenticationError(claude.ErrServerStartFailed, err)\n\t}\n\tdefer func() {\n\t\tstopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tif stopErr := oauthServer.Stop(stopCtx); stopErr != nil {\n\t\t\tlog.Warnf(\"claude oauth server stop error: %v\", stopErr)\n\t\t}\n\t}()\n\n\tauthSvc := claude.NewClaudeAuth(cfg)\n\n\tauthURL, returnedState, err := authSvc.GenerateAuthURL(state, pkceCodes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"claude authorization url generation failed: %w\", err)\n\t}\n\tstate = returnedState\n\n\tif !opts.NoBrowser {\n\t\tfmt.Println(\"Opening browser for Claude authentication\")\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the URL manually\")\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t} else if err = browser.OpenURL(authURL); err != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", err)\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t}\n\t} else {\n\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for Claude authentication callback...\")\n\n\tcallbackCh := make(chan *claude.OAuthResult, 1)\n\tcallbackErrCh := make(chan error, 1)\n\tmanualDescription := \"\"\n\n\tgo func() {\n\t\tresult, errWait := oauthServer.WaitForCallback(5 * time.Minute)\n\t\tif errWait != nil {\n\t\t\tcallbackErrCh <- errWait\n\t\t\treturn\n\t\t}\n\t\tcallbackCh <- result\n\t}()\n\n\tvar result *claude.OAuthResult\n\tvar manualPromptTimer *time.Timer\n\tvar manualPromptC <-chan time.Time\n\tif opts.Prompt != nil {\n\t\tmanualPromptTimer = time.NewTimer(15 * time.Second)\n\t\tmanualPromptC = manualPromptTimer.C\n\t\tdefer manualPromptTimer.Stop()\n\t}\n\nwaitForCallback:\n\tfor {\n\t\tselect {\n\t\tcase result = <-callbackCh:\n\t\t\tbreak waitForCallback\n\t\tcase err = <-callbackErrCh:\n\t\t\tif strings.Contains(err.Error(), \"timeout\") {\n\t\t\t\treturn nil, claude.NewAuthenticationError(claude.ErrCallbackTimeout, err)\n\t\t\t}\n\t\t\treturn nil, err\n\t\tcase <-manualPromptC:\n\t\t\tmanualPromptC = nil\n\t\t\tif manualPromptTimer != nil {\n\t\t\t\tmanualPromptTimer.Stop()\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase result = <-callbackCh:\n\t\t\t\tbreak waitForCallback\n\t\t\tcase err = <-callbackErrCh:\n\t\t\t\tif strings.Contains(err.Error(), \"timeout\") {\n\t\t\t\t\treturn nil, claude.NewAuthenticationError(claude.ErrCallbackTimeout, err)\n\t\t\t\t}\n\t\t\t\treturn nil, err\n\t\t\tdefault:\n\t\t\t}\n\t\t\tinput, errPrompt := opts.Prompt(\"Paste the Claude callback URL (or press Enter to keep waiting): \")\n\t\t\tif errPrompt != nil {\n\t\t\t\treturn nil, errPrompt\n\t\t\t}\n\t\t\tparsed, errParse := misc.ParseOAuthCallback(input)\n\t\t\tif errParse != nil {\n\t\t\t\treturn nil, errParse\n\t\t\t}\n\t\t\tif parsed == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmanualDescription = parsed.ErrorDescription\n\t\t\tresult = &claude.OAuthResult{\n\t\t\t\tCode:  parsed.Code,\n\t\t\t\tState: parsed.State,\n\t\t\t\tError: parsed.Error,\n\t\t\t}\n\t\t\tbreak waitForCallback\n\t\t}\n\t}\n\n\tif result.Error != \"\" {\n\t\treturn nil, claude.NewOAuthError(result.Error, manualDescription, http.StatusBadRequest)\n\t}\n\n\tif result.State != state {\n\t\tlog.Errorf(\"State mismatch: expected %s, got %s\", state, result.State)\n\t\treturn nil, claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf(\"state mismatch\"))\n\t}\n\n\tlog.Debug(\"Claude authorization code received; exchanging for tokens\")\n\tlog.Debugf(\"Code: %s, State: %s\", result.Code[:min(20, len(result.Code))], state)\n\n\tauthBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, state, pkceCodes)\n\tif err != nil {\n\t\tlog.Errorf(\"Token exchange failed: %v\", err)\n\t\treturn nil, claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, err)\n\t}\n\n\ttokenStorage := authSvc.CreateTokenStorage(authBundle)\n\n\tif tokenStorage == nil || tokenStorage.Email == \"\" {\n\t\treturn nil, fmt.Errorf(\"claude token storage missing account information\")\n\t}\n\n\tfileName := fmt.Sprintf(\"claude-%s.json\", tokenStorage.Email)\n\tmetadata := map[string]any{\n\t\t\"email\": tokenStorage.Email,\n\t}\n\n\tfmt.Println(\"Claude authentication successful\")\n\tif authBundle.APIKey != \"\" {\n\t\tfmt.Println(\"Claude API key obtained and stored\")\n\t}\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tStorage:  tokenStorage,\n\t\tMetadata: metadata,\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/codex.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t// legacy client removed\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// CodexAuthenticator implements the OAuth login flow for Codex accounts.\ntype CodexAuthenticator struct {\n\tCallbackPort int\n}\n\n// NewCodexAuthenticator constructs a Codex authenticator with default settings.\nfunc NewCodexAuthenticator() *CodexAuthenticator {\n\treturn &CodexAuthenticator{CallbackPort: 1455}\n}\n\nfunc (a *CodexAuthenticator) Provider() string {\n\treturn \"codex\"\n}\n\nfunc (a *CodexAuthenticator) RefreshLead() *time.Duration {\n\treturn new(5 * 24 * time.Hour)\n}\n\nfunc (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tif shouldUseCodexDeviceFlow(opts) {\n\t\treturn a.loginWithDeviceFlow(ctx, cfg, opts)\n\t}\n\n\tcallbackPort := a.CallbackPort\n\tif opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\n\tpkceCodes, err := codex.GeneratePKCECodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"codex pkce generation failed: %w\", err)\n\t}\n\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"codex state generation failed: %w\", err)\n\t}\n\n\toauthServer := codex.NewOAuthServer(callbackPort)\n\tif err = oauthServer.Start(); err != nil {\n\t\tif strings.Contains(err.Error(), \"already in use\") {\n\t\t\treturn nil, codex.NewAuthenticationError(codex.ErrPortInUse, err)\n\t\t}\n\t\treturn nil, codex.NewAuthenticationError(codex.ErrServerStartFailed, err)\n\t}\n\tdefer func() {\n\t\tstopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tif stopErr := oauthServer.Stop(stopCtx); stopErr != nil {\n\t\t\tlog.Warnf(\"codex oauth server stop error: %v\", stopErr)\n\t\t}\n\t}()\n\n\tauthSvc := codex.NewCodexAuth(cfg)\n\n\tauthURL, err := authSvc.GenerateAuthURL(state, pkceCodes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"codex authorization url generation failed: %w\", err)\n\t}\n\n\tif !opts.NoBrowser {\n\t\tfmt.Println(\"Opening browser for Codex authentication\")\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the URL manually\")\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t} else if err = browser.OpenURL(authURL); err != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", err)\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t}\n\t} else {\n\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for Codex authentication callback...\")\n\n\tcallbackCh := make(chan *codex.OAuthResult, 1)\n\tcallbackErrCh := make(chan error, 1)\n\tmanualDescription := \"\"\n\n\tgo func() {\n\t\tresult, errWait := oauthServer.WaitForCallback(5 * time.Minute)\n\t\tif errWait != nil {\n\t\t\tcallbackErrCh <- errWait\n\t\t\treturn\n\t\t}\n\t\tcallbackCh <- result\n\t}()\n\n\tvar result *codex.OAuthResult\n\tvar manualPromptTimer *time.Timer\n\tvar manualPromptC <-chan time.Time\n\tif opts.Prompt != nil {\n\t\tmanualPromptTimer = time.NewTimer(15 * time.Second)\n\t\tmanualPromptC = manualPromptTimer.C\n\t\tdefer manualPromptTimer.Stop()\n\t}\n\nwaitForCallback:\n\tfor {\n\t\tselect {\n\t\tcase result = <-callbackCh:\n\t\t\tbreak waitForCallback\n\t\tcase err = <-callbackErrCh:\n\t\t\tif strings.Contains(err.Error(), \"timeout\") {\n\t\t\t\treturn nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)\n\t\t\t}\n\t\t\treturn nil, err\n\t\tcase <-manualPromptC:\n\t\t\tmanualPromptC = nil\n\t\t\tif manualPromptTimer != nil {\n\t\t\t\tmanualPromptTimer.Stop()\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase result = <-callbackCh:\n\t\t\t\tbreak waitForCallback\n\t\t\tcase err = <-callbackErrCh:\n\t\t\t\tif strings.Contains(err.Error(), \"timeout\") {\n\t\t\t\t\treturn nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)\n\t\t\t\t}\n\t\t\t\treturn nil, err\n\t\t\tdefault:\n\t\t\t}\n\t\t\tinput, errPrompt := opts.Prompt(\"Paste the Codex callback URL (or press Enter to keep waiting): \")\n\t\t\tif errPrompt != nil {\n\t\t\t\treturn nil, errPrompt\n\t\t\t}\n\t\t\tparsed, errParse := misc.ParseOAuthCallback(input)\n\t\t\tif errParse != nil {\n\t\t\t\treturn nil, errParse\n\t\t\t}\n\t\t\tif parsed == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmanualDescription = parsed.ErrorDescription\n\t\t\tresult = &codex.OAuthResult{\n\t\t\t\tCode:  parsed.Code,\n\t\t\t\tState: parsed.State,\n\t\t\t\tError: parsed.Error,\n\t\t\t}\n\t\t\tbreak waitForCallback\n\t\t}\n\t}\n\n\tif result.Error != \"\" {\n\t\treturn nil, codex.NewOAuthError(result.Error, manualDescription, http.StatusBadRequest)\n\t}\n\n\tif result.State != state {\n\t\treturn nil, codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf(\"state mismatch\"))\n\t}\n\n\tlog.Debug(\"Codex authorization code received; exchanging for tokens\")\n\n\tauthBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, pkceCodes)\n\tif err != nil {\n\t\treturn nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)\n\t}\n\n\treturn a.buildAuthRecord(authSvc, authBundle)\n}\n"
  },
  {
    "path": "sdk/auth/codex_device.go",
    "content": "package auth\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tcodexLoginModeMetadataKey             = \"codex_login_mode\"\n\tcodexLoginModeDevice                  = \"device\"\n\tcodexDeviceUserCodeURL                = \"https://auth.openai.com/api/accounts/deviceauth/usercode\"\n\tcodexDeviceTokenURL                   = \"https://auth.openai.com/api/accounts/deviceauth/token\"\n\tcodexDeviceVerificationURL            = \"https://auth.openai.com/codex/device\"\n\tcodexDeviceTokenExchangeRedirectURI   = \"https://auth.openai.com/deviceauth/callback\"\n\tcodexDeviceTimeout                    = 15 * time.Minute\n\tcodexDeviceDefaultPollIntervalSeconds = 5\n)\n\ntype codexDeviceUserCodeRequest struct {\n\tClientID string `json:\"client_id\"`\n}\n\ntype codexDeviceUserCodeResponse struct {\n\tDeviceAuthID string          `json:\"device_auth_id\"`\n\tUserCode     string          `json:\"user_code\"`\n\tUserCodeAlt  string          `json:\"usercode\"`\n\tInterval     json.RawMessage `json:\"interval\"`\n}\n\ntype codexDeviceTokenRequest struct {\n\tDeviceAuthID string `json:\"device_auth_id\"`\n\tUserCode     string `json:\"user_code\"`\n}\n\ntype codexDeviceTokenResponse struct {\n\tAuthorizationCode string `json:\"authorization_code\"`\n\tCodeVerifier      string `json:\"code_verifier\"`\n\tCodeChallenge     string `json:\"code_challenge\"`\n}\n\nfunc shouldUseCodexDeviceFlow(opts *LoginOptions) bool {\n\tif opts == nil || opts.Metadata == nil {\n\t\treturn false\n\t}\n\treturn strings.EqualFold(strings.TrimSpace(opts.Metadata[codexLoginModeMetadataKey]), codexLoginModeDevice)\n}\n\nfunc (a *CodexAuthenticator) loginWithDeviceFlow(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\n\thttpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})\n\n\tuserCodeResp, err := requestCodexDeviceUserCode(ctx, httpClient)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdeviceCode := strings.TrimSpace(userCodeResp.UserCode)\n\tif deviceCode == \"\" {\n\t\tdeviceCode = strings.TrimSpace(userCodeResp.UserCodeAlt)\n\t}\n\tdeviceAuthID := strings.TrimSpace(userCodeResp.DeviceAuthID)\n\tif deviceCode == \"\" || deviceAuthID == \"\" {\n\t\treturn nil, fmt.Errorf(\"codex device flow did not return required fields\")\n\t}\n\n\tpollInterval := parseCodexDevicePollInterval(userCodeResp.Interval)\n\n\tfmt.Println(\"Starting Codex device authentication...\")\n\tfmt.Printf(\"Codex device URL: %s\\n\", codexDeviceVerificationURL)\n\tfmt.Printf(\"Codex device code: %s\\n\", deviceCode)\n\n\tif !opts.NoBrowser {\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the device URL manually\")\n\t\t} else if errOpen := browser.OpenURL(codexDeviceVerificationURL); errOpen != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", errOpen)\n\t\t}\n\t}\n\n\ttokenResp, err := pollCodexDeviceToken(ctx, httpClient, deviceAuthID, deviceCode, pollInterval)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tauthCode := strings.TrimSpace(tokenResp.AuthorizationCode)\n\tcodeVerifier := strings.TrimSpace(tokenResp.CodeVerifier)\n\tcodeChallenge := strings.TrimSpace(tokenResp.CodeChallenge)\n\tif authCode == \"\" || codeVerifier == \"\" || codeChallenge == \"\" {\n\t\treturn nil, fmt.Errorf(\"codex device flow token response missing required fields\")\n\t}\n\n\tauthSvc := codex.NewCodexAuth(cfg)\n\tauthBundle, err := authSvc.ExchangeCodeForTokensWithRedirect(\n\t\tctx,\n\t\tauthCode,\n\t\tcodexDeviceTokenExchangeRedirectURI,\n\t\t&codex.PKCECodes{\n\t\t\tCodeVerifier:  codeVerifier,\n\t\t\tCodeChallenge: codeChallenge,\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)\n\t}\n\n\treturn a.buildAuthRecord(authSvc, authBundle)\n}\n\nfunc requestCodexDeviceUserCode(ctx context.Context, client *http.Client) (*codexDeviceUserCodeResponse, error) {\n\tbody, err := json.Marshal(codexDeviceUserCodeRequest{ClientID: codex.ClientID})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to encode codex device request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceUserCodeURL, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create codex device request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to request codex device code: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read codex device code response: %w\", err)\n\t}\n\n\tif !codexDeviceIsSuccessStatus(resp.StatusCode) {\n\t\ttrimmed := strings.TrimSpace(string(respBody))\n\t\tif resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, fmt.Errorf(\"codex device endpoint is unavailable (status %d)\", resp.StatusCode)\n\t\t}\n\t\tif trimmed == \"\" {\n\t\t\ttrimmed = \"empty response body\"\n\t\t}\n\t\treturn nil, fmt.Errorf(\"codex device code request failed with status %d: %s\", resp.StatusCode, trimmed)\n\t}\n\n\tvar parsed codexDeviceUserCodeResponse\n\tif err := json.Unmarshal(respBody, &parsed); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode codex device code response: %w\", err)\n\t}\n\n\treturn &parsed, nil\n}\n\nfunc pollCodexDeviceToken(ctx context.Context, client *http.Client, deviceAuthID, userCode string, interval time.Duration) (*codexDeviceTokenResponse, error) {\n\tdeadline := time.Now().Add(codexDeviceTimeout)\n\n\tfor {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn nil, fmt.Errorf(\"codex device authentication timed out after 15 minutes\")\n\t\t}\n\n\t\tbody, err := json.Marshal(codexDeviceTokenRequest{\n\t\t\tDeviceAuthID: deviceAuthID,\n\t\t\tUserCode:     userCode,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to encode codex device poll request: %w\", err)\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceTokenURL, bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create codex device poll request: %w\", err)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"Accept\", \"application/json\")\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to poll codex device token: %w\", err)\n\t\t}\n\n\t\trespBody, readErr := io.ReadAll(resp.Body)\n\t\t_ = resp.Body.Close()\n\t\tif readErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read codex device poll response: %w\", readErr)\n\t\t}\n\n\t\tswitch {\n\t\tcase codexDeviceIsSuccessStatus(resp.StatusCode):\n\t\t\tvar parsed codexDeviceTokenResponse\n\t\t\tif err := json.Unmarshal(respBody, &parsed); err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to decode codex device token response: %w\", err)\n\t\t\t}\n\t\t\treturn &parsed, nil\n\t\tcase resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound:\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(interval):\n\t\t\t\tcontinue\n\t\t\t}\n\t\tdefault:\n\t\t\ttrimmed := strings.TrimSpace(string(respBody))\n\t\t\tif trimmed == \"\" {\n\t\t\t\ttrimmed = \"empty response body\"\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"codex device token polling failed with status %d: %s\", resp.StatusCode, trimmed)\n\t\t}\n\t}\n}\n\nfunc parseCodexDevicePollInterval(raw json.RawMessage) time.Duration {\n\tdefaultInterval := time.Duration(codexDeviceDefaultPollIntervalSeconds) * time.Second\n\tif len(raw) == 0 {\n\t\treturn defaultInterval\n\t}\n\n\tvar asString string\n\tif err := json.Unmarshal(raw, &asString); err == nil {\n\t\tif seconds, convErr := strconv.Atoi(strings.TrimSpace(asString)); convErr == nil && seconds > 0 {\n\t\t\treturn time.Duration(seconds) * time.Second\n\t\t}\n\t}\n\n\tvar asInt int\n\tif err := json.Unmarshal(raw, &asInt); err == nil && asInt > 0 {\n\t\treturn time.Duration(asInt) * time.Second\n\t}\n\n\treturn defaultInterval\n}\n\nfunc codexDeviceIsSuccessStatus(code int) bool {\n\treturn code >= 200 && code < 300\n}\n\nfunc (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundle *codex.CodexAuthBundle) (*coreauth.Auth, error) {\n\ttokenStorage := authSvc.CreateTokenStorage(authBundle)\n\n\tif tokenStorage == nil || tokenStorage.Email == \"\" {\n\t\treturn nil, fmt.Errorf(\"codex token storage missing account information\")\n\t}\n\n\tplanType := \"\"\n\thashAccountID := \"\"\n\tif tokenStorage.IDToken != \"\" {\n\t\tif claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil {\n\t\t\tplanType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)\n\t\t\taccountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID)\n\t\t\tif accountID != \"\" {\n\t\t\t\tdigest := sha256.Sum256([]byte(accountID))\n\t\t\t\thashAccountID = hex.EncodeToString(digest[:])[:8]\n\t\t\t}\n\t\t}\n\t}\n\n\tfileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)\n\tmetadata := map[string]any{\n\t\t\"email\": tokenStorage.Email,\n\t}\n\n\tfmt.Println(\"Codex authentication successful\")\n\tif authBundle.APIKey != \"\" {\n\t\tfmt.Println(\"Codex API key obtained and stored\")\n\t}\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tStorage:  tokenStorage,\n\t\tMetadata: metadata,\n\t\tAttributes: map[string]string{\n\t\t\t\"plan_type\": planType,\n\t\t},\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/errors.go",
    "content": "package auth\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces\"\n)\n\n// ProjectSelectionError indicates that the user must choose a specific project ID.\ntype ProjectSelectionError struct {\n\tEmail    string\n\tProjects []interfaces.GCPProjectProjects\n}\n\nfunc (e *ProjectSelectionError) Error() string {\n\tif e == nil {\n\t\treturn \"cliproxy auth: project selection required\"\n\t}\n\treturn fmt.Sprintf(\"cliproxy auth: project selection required for %s\", e.Email)\n}\n\n// ProjectsDisplay returns the projects list for caller presentation.\nfunc (e *ProjectSelectionError) ProjectsDisplay() []interfaces.GCPProjectProjects {\n\tif e == nil {\n\t\treturn nil\n\t}\n\treturn e.Projects\n}\n\n// EmailRequiredError indicates that the calling context must provide an email or alias.\ntype EmailRequiredError struct {\n\tPrompt string\n}\n\nfunc (e *EmailRequiredError) Error() string {\n\tif e == nil || e.Prompt == \"\" {\n\t\treturn \"cliproxy auth: email is required\"\n\t}\n\treturn e.Prompt\n}\n"
  },
  {
    "path": "sdk/auth/filestore.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.\ntype FileTokenStore struct {\n\tmu      sync.Mutex\n\tdirLock sync.RWMutex\n\tbaseDir string\n}\n\n// NewFileTokenStore creates a token store that saves credentials to disk through the\n// TokenStorage implementation embedded in the token record.\nfunc NewFileTokenStore() *FileTokenStore {\n\treturn &FileTokenStore{}\n}\n\n// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.\nfunc (s *FileTokenStore) SetBaseDir(dir string) {\n\ts.dirLock.Lock()\n\ts.baseDir = strings.TrimSpace(dir)\n\ts.dirLock.Unlock()\n}\n\n// Save persists token storage and metadata to the resolved auth file path.\nfunc (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: auth is nil\")\n\t}\n\n\tpath, err := s.resolveAuthPath(auth)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif path == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: missing file path attribute for %s\", auth.ID)\n\t}\n\n\tif auth.Disabled {\n\t\tif _, statErr := os.Stat(path); os.IsNotExist(statErr) {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: create dir failed: %w\", err)\n\t}\n\n\t// metadataSetter is a private interface for TokenStorage implementations that support metadata injection.\n\ttype metadataSetter interface {\n\t\tSetMetadata(map[string]any)\n\t}\n\n\tswitch {\n\tcase auth.Storage != nil:\n\t\tif setter, ok := auth.Storage.(metadataSetter); ok {\n\t\t\tsetter.SetMetadata(auth.Metadata)\n\t\t}\n\t\tif err = auth.Storage.SaveTokenToFile(path); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\tcase auth.Metadata != nil:\n\t\tauth.Metadata[\"disabled\"] = auth.Disabled\n\t\traw, errMarshal := json.Marshal(auth.Metadata)\n\t\tif errMarshal != nil {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: marshal metadata failed: %w\", errMarshal)\n\t\t}\n\t\tif existing, errRead := os.ReadFile(path); errRead == nil {\n\t\t\tif jsonEqual(existing, raw) {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t\tfile, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)\n\t\t\tif errOpen != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"auth filestore: open existing failed: %w\", errOpen)\n\t\t\t}\n\t\t\tif _, errWrite := file.Write(raw); errWrite != nil {\n\t\t\t\t_ = file.Close()\n\t\t\t\treturn \"\", fmt.Errorf(\"auth filestore: write existing failed: %w\", errWrite)\n\t\t\t}\n\t\t\tif errClose := file.Close(); errClose != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"auth filestore: close existing failed: %w\", errClose)\n\t\t\t}\n\t\t\treturn path, nil\n\t\t} else if !os.IsNotExist(errRead) {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: read existing failed: %w\", errRead)\n\t\t}\n\t\tif errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {\n\t\t\treturn \"\", fmt.Errorf(\"auth filestore: write file failed: %w\", errWrite)\n\t\t}\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"auth filestore: nothing to persist for %s\", auth.ID)\n\t}\n\n\tif auth.Attributes == nil {\n\t\tauth.Attributes = make(map[string]string)\n\t}\n\tauth.Attributes[\"path\"] = path\n\n\tif strings.TrimSpace(auth.FileName) == \"\" {\n\t\tauth.FileName = auth.ID\n\t}\n\n\treturn path, nil\n}\n\n// List enumerates all auth JSON files under the configured directory.\nfunc (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn nil, fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\tentries := make([]*cliproxyauth.Auth, 0)\n\terr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {\n\t\tif walkErr != nil {\n\t\t\treturn walkErr\n\t\t}\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(d.Name()), \".json\") {\n\t\t\treturn nil\n\t\t}\n\t\tauth, err := s.readAuthFile(path, dir)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tif auth != nil {\n\t\t\tentries = append(entries, auth)\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn entries, nil\n}\n\n// Delete removes the auth file.\nfunc (s *FileTokenStore) Delete(ctx context.Context, id string) error {\n\tid = strings.TrimSpace(id)\n\tif id == \"\" {\n\t\treturn fmt.Errorf(\"auth filestore: id is empty\")\n\t}\n\tpath, err := s.resolveDeletePath(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err = os.Remove(path); err != nil && !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"auth filestore: delete failed: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *FileTokenStore) resolveDeletePath(id string) (string, error) {\n\tif strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {\n\t\treturn id, nil\n\t}\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\treturn filepath.Join(dir, id), nil\n}\n\nfunc (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read file: %w\", err)\n\t}\n\tif len(data) == 0 {\n\t\treturn nil, nil\n\t}\n\tmetadata := make(map[string]any)\n\tif err = json.Unmarshal(data, &metadata); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal auth json: %w\", err)\n\t}\n\tprovider, _ := metadata[\"type\"].(string)\n\tif provider == \"\" {\n\t\tprovider = \"unknown\"\n\t}\n\tif provider == \"antigravity\" || provider == \"gemini\" {\n\t\tprojectID := \"\"\n\t\tif pid, ok := metadata[\"project_id\"].(string); ok {\n\t\t\tprojectID = strings.TrimSpace(pid)\n\t\t}\n\t\tif projectID == \"\" {\n\t\t\taccessToken := extractAccessToken(metadata)\n\t\t\t// For gemini type, the stored access_token is likely expired (~1h lifetime).\n\t\t\t// Refresh it using the long-lived refresh_token before querying.\n\t\t\tif provider == \"gemini\" {\n\t\t\t\tif tokenMap, ok := metadata[\"token\"].(map[string]any); ok {\n\t\t\t\t\tif refreshed, errRefresh := refreshGeminiAccessToken(tokenMap, http.DefaultClient); errRefresh == nil {\n\t\t\t\t\t\taccessToken = refreshed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif accessToken != \"\" {\n\t\t\t\tfetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient)\n\t\t\t\tif errFetch == nil && strings.TrimSpace(fetchedProjectID) != \"\" {\n\t\t\t\t\tmetadata[\"project_id\"] = strings.TrimSpace(fetchedProjectID)\n\t\t\t\t\tif raw, errMarshal := json.Marshal(metadata); errMarshal == nil {\n\t\t\t\t\t\tif file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil {\n\t\t\t\t\t\t\t_, _ = file.Write(raw)\n\t\t\t\t\t\t\t_ = file.Close()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stat file: %w\", err)\n\t}\n\tid := s.idFor(path, baseDir)\n\tdisabled, _ := metadata[\"disabled\"].(bool)\n\tstatus := cliproxyauth.StatusActive\n\tif disabled {\n\t\tstatus = cliproxyauth.StatusDisabled\n\t}\n\tauth := &cliproxyauth.Auth{\n\t\tID:               id,\n\t\tProvider:         provider,\n\t\tFileName:         id,\n\t\tLabel:            s.labelFor(metadata),\n\t\tStatus:           status,\n\t\tDisabled:         disabled,\n\t\tAttributes:       map[string]string{\"path\": path},\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        info.ModTime(),\n\t\tUpdatedAt:        info.ModTime(),\n\t\tLastRefreshedAt:  time.Time{},\n\t\tNextRefreshAfter: time.Time{},\n\t}\n\tif email, ok := metadata[\"email\"].(string); ok && email != \"\" {\n\t\tauth.Attributes[\"email\"] = email\n\t}\n\treturn auth, nil\n}\n\nfunc (s *FileTokenStore) idFor(path, baseDir string) string {\n\tid := path\n\tif baseDir != \"\" {\n\t\tif rel, errRel := filepath.Rel(baseDir, path); errRel == nil && rel != \"\" {\n\t\t\tid = rel\n\t\t}\n\t}\n\t// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.\n\tif runtime.GOOS == \"windows\" {\n\t\tid = strings.ToLower(id)\n\t}\n\treturn id\n}\n\nfunc (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {\n\tif auth == nil {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: auth is nil\")\n\t}\n\tif auth.Attributes != nil {\n\t\tif p := strings.TrimSpace(auth.Attributes[\"path\"]); p != \"\" {\n\t\t\treturn p, nil\n\t\t}\n\t}\n\tif fileName := strings.TrimSpace(auth.FileName); fileName != \"\" {\n\t\tif filepath.IsAbs(fileName) {\n\t\t\treturn fileName, nil\n\t\t}\n\t\tif dir := s.baseDirSnapshot(); dir != \"\" {\n\t\t\treturn filepath.Join(dir, fileName), nil\n\t\t}\n\t\treturn fileName, nil\n\t}\n\tif auth.ID == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: missing id\")\n\t}\n\tif filepath.IsAbs(auth.ID) {\n\t\treturn auth.ID, nil\n\t}\n\tdir := s.baseDirSnapshot()\n\tif dir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"auth filestore: directory not configured\")\n\t}\n\treturn filepath.Join(dir, auth.ID), nil\n}\n\nfunc (s *FileTokenStore) labelFor(metadata map[string]any) string {\n\tif metadata == nil {\n\t\treturn \"\"\n\t}\n\tif v, ok := metadata[\"label\"].(string); ok && v != \"\" {\n\t\treturn v\n\t}\n\tif v, ok := metadata[\"email\"].(string); ok && v != \"\" {\n\t\treturn v\n\t}\n\tif project, ok := metadata[\"project_id\"].(string); ok && project != \"\" {\n\t\treturn project\n\t}\n\treturn \"\"\n}\n\nfunc (s *FileTokenStore) baseDirSnapshot() string {\n\ts.dirLock.RLock()\n\tdefer s.dirLock.RUnlock()\n\treturn s.baseDir\n}\n\nfunc extractAccessToken(metadata map[string]any) string {\n\tif at, ok := metadata[\"access_token\"].(string); ok {\n\t\tif v := strings.TrimSpace(at); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t}\n\tif tokenMap, ok := metadata[\"token\"].(map[string]any); ok {\n\t\tif at, ok := tokenMap[\"access_token\"].(string); ok {\n\t\t\tif v := strings.TrimSpace(at); v != \"\" {\n\t\t\t\treturn v\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc refreshGeminiAccessToken(tokenMap map[string]any, httpClient *http.Client) (string, error) {\n\trefreshToken, _ := tokenMap[\"refresh_token\"].(string)\n\tclientID, _ := tokenMap[\"client_id\"].(string)\n\tclientSecret, _ := tokenMap[\"client_secret\"].(string)\n\ttokenURI, _ := tokenMap[\"token_uri\"].(string)\n\n\tif refreshToken == \"\" || clientID == \"\" || clientSecret == \"\" {\n\t\treturn \"\", fmt.Errorf(\"missing refresh credentials\")\n\t}\n\tif tokenURI == \"\" {\n\t\ttokenURI = \"https://oauth2.googleapis.com/token\"\n\t}\n\n\tdata := url.Values{\n\t\t\"grant_type\":    {\"refresh_token\"},\n\t\t\"refresh_token\": {refreshToken},\n\t\t\"client_id\":     {clientID},\n\t\t\"client_secret\": {clientSecret},\n\t}\n\n\tresp, err := httpClient.PostForm(tokenURI, data)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"refresh request: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tbody, _ := io.ReadAll(resp.Body)\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"refresh failed: status %d\", resp.StatusCode)\n\t}\n\n\tvar result map[string]any\n\tif errUnmarshal := json.Unmarshal(body, &result); errUnmarshal != nil {\n\t\treturn \"\", fmt.Errorf(\"decode refresh response: %w\", errUnmarshal)\n\t}\n\n\tnewAccessToken, _ := result[\"access_token\"].(string)\n\tif newAccessToken == \"\" {\n\t\treturn \"\", fmt.Errorf(\"no access_token in refresh response\")\n\t}\n\n\ttokenMap[\"access_token\"] = newAccessToken\n\treturn newAccessToken, nil\n}\n\n// jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing.\nfunc jsonEqual(a, b []byte) bool {\n\tvar objA any\n\tvar objB any\n\tif err := json.Unmarshal(a, &objA); err != nil {\n\t\treturn false\n\t}\n\tif err := json.Unmarshal(b, &objB); err != nil {\n\t\treturn false\n\t}\n\treturn deepEqualJSON(objA, objB)\n}\n\nfunc deepEqualJSON(a, b any) bool {\n\tswitch valA := a.(type) {\n\tcase map[string]any:\n\t\tvalB, ok := b.(map[string]any)\n\t\tif !ok || len(valA) != len(valB) {\n\t\t\treturn false\n\t\t}\n\t\tfor key, subA := range valA {\n\t\t\tsubB, ok1 := valB[key]\n\t\t\tif !ok1 || !deepEqualJSON(subA, subB) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase []any:\n\t\tsliceB, ok := b.([]any)\n\t\tif !ok || len(valA) != len(sliceB) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := range valA {\n\t\t\tif !deepEqualJSON(valA[i], sliceB[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase float64:\n\t\tvalB, ok := b.(float64)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase string:\n\t\tvalB, ok := b.(string)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase bool:\n\t\tvalB, ok := b.(bool)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\treturn valA == valB\n\tcase nil:\n\t\treturn b == nil\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "sdk/auth/filestore_test.go",
    "content": "package auth\n\nimport \"testing\"\n\nfunc TestExtractAccessToken(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\tname     string\n\t\tmetadata map[string]any\n\t\texpected string\n\t}{\n\t\t{\n\t\t\t\"antigravity top-level access_token\",\n\t\t\tmap[string]any{\"access_token\": \"tok-abc\"},\n\t\t\t\"tok-abc\",\n\t\t},\n\t\t{\n\t\t\t\"gemini nested token.access_token\",\n\t\t\tmap[string]any{\n\t\t\t\t\"token\": map[string]any{\"access_token\": \"tok-nested\"},\n\t\t\t},\n\t\t\t\"tok-nested\",\n\t\t},\n\t\t{\n\t\t\t\"top-level takes precedence over nested\",\n\t\t\tmap[string]any{\n\t\t\t\t\"access_token\": \"tok-top\",\n\t\t\t\t\"token\":        map[string]any{\"access_token\": \"tok-nested\"},\n\t\t\t},\n\t\t\t\"tok-top\",\n\t\t},\n\t\t{\n\t\t\t\"empty metadata\",\n\t\t\tmap[string]any{},\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"whitespace-only access_token\",\n\t\t\tmap[string]any{\"access_token\": \"   \"},\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"wrong type access_token\",\n\t\t\tmap[string]any{\"access_token\": 12345},\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"token is not a map\",\n\t\t\tmap[string]any{\"token\": \"not-a-map\"},\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"nested whitespace-only\",\n\t\t\tmap[string]any{\n\t\t\t\t\"token\": map[string]any{\"access_token\": \"  \"},\n\t\t\t},\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"fallback to nested when top-level empty\",\n\t\t\tmap[string]any{\n\t\t\t\t\"access_token\": \"\",\n\t\t\t\t\"token\":        map[string]any{\"access_token\": \"tok-fallback\"},\n\t\t\t},\n\t\t\t\"tok-fallback\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\ttt := tt\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tgot := extractAccessToken(tt.metadata)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"extractAccessToken() = %q, want %q\", got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "sdk/auth/gemini.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini\"\n\t// legacy client removed\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// GeminiAuthenticator implements the login flow for Google Gemini CLI accounts.\ntype GeminiAuthenticator struct{}\n\n// NewGeminiAuthenticator constructs a Gemini authenticator.\nfunc NewGeminiAuthenticator() *GeminiAuthenticator {\n\treturn &GeminiAuthenticator{}\n}\n\nfunc (a *GeminiAuthenticator) Provider() string {\n\treturn \"gemini\"\n}\n\nfunc (a *GeminiAuthenticator) RefreshLead() *time.Duration {\n\treturn nil\n}\n\nfunc (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tvar ts gemini.GeminiTokenStorage\n\tif opts.ProjectID != \"\" {\n\t\tts.ProjectID = opts.ProjectID\n\t}\n\n\tgeminiAuth := gemini.NewGeminiAuth()\n\t_, err := geminiAuth.GetAuthenticatedClient(ctx, &ts, cfg, &gemini.WebLoginOptions{\n\t\tNoBrowser:    opts.NoBrowser,\n\t\tCallbackPort: opts.CallbackPort,\n\t\tPrompt:       opts.Prompt,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"gemini authentication failed: %w\", err)\n\t}\n\n\t// Skip onboarding here; rely on upstream configuration\n\n\tfileName := fmt.Sprintf(\"%s-%s.json\", ts.Email, ts.ProjectID)\n\tmetadata := map[string]any{\n\t\t\"email\":      ts.Email,\n\t\t\"project_id\": ts.ProjectID,\n\t}\n\n\tfmt.Println(\"Gemini authentication successful\")\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tStorage:  &ts,\n\t\tMetadata: metadata,\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/iflow.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/misc\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// IFlowAuthenticator implements the OAuth login flow for iFlow accounts.\ntype IFlowAuthenticator struct{}\n\n// NewIFlowAuthenticator constructs a new authenticator instance.\nfunc NewIFlowAuthenticator() *IFlowAuthenticator { return &IFlowAuthenticator{} }\n\n// Provider returns the provider key for the authenticator.\nfunc (a *IFlowAuthenticator) Provider() string { return \"iflow\" }\n\n// RefreshLead indicates how soon before expiry a refresh should be attempted.\nfunc (a *IFlowAuthenticator) RefreshLead() *time.Duration {\n\treturn new(24 * time.Hour)\n}\n\n// Login performs the OAuth code flow using a local callback server.\nfunc (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tcallbackPort := iflow.CallbackPort\n\tif opts.CallbackPort > 0 {\n\t\tcallbackPort = opts.CallbackPort\n\t}\n\n\tauthSvc := iflow.NewIFlowAuth(cfg)\n\n\toauthServer := iflow.NewOAuthServer(callbackPort)\n\tif err := oauthServer.Start(); err != nil {\n\t\tif strings.Contains(err.Error(), \"already in use\") {\n\t\t\treturn nil, fmt.Errorf(\"iflow authentication server port in use: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"iflow authentication server failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tstopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tif stopErr := oauthServer.Stop(stopCtx); stopErr != nil {\n\t\t\tlog.Warnf(\"iflow oauth server stop error: %v\", stopErr)\n\t\t}\n\t}()\n\n\tstate, err := misc.GenerateRandomState()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow auth: failed to generate state: %w\", err)\n\t}\n\n\tauthURL, redirectURI := authSvc.AuthorizationURL(state, callbackPort)\n\n\tif !opts.NoBrowser {\n\t\tfmt.Println(\"Opening browser for iFlow authentication\")\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the URL manually\")\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t} else if err = browser.OpenURL(authURL); err != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", err)\n\t\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t}\n\t} else {\n\t\tutil.PrintSSHTunnelInstructions(callbackPort)\n\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for iFlow authentication callback...\")\n\n\tcallbackCh := make(chan *iflow.OAuthResult, 1)\n\tcallbackErrCh := make(chan error, 1)\n\n\tgo func() {\n\t\tresult, errWait := oauthServer.WaitForCallback(5 * time.Minute)\n\t\tif errWait != nil {\n\t\t\tcallbackErrCh <- errWait\n\t\t\treturn\n\t\t}\n\t\tcallbackCh <- result\n\t}()\n\n\tvar result *iflow.OAuthResult\n\tvar manualPromptTimer *time.Timer\n\tvar manualPromptC <-chan time.Time\n\tif opts.Prompt != nil {\n\t\tmanualPromptTimer = time.NewTimer(15 * time.Second)\n\t\tmanualPromptC = manualPromptTimer.C\n\t\tdefer manualPromptTimer.Stop()\n\t}\n\nwaitForCallback:\n\tfor {\n\t\tselect {\n\t\tcase result = <-callbackCh:\n\t\t\tbreak waitForCallback\n\t\tcase err = <-callbackErrCh:\n\t\t\treturn nil, fmt.Errorf(\"iflow auth: callback wait failed: %w\", err)\n\t\tcase <-manualPromptC:\n\t\t\tmanualPromptC = nil\n\t\t\tif manualPromptTimer != nil {\n\t\t\t\tmanualPromptTimer.Stop()\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase result = <-callbackCh:\n\t\t\t\tbreak waitForCallback\n\t\t\tcase err = <-callbackErrCh:\n\t\t\t\treturn nil, fmt.Errorf(\"iflow auth: callback wait failed: %w\", err)\n\t\t\tdefault:\n\t\t\t}\n\t\t\tinput, errPrompt := opts.Prompt(\"Paste the iFlow callback URL (or press Enter to keep waiting): \")\n\t\t\tif errPrompt != nil {\n\t\t\t\treturn nil, errPrompt\n\t\t\t}\n\t\t\tparsed, errParse := misc.ParseOAuthCallback(input)\n\t\t\tif errParse != nil {\n\t\t\t\treturn nil, errParse\n\t\t\t}\n\t\t\tif parsed == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresult = &iflow.OAuthResult{\n\t\t\t\tCode:  parsed.Code,\n\t\t\t\tState: parsed.State,\n\t\t\t\tError: parsed.Error,\n\t\t\t}\n\t\t\tbreak waitForCallback\n\t\t}\n\t}\n\tif result.Error != \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow auth: provider returned error %s\", result.Error)\n\t}\n\tif result.State != state {\n\t\treturn nil, fmt.Errorf(\"iflow auth: state mismatch\")\n\t}\n\n\ttokenData, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"iflow authentication failed: %w\", err)\n\t}\n\n\ttokenStorage := authSvc.CreateTokenStorage(tokenData)\n\n\temail := strings.TrimSpace(tokenStorage.Email)\n\tif email == \"\" {\n\t\treturn nil, fmt.Errorf(\"iflow authentication failed: missing account identifier\")\n\t}\n\n\tfileName := fmt.Sprintf(\"iflow-%s-%d.json\", email, time.Now().Unix())\n\tmetadata := map[string]any{\n\t\t\"email\":         email,\n\t\t\"api_key\":       tokenStorage.APIKey,\n\t\t\"access_token\":  tokenStorage.AccessToken,\n\t\t\"refresh_token\": tokenStorage.RefreshToken,\n\t\t\"expired\":       tokenStorage.Expire,\n\t}\n\n\tfmt.Println(\"iFlow authentication successful\")\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tStorage:  tokenStorage,\n\t\tMetadata: metadata,\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": tokenStorage.APIKey,\n\t\t},\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/interfaces.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nvar ErrRefreshNotSupported = errors.New(\"cliproxy auth: refresh not supported\")\n\n// LoginOptions captures generic knobs shared across authenticators.\n// Provider-specific logic can inspect Metadata for extra parameters.\ntype LoginOptions struct {\n\tNoBrowser    bool\n\tProjectID    string\n\tCallbackPort int\n\tMetadata     map[string]string\n\tPrompt       func(prompt string) (string, error)\n}\n\n// Authenticator manages login and optional refresh flows for a provider.\ntype Authenticator interface {\n\tProvider() string\n\tLogin(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error)\n\tRefreshLead() *time.Duration\n}\n"
  },
  {
    "path": "sdk/auth/kimi.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// kimiRefreshLead is the duration before token expiry when refresh should occur.\nvar kimiRefreshLead = 5 * time.Minute\n\n// KimiAuthenticator implements the OAuth device flow login for Kimi (Moonshot AI).\ntype KimiAuthenticator struct{}\n\n// NewKimiAuthenticator constructs a new Kimi authenticator.\nfunc NewKimiAuthenticator() Authenticator {\n\treturn &KimiAuthenticator{}\n}\n\n// Provider returns the provider key for kimi.\nfunc (KimiAuthenticator) Provider() string {\n\treturn \"kimi\"\n}\n\n// RefreshLead returns the duration before token expiry when refresh should occur.\n// Kimi tokens expire and need to be refreshed before expiry.\nfunc (KimiAuthenticator) RefreshLead() *time.Duration {\n\treturn &kimiRefreshLead\n}\n\n// Login initiates the Kimi device flow authentication.\nfunc (a KimiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tauthSvc := kimi.NewKimiAuth(cfg)\n\n\t// Start the device flow\n\tfmt.Println(\"Starting Kimi authentication...\")\n\tdeviceCode, err := authSvc.StartDeviceFlow(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: failed to start device flow: %w\", err)\n\t}\n\n\t// Display the verification URL\n\tverificationURL := deviceCode.VerificationURIComplete\n\tif verificationURL == \"\" {\n\t\tverificationURL = deviceCode.VerificationURI\n\t}\n\n\tfmt.Printf(\"\\nTo authenticate, please visit:\\n%s\\n\\n\", verificationURL)\n\tif deviceCode.UserCode != \"\" {\n\t\tfmt.Printf(\"User code: %s\\n\\n\", deviceCode.UserCode)\n\t}\n\n\t// Try to open the browser automatically\n\tif !opts.NoBrowser {\n\t\tif browser.IsAvailable() {\n\t\t\tif errOpen := browser.OpenURL(verificationURL); errOpen != nil {\n\t\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", errOpen)\n\t\t\t} else {\n\t\t\t\tfmt.Println(\"Browser opened automatically.\")\n\t\t\t}\n\t\t}\n\t}\n\n\tfmt.Println(\"Waiting for authorization...\")\n\tif deviceCode.ExpiresIn > 0 {\n\t\tfmt.Printf(\"(This will timeout in %d seconds if not authorized)\\n\", deviceCode.ExpiresIn)\n\t}\n\n\t// Wait for user authorization\n\tauthBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"kimi: %w\", err)\n\t}\n\n\t// Create the token storage\n\ttokenStorage := authSvc.CreateTokenStorage(authBundle)\n\n\t// Build metadata with token information\n\tmetadata := map[string]any{\n\t\t\"type\":          \"kimi\",\n\t\t\"access_token\":  authBundle.TokenData.AccessToken,\n\t\t\"refresh_token\": authBundle.TokenData.RefreshToken,\n\t\t\"token_type\":    authBundle.TokenData.TokenType,\n\t\t\"scope\":         authBundle.TokenData.Scope,\n\t\t\"timestamp\":     time.Now().UnixMilli(),\n\t}\n\n\tif authBundle.TokenData.ExpiresAt > 0 {\n\t\texp := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)\n\t\tmetadata[\"expired\"] = exp\n\t}\n\tif strings.TrimSpace(authBundle.DeviceID) != \"\" {\n\t\tmetadata[\"device_id\"] = strings.TrimSpace(authBundle.DeviceID)\n\t}\n\n\t// Generate a unique filename\n\tfileName := fmt.Sprintf(\"kimi-%d.json\", time.Now().UnixMilli())\n\n\tfmt.Println(\"\\nKimi authentication successful!\")\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tLabel:    \"Kimi User\",\n\t\tStorage:  tokenStorage,\n\t\tMetadata: metadata,\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/manager.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\n// Manager aggregates authenticators and coordinates persistence via a token store.\ntype Manager struct {\n\tauthenticators map[string]Authenticator\n\tstore          coreauth.Store\n}\n\n// NewManager constructs a manager with the provided token store and authenticators.\n// If store is nil, the caller must set it later using SetStore.\nfunc NewManager(store coreauth.Store, authenticators ...Authenticator) *Manager {\n\tmgr := &Manager{\n\t\tauthenticators: make(map[string]Authenticator),\n\t\tstore:          store,\n\t}\n\tfor i := range authenticators {\n\t\tmgr.Register(authenticators[i])\n\t}\n\treturn mgr\n}\n\n// Register adds or replaces an authenticator keyed by its provider identifier.\nfunc (m *Manager) Register(a Authenticator) {\n\tif a == nil {\n\t\treturn\n\t}\n\tif m.authenticators == nil {\n\t\tm.authenticators = make(map[string]Authenticator)\n\t}\n\tm.authenticators[a.Provider()] = a\n}\n\n// SetStore updates the token store used for persistence.\nfunc (m *Manager) SetStore(store coreauth.Store) {\n\tm.store = store\n}\n\n// Login executes the provider login flow and persists the resulting auth record.\nfunc (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, string, error) {\n\tauth, ok := m.authenticators[provider]\n\tif !ok {\n\t\treturn nil, \"\", fmt.Errorf(\"cliproxy auth: authenticator %s not registered\", provider)\n\t}\n\n\trecord, err := auth.Login(ctx, cfg, opts)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tif record == nil {\n\t\treturn nil, \"\", fmt.Errorf(\"cliproxy auth: authenticator %s returned nil record\", provider)\n\t}\n\n\tif m.store == nil {\n\t\treturn record, \"\", nil\n\t}\n\n\tif cfg != nil {\n\t\tif dirSetter, ok := m.store.(interface{ SetBaseDir(string) }); ok {\n\t\t\tdirSetter.SetBaseDir(cfg.AuthDir)\n\t\t}\n\t}\n\n\tsavedPath, err := m.store.Save(ctx, record)\n\tif err != nil {\n\t\treturn record, \"\", err\n\t}\n\treturn record, savedPath, nil\n}\n"
  },
  {
    "path": "sdk/auth/qwen.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/browser\"\n\t// legacy client removed\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// QwenAuthenticator implements the device flow login for Qwen accounts.\ntype QwenAuthenticator struct{}\n\n// NewQwenAuthenticator constructs a Qwen authenticator.\nfunc NewQwenAuthenticator() *QwenAuthenticator {\n\treturn &QwenAuthenticator{}\n}\n\nfunc (a *QwenAuthenticator) Provider() string {\n\treturn \"qwen\"\n}\n\nfunc (a *QwenAuthenticator) RefreshLead() *time.Duration {\n\treturn new(3 * time.Hour)\n}\n\nfunc (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy auth: configuration is required\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif opts == nil {\n\t\topts = &LoginOptions{}\n\t}\n\n\tauthSvc := qwen.NewQwenAuth(cfg)\n\n\tdeviceFlow, err := authSvc.InitiateDeviceFlow(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"qwen device flow initiation failed: %w\", err)\n\t}\n\n\tauthURL := deviceFlow.VerificationURIComplete\n\n\tif !opts.NoBrowser {\n\t\tfmt.Println(\"Opening browser for Qwen authentication\")\n\t\tif !browser.IsAvailable() {\n\t\t\tlog.Warn(\"No browser available; please open the URL manually\")\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t} else if err = browser.OpenURL(authURL); err != nil {\n\t\t\tlog.Warnf(\"Failed to open browser automatically: %v\", err)\n\t\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t\t}\n\t} else {\n\t\tfmt.Printf(\"Visit the following URL to continue authentication:\\n%s\\n\", authURL)\n\t}\n\n\tfmt.Println(\"Waiting for Qwen authentication...\")\n\n\ttokenData, err := authSvc.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"qwen authentication failed: %w\", err)\n\t}\n\n\ttokenStorage := authSvc.CreateTokenStorage(tokenData)\n\n\temail := \"\"\n\tif opts.Metadata != nil {\n\t\temail = opts.Metadata[\"email\"]\n\t\tif email == \"\" {\n\t\t\temail = opts.Metadata[\"alias\"]\n\t\t}\n\t}\n\n\tif email == \"\" && opts.Prompt != nil {\n\t\temail, err = opts.Prompt(\"Please input your email address or alias for Qwen:\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\temail = strings.TrimSpace(email)\n\tif email == \"\" {\n\t\treturn nil, &EmailRequiredError{Prompt: \"Please provide an email address or alias for Qwen.\"}\n\t}\n\n\ttokenStorage.Email = email\n\n\t// no legacy client construction\n\n\tfileName := fmt.Sprintf(\"qwen-%s.json\", tokenStorage.Email)\n\tmetadata := map[string]any{\n\t\t\"email\": tokenStorage.Email,\n\t}\n\n\tfmt.Println(\"Qwen authentication successful\")\n\n\treturn &coreauth.Auth{\n\t\tID:       fileName,\n\t\tProvider: a.Provider(),\n\t\tFileName: fileName,\n\t\tStorage:  tokenStorage,\n\t\tMetadata: metadata,\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/auth/refresh_registry.go",
    "content": "package auth\n\nimport (\n\t\"time\"\n\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc init() {\n\tregisterRefreshLead(\"codex\", func() Authenticator { return NewCodexAuthenticator() })\n\tregisterRefreshLead(\"claude\", func() Authenticator { return NewClaudeAuthenticator() })\n\tregisterRefreshLead(\"qwen\", func() Authenticator { return NewQwenAuthenticator() })\n\tregisterRefreshLead(\"iflow\", func() Authenticator { return NewIFlowAuthenticator() })\n\tregisterRefreshLead(\"gemini\", func() Authenticator { return NewGeminiAuthenticator() })\n\tregisterRefreshLead(\"gemini-cli\", func() Authenticator { return NewGeminiAuthenticator() })\n\tregisterRefreshLead(\"antigravity\", func() Authenticator { return NewAntigravityAuthenticator() })\n\tregisterRefreshLead(\"kimi\", func() Authenticator { return NewKimiAuthenticator() })\n}\n\nfunc registerRefreshLead(provider string, factory func() Authenticator) {\n\tcliproxyauth.RegisterRefreshLeadProvider(provider, func() *time.Duration {\n\t\tif factory == nil {\n\t\t\treturn nil\n\t\t}\n\t\tauth := factory()\n\t\tif auth == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn auth.RefreshLead()\n\t})\n}\n"
  },
  {
    "path": "sdk/auth/store_registry.go",
    "content": "package auth\n\nimport (\n\t\"sync\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nvar (\n\tstoreMu         sync.RWMutex\n\tregisteredStore coreauth.Store\n)\n\n// RegisterTokenStore sets the global token store used by the authentication helpers.\nfunc RegisterTokenStore(store coreauth.Store) {\n\tstoreMu.Lock()\n\tregisteredStore = store\n\tstoreMu.Unlock()\n}\n\n// GetTokenStore returns the globally registered token store.\nfunc GetTokenStore() coreauth.Store {\n\tstoreMu.RLock()\n\ts := registeredStore\n\tstoreMu.RUnlock()\n\tif s != nil {\n\t\treturn s\n\t}\n\tstoreMu.Lock()\n\tdefer storeMu.Unlock()\n\tif registeredStore == nil {\n\t\tregisteredStore = NewFileTokenStore()\n\t}\n\treturn registeredStore\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/api_key_model_alias_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\tinternalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc TestLookupAPIKeyUpstreamModel(t *testing.T) {\n\tcfg := &internalconfig.Config{\n\t\tGeminiKey: []internalconfig.GeminiKey{\n\t\t\t{\n\t\t\t\tAPIKey:  \"k\",\n\t\t\t\tBaseURL: \"https://example.com\",\n\t\t\t\tModels: []internalconfig.GeminiModel{\n\t\t\t\t\t{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"g25p\"},\n\t\t\t\t\t{Name: \"gemini-2.5-flash(low)\", Alias: \"g25f\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tmgr := NewManager(nil, nil, nil)\n\tmgr.SetConfig(cfg)\n\n\tctx := context.Background()\n\t_, _ = mgr.Register(ctx, &Auth{ID: \"a1\", Provider: \"gemini\", Attributes: map[string]string{\"api_key\": \"k\", \"base_url\": \"https://example.com\"}})\n\n\ttests := []struct {\n\t\tname   string\n\t\tauthID string\n\t\tinput  string\n\t\twant   string\n\t}{\n\t\t// Fast path + suffix preservation\n\t\t{\"alias with suffix\", \"a1\", \"g25p(8192)\", \"gemini-2.5-pro-exp-03-25(8192)\"},\n\t\t{\"alias without suffix\", \"a1\", \"g25p\", \"gemini-2.5-pro-exp-03-25\"},\n\n\t\t// Config suffix takes priority\n\t\t{\"config suffix priority\", \"a1\", \"g25f(high)\", \"gemini-2.5-flash(low)\"},\n\t\t{\"config suffix no user suffix\", \"a1\", \"g25f\", \"gemini-2.5-flash(low)\"},\n\n\t\t// Case insensitive\n\t\t{\"uppercase alias\", \"a1\", \"G25P\", \"gemini-2.5-pro-exp-03-25\"},\n\t\t{\"mixed case with suffix\", \"a1\", \"G25p(4096)\", \"gemini-2.5-pro-exp-03-25(4096)\"},\n\n\t\t// Direct name lookup\n\t\t{\"upstream name direct\", \"a1\", \"gemini-2.5-pro-exp-03-25\", \"gemini-2.5-pro-exp-03-25\"},\n\t\t{\"upstream name with suffix\", \"a1\", \"gemini-2.5-pro-exp-03-25(8192)\", \"gemini-2.5-pro-exp-03-25(8192)\"},\n\n\t\t// Cache miss scenarios\n\t\t{\"non-existent auth\", \"non-existent\", \"g25p\", \"\"},\n\t\t{\"unknown alias\", \"a1\", \"unknown-alias\", \"\"},\n\t\t{\"empty auth ID\", \"\", \"g25p\", \"\"},\n\t\t{\"empty model\", \"a1\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresolved := mgr.lookupAPIKeyUpstreamModel(tt.authID, tt.input)\n\t\t\tif resolved != tt.want {\n\t\t\t\tt.Errorf(\"lookupAPIKeyUpstreamModel(%q, %q) = %q, want %q\", tt.authID, tt.input, resolved, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAPIKeyModelAlias_ConfigHotReload(t *testing.T) {\n\tcfg := &internalconfig.Config{\n\t\tGeminiKey: []internalconfig.GeminiKey{\n\t\t\t{\n\t\t\t\tAPIKey: \"k\",\n\t\t\t\tModels: []internalconfig.GeminiModel{{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"g25p\"}},\n\t\t\t},\n\t\t},\n\t}\n\n\tmgr := NewManager(nil, nil, nil)\n\tmgr.SetConfig(cfg)\n\n\tctx := context.Background()\n\t_, _ = mgr.Register(ctx, &Auth{ID: \"a1\", Provider: \"gemini\", Attributes: map[string]string{\"api_key\": \"k\"}})\n\n\t// Initial alias\n\tif resolved := mgr.lookupAPIKeyUpstreamModel(\"a1\", \"g25p\"); resolved != \"gemini-2.5-pro-exp-03-25\" {\n\t\tt.Fatalf(\"before reload: got %q, want %q\", resolved, \"gemini-2.5-pro-exp-03-25\")\n\t}\n\n\t// Hot reload with new alias\n\tmgr.SetConfig(&internalconfig.Config{\n\t\tGeminiKey: []internalconfig.GeminiKey{\n\t\t\t{\n\t\t\t\tAPIKey: \"k\",\n\t\t\t\tModels: []internalconfig.GeminiModel{{Name: \"gemini-2.5-flash\", Alias: \"g25p\"}},\n\t\t\t},\n\t\t},\n\t})\n\n\t// New alias should take effect\n\tif resolved := mgr.lookupAPIKeyUpstreamModel(\"a1\", \"g25p\"); resolved != \"gemini-2.5-flash\" {\n\t\tt.Fatalf(\"after reload: got %q, want %q\", resolved, \"gemini-2.5-flash\")\n\t}\n}\n\nfunc TestAPIKeyModelAlias_MultipleProviders(t *testing.T) {\n\tcfg := &internalconfig.Config{\n\t\tGeminiKey: []internalconfig.GeminiKey{{APIKey: \"gemini-key\", Models: []internalconfig.GeminiModel{{Name: \"gemini-2.5-pro\", Alias: \"gp\"}}}},\n\t\tClaudeKey: []internalconfig.ClaudeKey{{APIKey: \"claude-key\", Models: []internalconfig.ClaudeModel{{Name: \"claude-sonnet-4\", Alias: \"cs4\"}}}},\n\t\tCodexKey:  []internalconfig.CodexKey{{APIKey: \"codex-key\", Models: []internalconfig.CodexModel{{Name: \"o3\", Alias: \"o\"}}}},\n\t}\n\n\tmgr := NewManager(nil, nil, nil)\n\tmgr.SetConfig(cfg)\n\n\tctx := context.Background()\n\t_, _ = mgr.Register(ctx, &Auth{ID: \"gemini-auth\", Provider: \"gemini\", Attributes: map[string]string{\"api_key\": \"gemini-key\"}})\n\t_, _ = mgr.Register(ctx, &Auth{ID: \"claude-auth\", Provider: \"claude\", Attributes: map[string]string{\"api_key\": \"claude-key\"}})\n\t_, _ = mgr.Register(ctx, &Auth{ID: \"codex-auth\", Provider: \"codex\", Attributes: map[string]string{\"api_key\": \"codex-key\"}})\n\n\ttests := []struct {\n\t\tauthID, input, want string\n\t}{\n\t\t{\"gemini-auth\", \"gp\", \"gemini-2.5-pro\"},\n\t\t{\"claude-auth\", \"cs4\", \"claude-sonnet-4\"},\n\t\t{\"codex-auth\", \"o\", \"o3\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tif resolved := mgr.lookupAPIKeyUpstreamModel(tt.authID, tt.input); resolved != tt.want {\n\t\t\tt.Errorf(\"lookupAPIKeyUpstreamModel(%q, %q) = %q, want %q\", tt.authID, tt.input, resolved, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestApplyAPIKeyModelAlias(t *testing.T) {\n\tcfg := &internalconfig.Config{\n\t\tGeminiKey: []internalconfig.GeminiKey{\n\t\t\t{APIKey: \"k\", Models: []internalconfig.GeminiModel{{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"g25p\"}}},\n\t\t},\n\t}\n\n\tmgr := NewManager(nil, nil, nil)\n\tmgr.SetConfig(cfg)\n\n\tctx := context.Background()\n\tapiKeyAuth := &Auth{ID: \"a1\", Provider: \"gemini\", Attributes: map[string]string{\"api_key\": \"k\"}}\n\toauthAuth := &Auth{ID: \"oauth-auth\", Provider: \"gemini\", Attributes: map[string]string{\"auth_kind\": \"oauth\"}}\n\t_, _ = mgr.Register(ctx, apiKeyAuth)\n\n\ttests := []struct {\n\t\tname       string\n\t\tauth       *Auth\n\t\tinputModel string\n\t\twantModel  string\n\t}{\n\t\t{\n\t\t\tname:       \"api_key auth with alias\",\n\t\t\tauth:       apiKeyAuth,\n\t\t\tinputModel: \"g25p(8192)\",\n\t\t\twantModel:  \"gemini-2.5-pro-exp-03-25(8192)\",\n\t\t},\n\t\t{\n\t\t\tname:       \"oauth auth passthrough\",\n\t\t\tauth:       oauthAuth,\n\t\t\tinputModel: \"some-model\",\n\t\t\twantModel:  \"some-model\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresolvedModel := mgr.applyAPIKeyModelAlias(tt.auth, tt.inputModel)\n\n\t\t\tif resolvedModel != tt.wantModel {\n\t\t\t\tt.Errorf(\"model = %q, want %q\", resolvedModel, tt.wantModel)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor.go",
    "content": "package auth\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\tinternalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/util\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ProviderExecutor defines the contract required by Manager to execute provider calls.\ntype ProviderExecutor interface {\n\t// Identifier returns the provider key handled by this executor.\n\tIdentifier() string\n\t// Execute handles non-streaming execution and returns the provider response payload.\n\tExecute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error)\n\t// ExecuteStream handles streaming execution and returns a StreamResult containing\n\t// upstream headers and a channel of provider chunks.\n\tExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error)\n\t// Refresh attempts to refresh provider credentials and returns the updated auth state.\n\tRefresh(ctx context.Context, auth *Auth) (*Auth, error)\n\t// CountTokens returns the token count for the given request.\n\tCountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error)\n\t// HttpRequest injects provider credentials into the supplied HTTP request and executes it.\n\t// Callers must close the response body when non-nil.\n\tHttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error)\n}\n\n// ExecutionSessionCloser allows executors to release per-session runtime resources.\ntype ExecutionSessionCloser interface {\n\tCloseExecutionSession(sessionID string)\n}\n\nconst (\n\t// CloseAllExecutionSessionsID asks an executor to release all active execution sessions.\n\t// Executors that do not support this marker may ignore it.\n\tCloseAllExecutionSessionsID = \"__all_execution_sessions__\"\n)\n\n// RefreshEvaluator allows runtime state to override refresh decisions.\ntype RefreshEvaluator interface {\n\tShouldRefresh(now time.Time, auth *Auth) bool\n}\n\nconst (\n\trefreshCheckInterval  = 5 * time.Second\n\trefreshMaxConcurrency = 16\n\trefreshPendingBackoff = time.Minute\n\trefreshFailureBackoff = 5 * time.Minute\n\tquotaBackoffBase      = time.Second\n\tquotaBackoffMax       = 30 * time.Minute\n)\n\nvar quotaCooldownDisabled atomic.Bool\n\n// SetQuotaCooldownDisabled toggles quota cooldown scheduling globally.\nfunc SetQuotaCooldownDisabled(disable bool) {\n\tquotaCooldownDisabled.Store(disable)\n}\n\nfunc quotaCooldownDisabledForAuth(auth *Auth) bool {\n\tif auth != nil {\n\t\tif override, ok := auth.DisableCoolingOverride(); ok {\n\t\t\treturn override\n\t\t}\n\t}\n\treturn quotaCooldownDisabled.Load()\n}\n\n// Result captures execution outcome used to adjust auth state.\ntype Result struct {\n\t// AuthID references the auth that produced this result.\n\tAuthID string\n\t// Provider is copied for convenience when emitting hooks.\n\tProvider string\n\t// Model is the upstream model identifier used for the request.\n\tModel string\n\t// Success marks whether the execution succeeded.\n\tSuccess bool\n\t// RetryAfter carries a provider supplied retry hint (e.g. 429 retryDelay).\n\tRetryAfter *time.Duration\n\t// Error describes the failure when Success is false.\n\tError *Error\n}\n\n// Selector chooses an auth candidate for execution.\ntype Selector interface {\n\tPick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error)\n}\n\n// Hook captures lifecycle callbacks for observing auth changes.\ntype Hook interface {\n\t// OnAuthRegistered fires when a new auth is registered.\n\tOnAuthRegistered(ctx context.Context, auth *Auth)\n\t// OnAuthUpdated fires when an existing auth changes state.\n\tOnAuthUpdated(ctx context.Context, auth *Auth)\n\t// OnResult fires when execution result is recorded.\n\tOnResult(ctx context.Context, result Result)\n}\n\n// NoopHook provides optional hook defaults.\ntype NoopHook struct{}\n\n// OnAuthRegistered implements Hook.\nfunc (NoopHook) OnAuthRegistered(context.Context, *Auth) {}\n\n// OnAuthUpdated implements Hook.\nfunc (NoopHook) OnAuthUpdated(context.Context, *Auth) {}\n\n// OnResult implements Hook.\nfunc (NoopHook) OnResult(context.Context, Result) {}\n\n// Manager orchestrates auth lifecycle, selection, execution, and persistence.\ntype Manager struct {\n\tstore     Store\n\texecutors map[string]ProviderExecutor\n\tselector  Selector\n\thook      Hook\n\tmu        sync.RWMutex\n\tauths     map[string]*Auth\n\tscheduler *authScheduler\n\t// providerOffsets tracks per-model provider rotation state for multi-provider routing.\n\tproviderOffsets map[string]int\n\n\t// Retry controls request retry behavior.\n\trequestRetry        atomic.Int32\n\tmaxRetryCredentials atomic.Int32\n\tmaxRetryInterval    atomic.Int64\n\n\t// oauthModelAlias stores global OAuth model alias mappings (alias -> upstream name) keyed by channel.\n\toauthModelAlias atomic.Value\n\n\t// apiKeyModelAlias caches resolved model alias mappings for API-key auths.\n\t// Keyed by auth.ID, value is alias(lower) -> upstream model (including suffix).\n\tapiKeyModelAlias atomic.Value\n\n\t// modelPoolOffsets tracks per-auth alias pool rotation state.\n\tmodelPoolOffsets map[string]int\n\n\t// runtimeConfig stores the latest application config for request-time decisions.\n\t// It is initialized in NewManager; never Load() before first Store().\n\truntimeConfig atomic.Value\n\n\t// Optional HTTP RoundTripper provider injected by host.\n\trtProvider RoundTripperProvider\n\n\t// Auto refresh state\n\trefreshCancel    context.CancelFunc\n\trefreshSemaphore chan struct{}\n}\n\n// NewManager constructs a manager with optional custom selector and hook.\nfunc NewManager(store Store, selector Selector, hook Hook) *Manager {\n\tif selector == nil {\n\t\tselector = &RoundRobinSelector{}\n\t}\n\tif hook == nil {\n\t\thook = NoopHook{}\n\t}\n\tmanager := &Manager{\n\t\tstore:            store,\n\t\texecutors:        make(map[string]ProviderExecutor),\n\t\tselector:         selector,\n\t\thook:             hook,\n\t\tauths:            make(map[string]*Auth),\n\t\tproviderOffsets:  make(map[string]int),\n\t\tmodelPoolOffsets: make(map[string]int),\n\t\trefreshSemaphore: make(chan struct{}, refreshMaxConcurrency),\n\t}\n\t// atomic.Value requires non-nil initial value.\n\tmanager.runtimeConfig.Store(&internalconfig.Config{})\n\tmanager.apiKeyModelAlias.Store(apiKeyModelAliasTable(nil))\n\tmanager.scheduler = newAuthScheduler(selector)\n\treturn manager\n}\n\nfunc isBuiltInSelector(selector Selector) bool {\n\tswitch selector.(type) {\n\tcase *RoundRobinSelector, *FillFirstSelector:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (m *Manager) syncSchedulerFromSnapshot(auths []*Auth) {\n\tif m == nil || m.scheduler == nil {\n\t\treturn\n\t}\n\tm.scheduler.rebuild(auths)\n}\n\nfunc (m *Manager) syncScheduler() {\n\tif m == nil || m.scheduler == nil {\n\t\treturn\n\t}\n\tm.syncSchedulerFromSnapshot(m.snapshotAuths())\n}\n\n// RefreshSchedulerEntry re-upserts a single auth into the scheduler so that its\n// supportedModelSet is rebuilt from the current global model registry state.\n// This must be called after models have been registered for a newly added auth,\n// because the initial scheduler.upsertAuth during Register/Update runs before\n// registerModelsForAuth and therefore snapshots an empty model set.\nfunc (m *Manager) RefreshSchedulerEntry(authID string) {\n\tif m == nil || m.scheduler == nil || authID == \"\" {\n\t\treturn\n\t}\n\tm.mu.RLock()\n\tauth, ok := m.auths[authID]\n\tif !ok || auth == nil {\n\t\tm.mu.RUnlock()\n\t\treturn\n\t}\n\tsnapshot := auth.Clone()\n\tm.mu.RUnlock()\n\tm.scheduler.upsertAuth(snapshot)\n}\n\nfunc (m *Manager) SetSelector(selector Selector) {\n\tif m == nil {\n\t\treturn\n\t}\n\tif selector == nil {\n\t\tselector = &RoundRobinSelector{}\n\t}\n\tm.mu.Lock()\n\tm.selector = selector\n\tm.mu.Unlock()\n\tif m.scheduler != nil {\n\t\tm.scheduler.setSelector(selector)\n\t\tm.syncScheduler()\n\t}\n}\n\n// SetStore swaps the underlying persistence store.\nfunc (m *Manager) SetStore(store Store) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.store = store\n}\n\n// SetRoundTripperProvider register a provider that returns a per-auth RoundTripper.\nfunc (m *Manager) SetRoundTripperProvider(p RoundTripperProvider) {\n\tm.mu.Lock()\n\tm.rtProvider = p\n\tm.mu.Unlock()\n}\n\n// SetConfig updates the runtime config snapshot used by request-time helpers.\n// Callers should provide the latest config on reload so per-credential alias mapping stays in sync.\nfunc (m *Manager) SetConfig(cfg *internalconfig.Config) {\n\tif m == nil {\n\t\treturn\n\t}\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\tm.runtimeConfig.Store(cfg)\n\tm.rebuildAPIKeyModelAliasFromRuntimeConfig()\n}\n\nfunc (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string {\n\tif m == nil {\n\t\treturn \"\"\n\t}\n\tauthID = strings.TrimSpace(authID)\n\tif authID == \"\" {\n\t\treturn \"\"\n\t}\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn \"\"\n\t}\n\ttable, _ := m.apiKeyModelAlias.Load().(apiKeyModelAliasTable)\n\tif table == nil {\n\t\treturn \"\"\n\t}\n\tbyAlias := table[authID]\n\tif len(byAlias) == 0 {\n\t\treturn \"\"\n\t}\n\tkey := strings.ToLower(thinking.ParseSuffix(requestedModel).ModelName)\n\tif key == \"\" {\n\t\tkey = strings.ToLower(requestedModel)\n\t}\n\tresolved := strings.TrimSpace(byAlias[key])\n\tif resolved == \"\" {\n\t\treturn \"\"\n\t}\n\treturn preserveRequestedModelSuffix(requestedModel, resolved)\n}\n\nfunc isAPIKeyAuth(auth *Auth) bool {\n\tif auth == nil {\n\t\treturn false\n\t}\n\tkind, _ := auth.AccountInfo()\n\treturn strings.EqualFold(strings.TrimSpace(kind), \"api_key\")\n}\n\nfunc isOpenAICompatAPIKeyAuth(auth *Auth) bool {\n\tif !isAPIKeyAuth(auth) {\n\t\treturn false\n\t}\n\tif strings.EqualFold(strings.TrimSpace(auth.Provider), \"openai-compatibility\") {\n\t\treturn true\n\t}\n\tif auth.Attributes == nil {\n\t\treturn false\n\t}\n\treturn strings.TrimSpace(auth.Attributes[\"compat_name\"]) != \"\"\n}\n\nfunc openAICompatProviderKey(auth *Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif auth.Attributes != nil {\n\t\tif providerKey := strings.TrimSpace(auth.Attributes[\"provider_key\"]); providerKey != \"\" {\n\t\t\treturn strings.ToLower(providerKey)\n\t\t}\n\t\tif compatName := strings.TrimSpace(auth.Attributes[\"compat_name\"]); compatName != \"\" {\n\t\t\treturn strings.ToLower(compatName)\n\t\t}\n\t}\n\treturn strings.ToLower(strings.TrimSpace(auth.Provider))\n}\n\nfunc openAICompatModelPoolKey(auth *Auth, requestedModel string) string {\n\tbase := strings.TrimSpace(thinking.ParseSuffix(requestedModel).ModelName)\n\tif base == \"\" {\n\t\tbase = strings.TrimSpace(requestedModel)\n\t}\n\treturn strings.ToLower(strings.TrimSpace(auth.ID)) + \"|\" + openAICompatProviderKey(auth) + \"|\" + strings.ToLower(base)\n}\n\nfunc (m *Manager) nextModelPoolOffset(key string, size int) int {\n\tif m == nil || size <= 1 {\n\t\treturn 0\n\t}\n\tkey = strings.TrimSpace(key)\n\tif key == \"\" {\n\t\treturn 0\n\t}\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tif m.modelPoolOffsets == nil {\n\t\tm.modelPoolOffsets = make(map[string]int)\n\t}\n\toffset := m.modelPoolOffsets[key]\n\tif offset >= 2_147_483_640 {\n\t\toffset = 0\n\t}\n\tm.modelPoolOffsets[key] = offset + 1\n\tif size <= 0 {\n\t\treturn 0\n\t}\n\treturn offset % size\n}\n\nfunc rotateStrings(values []string, offset int) []string {\n\tif len(values) <= 1 {\n\t\treturn values\n\t}\n\tif offset <= 0 {\n\t\tout := make([]string, len(values))\n\t\tcopy(out, values)\n\t\treturn out\n\t}\n\toffset = offset % len(values)\n\tout := make([]string, 0, len(values))\n\tout = append(out, values[offset:]...)\n\tout = append(out, values[:offset]...)\n\treturn out\n}\n\nfunc (m *Manager) resolveOpenAICompatUpstreamModelPool(auth *Auth, requestedModel string) []string {\n\tif m == nil || !isOpenAICompatAPIKeyAuth(auth) {\n\t\treturn nil\n\t}\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn nil\n\t}\n\tcfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\tproviderKey := \"\"\n\tcompatName := \"\"\n\tif auth.Attributes != nil {\n\t\tproviderKey = strings.TrimSpace(auth.Attributes[\"provider_key\"])\n\t\tcompatName = strings.TrimSpace(auth.Attributes[\"compat_name\"])\n\t}\n\tentry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider)\n\tif entry == nil {\n\t\treturn nil\n\t}\n\treturn resolveModelAliasPoolFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\nfunc preserveRequestedModelSuffix(requestedModel, resolved string) string {\n\treturn preserveResolvedModelSuffix(resolved, thinking.ParseSuffix(requestedModel))\n}\n\nfunc (m *Manager) executionModelCandidates(auth *Auth, routeModel string) []string {\n\treturn m.prepareExecutionModels(auth, routeModel)\n}\n\nfunc (m *Manager) prepareExecutionModels(auth *Auth, routeModel string) []string {\n\trequestedModel := rewriteModelForAuth(routeModel, auth)\n\trequestedModel = m.applyOAuthModelAlias(auth, requestedModel)\n\tif pool := m.resolveOpenAICompatUpstreamModelPool(auth, requestedModel); len(pool) > 0 {\n\t\tif len(pool) == 1 {\n\t\t\treturn pool\n\t\t}\n\t\toffset := m.nextModelPoolOffset(openAICompatModelPoolKey(auth, requestedModel), len(pool))\n\t\treturn rotateStrings(pool, offset)\n\t}\n\tresolved := m.applyAPIKeyModelAlias(auth, requestedModel)\n\tif strings.TrimSpace(resolved) == \"\" {\n\t\tresolved = requestedModel\n\t}\n\treturn []string{resolved}\n}\n\nfunc discardStreamChunks(ch <-chan cliproxyexecutor.StreamChunk) {\n\tif ch == nil {\n\t\treturn\n\t}\n\tgo func() {\n\t\tfor range ch {\n\t\t}\n\t}()\n}\n\nfunc readStreamBootstrap(ctx context.Context, ch <-chan cliproxyexecutor.StreamChunk) ([]cliproxyexecutor.StreamChunk, bool, error) {\n\tif ch == nil {\n\t\treturn nil, true, nil\n\t}\n\tbuffered := make([]cliproxyexecutor.StreamChunk, 0, 1)\n\tfor {\n\t\tvar (\n\t\t\tchunk cliproxyexecutor.StreamChunk\n\t\t\tok    bool\n\t\t)\n\t\tif ctx != nil {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, false, ctx.Err()\n\t\t\tcase chunk, ok = <-ch:\n\t\t\t}\n\t\t} else {\n\t\t\tchunk, ok = <-ch\n\t\t}\n\t\tif !ok {\n\t\t\treturn buffered, true, nil\n\t\t}\n\t\tif chunk.Err != nil {\n\t\t\treturn nil, false, chunk.Err\n\t\t}\n\t\tbuffered = append(buffered, chunk)\n\t\tif len(chunk.Payload) > 0 {\n\t\t\treturn buffered, false, nil\n\t\t}\n\t}\n}\n\nfunc (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, routeModel string, headers http.Header, buffered []cliproxyexecutor.StreamChunk, remaining <-chan cliproxyexecutor.StreamChunk) *cliproxyexecutor.StreamResult {\n\tout := make(chan cliproxyexecutor.StreamChunk)\n\tgo func() {\n\t\tdefer close(out)\n\t\tvar failed bool\n\t\tforward := true\n\t\temit := func(chunk cliproxyexecutor.StreamChunk) bool {\n\t\t\tif chunk.Err != nil && !failed {\n\t\t\t\tfailed = true\n\t\t\t\trerr := &Error{Message: chunk.Err.Error()}\n\t\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](chunk.Err); ok && se != nil {\n\t\t\t\t\trerr.HTTPStatus = se.StatusCode()\n\t\t\t\t}\n\t\t\t\tm.MarkResult(ctx, Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr})\n\t\t\t}\n\t\t\tif !forward {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif ctx == nil {\n\t\t\t\tout <- chunk\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tforward = false\n\t\t\t\treturn false\n\t\t\tcase out <- chunk:\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\tfor _, chunk := range buffered {\n\t\t\tif ok := emit(chunk); !ok {\n\t\t\t\tdiscardStreamChunks(remaining)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tfor chunk := range remaining {\n\t\t\tif ok := emit(chunk); !ok {\n\t\t\t\tdiscardStreamChunks(remaining)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif !failed {\n\t\t\tm.MarkResult(ctx, Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: true})\n\t\t}\n\t}()\n\treturn &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}\n}\n\nfunc (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel string) (*cliproxyexecutor.StreamResult, error) {\n\tif executor == nil {\n\t\treturn nil, &Error{Code: \"executor_not_found\", Message: \"executor not registered\"}\n\t}\n\texecModels := m.prepareExecutionModels(auth, routeModel)\n\tvar lastErr error\n\tfor idx, execModel := range execModels {\n\t\texecReq := req\n\t\texecReq.Model = execModel\n\t\tstreamResult, errStream := executor.ExecuteStream(ctx, auth, execReq, opts)\n\t\tif errStream != nil {\n\t\t\tif errCtx := ctx.Err(); errCtx != nil {\n\t\t\t\treturn nil, errCtx\n\t\t\t}\n\t\t\trerr := &Error{Message: errStream.Error()}\n\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](errStream); ok && se != nil {\n\t\t\t\trerr.HTTPStatus = se.StatusCode()\n\t\t\t}\n\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}\n\t\t\tresult.RetryAfter = retryAfterFromError(errStream)\n\t\t\tm.MarkResult(ctx, result)\n\t\t\tif isRequestInvalidError(errStream) {\n\t\t\t\treturn nil, errStream\n\t\t\t}\n\t\t\tlastErr = errStream\n\t\t\tcontinue\n\t\t}\n\n\t\tbuffered, closed, bootstrapErr := readStreamBootstrap(ctx, streamResult.Chunks)\n\t\tif bootstrapErr != nil {\n\t\t\tif errCtx := ctx.Err(); errCtx != nil {\n\t\t\t\tdiscardStreamChunks(streamResult.Chunks)\n\t\t\t\treturn nil, errCtx\n\t\t\t}\n\t\t\tif isRequestInvalidError(bootstrapErr) {\n\t\t\t\trerr := &Error{Message: bootstrapErr.Error()}\n\t\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](bootstrapErr); ok && se != nil {\n\t\t\t\t\trerr.HTTPStatus = se.StatusCode()\n\t\t\t\t}\n\t\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}\n\t\t\t\tresult.RetryAfter = retryAfterFromError(bootstrapErr)\n\t\t\t\tm.MarkResult(ctx, result)\n\t\t\t\tdiscardStreamChunks(streamResult.Chunks)\n\t\t\t\treturn nil, bootstrapErr\n\t\t\t}\n\t\t\tif idx < len(execModels)-1 {\n\t\t\t\trerr := &Error{Message: bootstrapErr.Error()}\n\t\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](bootstrapErr); ok && se != nil {\n\t\t\t\t\trerr.HTTPStatus = se.StatusCode()\n\t\t\t\t}\n\t\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}\n\t\t\t\tresult.RetryAfter = retryAfterFromError(bootstrapErr)\n\t\t\t\tm.MarkResult(ctx, result)\n\t\t\t\tdiscardStreamChunks(streamResult.Chunks)\n\t\t\t\tlastErr = bootstrapErr\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\terrCh := make(chan cliproxyexecutor.StreamChunk, 1)\n\t\t\terrCh <- cliproxyexecutor.StreamChunk{Err: bootstrapErr}\n\t\t\tclose(errCh)\n\t\t\treturn m.wrapStreamResult(ctx, auth.Clone(), provider, routeModel, streamResult.Headers, nil, errCh), nil\n\t\t}\n\n\t\tif closed && len(buffered) == 0 {\n\t\t\temptyErr := &Error{Code: \"empty_stream\", Message: \"upstream stream closed before first payload\", Retryable: true}\n\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: emptyErr}\n\t\t\tm.MarkResult(ctx, result)\n\t\t\tif idx < len(execModels)-1 {\n\t\t\t\tlastErr = emptyErr\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\terrCh := make(chan cliproxyexecutor.StreamChunk, 1)\n\t\t\terrCh <- cliproxyexecutor.StreamChunk{Err: emptyErr}\n\t\t\tclose(errCh)\n\t\t\treturn m.wrapStreamResult(ctx, auth.Clone(), provider, routeModel, streamResult.Headers, nil, errCh), nil\n\t\t}\n\n\t\tremaining := streamResult.Chunks\n\t\tif closed {\n\t\t\tclosedCh := make(chan cliproxyexecutor.StreamChunk)\n\t\t\tclose(closedCh)\n\t\t\tremaining = closedCh\n\t\t}\n\t\treturn m.wrapStreamResult(ctx, auth.Clone(), provider, routeModel, streamResult.Headers, buffered, remaining), nil\n\t}\n\tif lastErr == nil {\n\t\tlastErr = &Error{Code: \"auth_not_found\", Message: \"no upstream model available\"}\n\t}\n\treturn nil, lastErr\n}\n\nfunc (m *Manager) rebuildAPIKeyModelAliasFromRuntimeConfig() {\n\tif m == nil {\n\t\treturn\n\t}\n\tcfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.rebuildAPIKeyModelAliasLocked(cfg)\n}\n\nfunc (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) {\n\tif m == nil {\n\t\treturn\n\t}\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\n\tout := make(apiKeyModelAliasTable)\n\tfor _, auth := range m.auths {\n\t\tif auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.TrimSpace(auth.ID) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkind, _ := auth.AccountInfo()\n\t\tif !strings.EqualFold(strings.TrimSpace(kind), \"api_key\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tbyAlias := make(map[string]string)\n\t\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\t\tswitch provider {\n\t\tcase \"gemini\":\n\t\t\tif entry := resolveGeminiAPIKeyConfig(cfg, auth); entry != nil {\n\t\t\t\tcompileAPIKeyModelAliasForModels(byAlias, entry.Models)\n\t\t\t}\n\t\tcase \"claude\":\n\t\t\tif entry := resolveClaudeAPIKeyConfig(cfg, auth); entry != nil {\n\t\t\t\tcompileAPIKeyModelAliasForModels(byAlias, entry.Models)\n\t\t\t}\n\t\tcase \"codex\":\n\t\t\tif entry := resolveCodexAPIKeyConfig(cfg, auth); entry != nil {\n\t\t\t\tcompileAPIKeyModelAliasForModels(byAlias, entry.Models)\n\t\t\t}\n\t\tcase \"vertex\":\n\t\t\tif entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil {\n\t\t\t\tcompileAPIKeyModelAliasForModels(byAlias, entry.Models)\n\t\t\t}\n\t\tdefault:\n\t\t\t// OpenAI-compat uses config selection from auth.Attributes.\n\t\t\tproviderKey := \"\"\n\t\t\tcompatName := \"\"\n\t\t\tif auth.Attributes != nil {\n\t\t\t\tproviderKey = strings.TrimSpace(auth.Attributes[\"provider_key\"])\n\t\t\t\tcompatName = strings.TrimSpace(auth.Attributes[\"compat_name\"])\n\t\t\t}\n\t\t\tif compatName != \"\" || strings.EqualFold(strings.TrimSpace(auth.Provider), \"openai-compatibility\") {\n\t\t\t\tif entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider); entry != nil {\n\t\t\t\t\tcompileAPIKeyModelAliasForModels(byAlias, entry.Models)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(byAlias) > 0 {\n\t\t\tout[auth.ID] = byAlias\n\t\t}\n\t}\n\n\tm.apiKeyModelAlias.Store(out)\n}\n\nfunc compileAPIKeyModelAliasForModels[T interface {\n\tGetName() string\n\tGetAlias() string\n}](out map[string]string, models []T) {\n\tif out == nil {\n\t\treturn\n\t}\n\tfor i := range models {\n\t\talias := strings.TrimSpace(models[i].GetAlias())\n\t\tname := strings.TrimSpace(models[i].GetName())\n\t\tif alias == \"\" || name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\taliasKey := strings.ToLower(thinking.ParseSuffix(alias).ModelName)\n\t\tif aliasKey == \"\" {\n\t\t\taliasKey = strings.ToLower(alias)\n\t\t}\n\t\t// Config priority: first alias wins.\n\t\tif _, exists := out[aliasKey]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tout[aliasKey] = name\n\t\t// Also allow direct lookup by upstream name (case-insensitive), so lookups on already-upstream\n\t\t// models remain a cheap no-op.\n\t\tnameKey := strings.ToLower(thinking.ParseSuffix(name).ModelName)\n\t\tif nameKey == \"\" {\n\t\t\tnameKey = strings.ToLower(name)\n\t\t}\n\t\tif nameKey != \"\" {\n\t\t\tif _, exists := out[nameKey]; !exists {\n\t\t\t\tout[nameKey] = name\n\t\t\t}\n\t\t}\n\t\t// Preserve config suffix priority by seeding a base-name lookup when name already has suffix.\n\t\tnameResult := thinking.ParseSuffix(name)\n\t\tif nameResult.HasSuffix {\n\t\t\tbaseKey := strings.ToLower(strings.TrimSpace(nameResult.ModelName))\n\t\t\tif baseKey != \"\" {\n\t\t\t\tif _, exists := out[baseKey]; !exists {\n\t\t\t\t\tout[baseKey] = name\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// SetRetryConfig updates retry attempts, credential retry limit and cooldown wait interval.\nfunc (m *Manager) SetRetryConfig(retry int, maxRetryInterval time.Duration, maxRetryCredentials int) {\n\tif m == nil {\n\t\treturn\n\t}\n\tif retry < 0 {\n\t\tretry = 0\n\t}\n\tif maxRetryCredentials < 0 {\n\t\tmaxRetryCredentials = 0\n\t}\n\tif maxRetryInterval < 0 {\n\t\tmaxRetryInterval = 0\n\t}\n\tm.requestRetry.Store(int32(retry))\n\tm.maxRetryCredentials.Store(int32(maxRetryCredentials))\n\tm.maxRetryInterval.Store(maxRetryInterval.Nanoseconds())\n}\n\n// RegisterExecutor registers a provider executor with the manager.\nfunc (m *Manager) RegisterExecutor(executor ProviderExecutor) {\n\tif executor == nil {\n\t\treturn\n\t}\n\tprovider := strings.TrimSpace(executor.Identifier())\n\tif provider == \"\" {\n\t\treturn\n\t}\n\n\tvar replaced ProviderExecutor\n\tm.mu.Lock()\n\treplaced = m.executors[provider]\n\tm.executors[provider] = executor\n\tm.mu.Unlock()\n\n\tif replaced == nil || replaced == executor {\n\t\treturn\n\t}\n\tif closer, ok := replaced.(ExecutionSessionCloser); ok && closer != nil {\n\t\tcloser.CloseExecutionSession(CloseAllExecutionSessionsID)\n\t}\n}\n\n// UnregisterExecutor removes the executor associated with the provider key.\nfunc (m *Manager) UnregisterExecutor(provider string) {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif provider == \"\" {\n\t\treturn\n\t}\n\tm.mu.Lock()\n\tdelete(m.executors, provider)\n\tm.mu.Unlock()\n}\n\n// Register inserts a new auth entry into the manager.\nfunc (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) {\n\tif auth == nil {\n\t\treturn nil, nil\n\t}\n\tif auth.ID == \"\" {\n\t\tauth.ID = uuid.NewString()\n\t}\n\tauth.EnsureIndex()\n\tauthClone := auth.Clone()\n\tm.mu.Lock()\n\tm.auths[auth.ID] = authClone\n\tm.mu.Unlock()\n\tm.rebuildAPIKeyModelAliasFromRuntimeConfig()\n\tif m.scheduler != nil {\n\t\tm.scheduler.upsertAuth(authClone)\n\t}\n\t_ = m.persist(ctx, auth)\n\tm.hook.OnAuthRegistered(ctx, auth.Clone())\n\treturn auth.Clone(), nil\n}\n\n// Update replaces an existing auth entry and notifies hooks.\nfunc (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {\n\tif auth == nil || auth.ID == \"\" {\n\t\treturn nil, nil\n\t}\n\tm.mu.Lock()\n\tif existing, ok := m.auths[auth.ID]; ok && existing != nil {\n\t\tif !auth.indexAssigned && auth.Index == \"\" {\n\t\t\tauth.Index = existing.Index\n\t\t\tauth.indexAssigned = existing.indexAssigned\n\t\t}\n\t\tif len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {\n\t\t\tauth.ModelStates = existing.ModelStates\n\t\t}\n\t}\n\tauth.EnsureIndex()\n\tauthClone := auth.Clone()\n\tm.auths[auth.ID] = authClone\n\tm.mu.Unlock()\n\tm.rebuildAPIKeyModelAliasFromRuntimeConfig()\n\tif m.scheduler != nil {\n\t\tm.scheduler.upsertAuth(authClone)\n\t}\n\t_ = m.persist(ctx, auth)\n\tm.hook.OnAuthUpdated(ctx, auth.Clone())\n\treturn auth.Clone(), nil\n}\n\n// Load resets manager state from the backing store.\nfunc (m *Manager) Load(ctx context.Context) error {\n\tm.mu.Lock()\n\tif m.store == nil {\n\t\tm.mu.Unlock()\n\t\treturn nil\n\t}\n\titems, err := m.store.List(ctx)\n\tif err != nil {\n\t\tm.mu.Unlock()\n\t\treturn err\n\t}\n\tm.auths = make(map[string]*Auth, len(items))\n\tfor _, auth := range items {\n\t\tif auth == nil || auth.ID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tauth.EnsureIndex()\n\t\tm.auths[auth.ID] = auth.Clone()\n\t}\n\tcfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\tm.rebuildAPIKeyModelAliasLocked(cfg)\n\tm.mu.Unlock()\n\tm.syncScheduler()\n\treturn nil\n}\n\n// Execute performs a non-streaming execution using the configured selector and executor.\n// It supports multiple providers for the same model and round-robins the starting provider per model.\nfunc (m *Manager) Execute(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tnormalized := m.normalizeProviders(providers)\n\tif len(normalized) == 0 {\n\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\n\t_, maxRetryCredentials, maxWait := m.retrySettings()\n\n\tvar lastErr error\n\tfor attempt := 0; ; attempt++ {\n\t\tresp, errExec := m.executeMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)\n\t\tif errExec == nil {\n\t\t\treturn resp, nil\n\t\t}\n\t\tlastErr = errExec\n\t\twait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, req.Model, maxWait)\n\t\tif !shouldRetry {\n\t\t\tbreak\n\t\t}\n\t\tif errWait := waitForCooldown(ctx, wait); errWait != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errWait\n\t\t}\n\t}\n\tif lastErr != nil {\n\t\treturn cliproxyexecutor.Response{}, lastErr\n\t}\n\treturn cliproxyexecutor.Response{}, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n}\n\n// ExecuteCount performs a non-streaming execution using the configured selector and executor.\n// It supports multiple providers for the same model and round-robins the starting provider per model.\nfunc (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\tnormalized := m.normalizeProviders(providers)\n\tif len(normalized) == 0 {\n\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\n\t_, maxRetryCredentials, maxWait := m.retrySettings()\n\n\tvar lastErr error\n\tfor attempt := 0; ; attempt++ {\n\t\tresp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)\n\t\tif errExec == nil {\n\t\t\treturn resp, nil\n\t\t}\n\t\tlastErr = errExec\n\t\twait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, req.Model, maxWait)\n\t\tif !shouldRetry {\n\t\t\tbreak\n\t\t}\n\t\tif errWait := waitForCooldown(ctx, wait); errWait != nil {\n\t\t\treturn cliproxyexecutor.Response{}, errWait\n\t\t}\n\t}\n\tif lastErr != nil {\n\t\treturn cliproxyexecutor.Response{}, lastErr\n\t}\n\treturn cliproxyexecutor.Response{}, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n}\n\n// ExecuteStream performs a streaming execution using the configured selector and executor.\n// It supports multiple providers for the same model and round-robins the starting provider per model.\nfunc (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\tnormalized := m.normalizeProviders(providers)\n\tif len(normalized) == 0 {\n\t\treturn nil, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\n\t_, maxRetryCredentials, maxWait := m.retrySettings()\n\n\tvar lastErr error\n\tfor attempt := 0; ; attempt++ {\n\t\tresult, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)\n\t\tif errStream == nil {\n\t\t\treturn result, nil\n\t\t}\n\t\tlastErr = errStream\n\t\twait, shouldRetry := m.shouldRetryAfterError(errStream, attempt, normalized, req.Model, maxWait)\n\t\tif !shouldRetry {\n\t\t\tbreak\n\t\t}\n\t\tif errWait := waitForCooldown(ctx, wait); errWait != nil {\n\t\t\treturn nil, errWait\n\t\t}\n\t}\n\tif lastErr != nil {\n\t\treturn nil, lastErr\n\t}\n\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n}\n\nfunc (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (cliproxyexecutor.Response, error) {\n\tif len(providers) == 0 {\n\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\trouteModel := req.Model\n\topts = ensureRequestedModelMetadata(opts, routeModel)\n\ttried := make(map[string]struct{})\n\tvar lastErr error\n\tfor {\n\t\tif maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn cliproxyexecutor.Response{}, lastErr\n\t\t\t}\n\t\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t\t}\n\t\tauth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)\n\t\tif errPick != nil {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn cliproxyexecutor.Response{}, lastErr\n\t\t\t}\n\t\t\treturn cliproxyexecutor.Response{}, errPick\n\t\t}\n\n\t\tentry := logEntryWithRequestID(ctx)\n\t\tdebugLogAuthSelection(entry, auth, provider, req.Model)\n\t\tpublishSelectedAuthMetadata(opts.Metadata, auth.ID)\n\n\t\ttried[auth.ID] = struct{}{}\n\t\texecCtx := ctx\n\t\tif rt := m.roundTripperFor(auth); rt != nil {\n\t\t\texecCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)\n\t\t\texecCtx = context.WithValue(execCtx, \"cliproxy.roundtripper\", rt)\n\t\t}\n\n\t\tmodels := m.prepareExecutionModels(auth, routeModel)\n\t\tvar authErr error\n\t\tfor _, upstreamModel := range models {\n\t\t\texecReq := req\n\t\t\texecReq.Model = upstreamModel\n\t\t\tresp, errExec := executor.Execute(execCtx, auth, execReq, opts)\n\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}\n\t\t\tif errExec != nil {\n\t\t\t\tif errCtx := execCtx.Err(); errCtx != nil {\n\t\t\t\t\treturn cliproxyexecutor.Response{}, errCtx\n\t\t\t\t}\n\t\t\t\tresult.Error = &Error{Message: errExec.Error()}\n\t\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil {\n\t\t\t\t\tresult.Error.HTTPStatus = se.StatusCode()\n\t\t\t\t}\n\t\t\t\tif ra := retryAfterFromError(errExec); ra != nil {\n\t\t\t\t\tresult.RetryAfter = ra\n\t\t\t\t}\n\t\t\t\tm.MarkResult(execCtx, result)\n\t\t\t\tif isRequestInvalidError(errExec) {\n\t\t\t\t\treturn cliproxyexecutor.Response{}, errExec\n\t\t\t\t}\n\t\t\t\tauthErr = errExec\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tm.MarkResult(execCtx, result)\n\t\t\treturn resp, nil\n\t\t}\n\t\tif authErr != nil {\n\t\t\tif isRequestInvalidError(authErr) {\n\t\t\t\treturn cliproxyexecutor.Response{}, authErr\n\t\t\t}\n\t\t\tlastErr = authErr\n\t\t\tcontinue\n\t\t}\n\t}\n}\n\nfunc (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (cliproxyexecutor.Response, error) {\n\tif len(providers) == 0 {\n\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\trouteModel := req.Model\n\topts = ensureRequestedModelMetadata(opts, routeModel)\n\ttried := make(map[string]struct{})\n\tvar lastErr error\n\tfor {\n\t\tif maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn cliproxyexecutor.Response{}, lastErr\n\t\t\t}\n\t\t\treturn cliproxyexecutor.Response{}, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t\t}\n\t\tauth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)\n\t\tif errPick != nil {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn cliproxyexecutor.Response{}, lastErr\n\t\t\t}\n\t\t\treturn cliproxyexecutor.Response{}, errPick\n\t\t}\n\n\t\tentry := logEntryWithRequestID(ctx)\n\t\tdebugLogAuthSelection(entry, auth, provider, req.Model)\n\t\tpublishSelectedAuthMetadata(opts.Metadata, auth.ID)\n\n\t\ttried[auth.ID] = struct{}{}\n\t\texecCtx := ctx\n\t\tif rt := m.roundTripperFor(auth); rt != nil {\n\t\t\texecCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)\n\t\t\texecCtx = context.WithValue(execCtx, \"cliproxy.roundtripper\", rt)\n\t\t}\n\n\t\tmodels := m.prepareExecutionModels(auth, routeModel)\n\t\tvar authErr error\n\t\tfor _, upstreamModel := range models {\n\t\t\texecReq := req\n\t\t\texecReq.Model = upstreamModel\n\t\t\tresp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)\n\t\t\tresult := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}\n\t\t\tif errExec != nil {\n\t\t\t\tif errCtx := execCtx.Err(); errCtx != nil {\n\t\t\t\t\treturn cliproxyexecutor.Response{}, errCtx\n\t\t\t\t}\n\t\t\t\tresult.Error = &Error{Message: errExec.Error()}\n\t\t\t\tif se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil {\n\t\t\t\t\tresult.Error.HTTPStatus = se.StatusCode()\n\t\t\t\t}\n\t\t\t\tif ra := retryAfterFromError(errExec); ra != nil {\n\t\t\t\t\tresult.RetryAfter = ra\n\t\t\t\t}\n\t\t\t\tm.hook.OnResult(execCtx, result)\n\t\t\t\tif isRequestInvalidError(errExec) {\n\t\t\t\t\treturn cliproxyexecutor.Response{}, errExec\n\t\t\t\t}\n\t\t\t\tauthErr = errExec\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tm.hook.OnResult(execCtx, result)\n\t\t\treturn resp, nil\n\t\t}\n\t\tif authErr != nil {\n\t\t\tif isRequestInvalidError(authErr) {\n\t\t\t\treturn cliproxyexecutor.Response{}, authErr\n\t\t\t}\n\t\t\tlastErr = authErr\n\t\t\tcontinue\n\t\t}\n\t}\n}\n\nfunc (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (*cliproxyexecutor.StreamResult, error) {\n\tif len(providers) == 0 {\n\t\treturn nil, &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\trouteModel := req.Model\n\topts = ensureRequestedModelMetadata(opts, routeModel)\n\ttried := make(map[string]struct{})\n\tvar lastErr error\n\tfor {\n\t\tif maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn nil, lastErr\n\t\t\t}\n\t\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t\t}\n\t\tauth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)\n\t\tif errPick != nil {\n\t\t\tif lastErr != nil {\n\t\t\t\treturn nil, lastErr\n\t\t\t}\n\t\t\treturn nil, errPick\n\t\t}\n\n\t\tentry := logEntryWithRequestID(ctx)\n\t\tdebugLogAuthSelection(entry, auth, provider, req.Model)\n\t\tpublishSelectedAuthMetadata(opts.Metadata, auth.ID)\n\n\t\ttried[auth.ID] = struct{}{}\n\t\texecCtx := ctx\n\t\tif rt := m.roundTripperFor(auth); rt != nil {\n\t\t\texecCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)\n\t\t\texecCtx = context.WithValue(execCtx, \"cliproxy.roundtripper\", rt)\n\t\t}\n\t\tstreamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, req, opts, routeModel)\n\t\tif errStream != nil {\n\t\t\tif errCtx := execCtx.Err(); errCtx != nil {\n\t\t\t\treturn nil, errCtx\n\t\t\t}\n\t\t\tif isRequestInvalidError(errStream) {\n\t\t\t\treturn nil, errStream\n\t\t\t}\n\t\t\tlastErr = errStream\n\t\t\tcontinue\n\t\t}\n\t\treturn streamResult, nil\n\t}\n}\n\nfunc ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options {\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn opts\n\t}\n\tif hasRequestedModelMetadata(opts.Metadata) {\n\t\treturn opts\n\t}\n\tif len(opts.Metadata) == 0 {\n\t\topts.Metadata = map[string]any{cliproxyexecutor.RequestedModelMetadataKey: requestedModel}\n\t\treturn opts\n\t}\n\tmeta := make(map[string]any, len(opts.Metadata)+1)\n\tfor k, v := range opts.Metadata {\n\t\tmeta[k] = v\n\t}\n\tmeta[cliproxyexecutor.RequestedModelMetadataKey] = requestedModel\n\topts.Metadata = meta\n\treturn opts\n}\n\nfunc hasRequestedModelMetadata(meta map[string]any) bool {\n\tif len(meta) == 0 {\n\t\treturn false\n\t}\n\traw, ok := meta[cliproxyexecutor.RequestedModelMetadataKey]\n\tif !ok || raw == nil {\n\t\treturn false\n\t}\n\tswitch v := raw.(type) {\n\tcase string:\n\t\treturn strings.TrimSpace(v) != \"\"\n\tcase []byte:\n\t\treturn strings.TrimSpace(string(v)) != \"\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc pinnedAuthIDFromMetadata(meta map[string]any) string {\n\tif len(meta) == 0 {\n\t\treturn \"\"\n\t}\n\traw, ok := meta[cliproxyexecutor.PinnedAuthMetadataKey]\n\tif !ok || raw == nil {\n\t\treturn \"\"\n\t}\n\tswitch val := raw.(type) {\n\tcase string:\n\t\treturn strings.TrimSpace(val)\n\tcase []byte:\n\t\treturn strings.TrimSpace(string(val))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc publishSelectedAuthMetadata(meta map[string]any, authID string) {\n\tif len(meta) == 0 {\n\t\treturn\n\t}\n\tauthID = strings.TrimSpace(authID)\n\tif authID == \"\" {\n\t\treturn\n\t}\n\tmeta[cliproxyexecutor.SelectedAuthMetadataKey] = authID\n\tif callback, ok := meta[cliproxyexecutor.SelectedAuthCallbackMetadataKey].(func(string)); ok && callback != nil {\n\t\tcallback(authID)\n\t}\n}\n\nfunc rewriteModelForAuth(model string, auth *Auth) string {\n\tif auth == nil || model == \"\" {\n\t\treturn model\n\t}\n\tprefix := strings.TrimSpace(auth.Prefix)\n\tif prefix == \"\" {\n\t\treturn model\n\t}\n\tneedle := prefix + \"/\"\n\tif !strings.HasPrefix(model, needle) {\n\t\treturn model\n\t}\n\treturn strings.TrimPrefix(model, needle)\n}\n\nfunc (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) string {\n\tif m == nil || auth == nil {\n\t\treturn requestedModel\n\t}\n\n\tkind, _ := auth.AccountInfo()\n\tif !strings.EqualFold(strings.TrimSpace(kind), \"api_key\") {\n\t\treturn requestedModel\n\t}\n\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn requestedModel\n\t}\n\n\t// Fast path: lookup per-auth mapping table (keyed by auth.ID).\n\tif resolved := m.lookupAPIKeyUpstreamModel(auth.ID, requestedModel); resolved != \"\" {\n\t\treturn resolved\n\t}\n\n\t// Slow path: scan config for the matching credential entry and resolve alias.\n\t// This acts as a safety net if mappings are stale or auth.ID is missing.\n\tcfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)\n\tif cfg == nil {\n\t\tcfg = &internalconfig.Config{}\n\t}\n\n\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\tupstreamModel := \"\"\n\tswitch provider {\n\tcase \"gemini\":\n\t\tupstreamModel = resolveUpstreamModelForGeminiAPIKey(cfg, auth, requestedModel)\n\tcase \"claude\":\n\t\tupstreamModel = resolveUpstreamModelForClaudeAPIKey(cfg, auth, requestedModel)\n\tcase \"codex\":\n\t\tupstreamModel = resolveUpstreamModelForCodexAPIKey(cfg, auth, requestedModel)\n\tcase \"vertex\":\n\t\tupstreamModel = resolveUpstreamModelForVertexAPIKey(cfg, auth, requestedModel)\n\tdefault:\n\t\tupstreamModel = resolveUpstreamModelForOpenAICompatAPIKey(cfg, auth, requestedModel)\n\t}\n\n\t// Return upstream model if found, otherwise return requested model.\n\tif upstreamModel != \"\" {\n\t\treturn upstreamModel\n\t}\n\treturn requestedModel\n}\n\n// APIKeyConfigEntry is a generic interface for API key configurations.\ntype APIKeyConfigEntry interface {\n\tGetAPIKey() string\n\tGetBaseURL() string\n}\n\nfunc resolveAPIKeyConfig[T APIKeyConfigEntry](entries []T, auth *Auth) *T {\n\tif auth == nil || len(entries) == 0 {\n\t\treturn nil\n\t}\n\tattrKey, attrBase := \"\", \"\"\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range entries {\n\t\tentry := &entries[i]\n\t\tcfgKey := strings.TrimSpace((*entry).GetAPIKey())\n\t\tcfgBase := strings.TrimSpace((*entry).GetBaseURL())\n\t\tif attrKey != \"\" && attrBase != \"\" {\n\t\t\tif strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range entries {\n\t\t\tentry := &entries[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace((*entry).GetAPIKey()), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc resolveGeminiAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.GeminiKey {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\treturn resolveAPIKeyConfig(cfg.GeminiKey, auth)\n}\n\nfunc resolveClaudeAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.ClaudeKey {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\treturn resolveAPIKeyConfig(cfg.ClaudeKey, auth)\n}\n\nfunc resolveCodexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.CodexKey {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\treturn resolveAPIKeyConfig(cfg.CodexKey, auth)\n}\n\nfunc resolveVertexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.VertexCompatKey {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\treturn resolveAPIKeyConfig(cfg.VertexCompatAPIKey, auth)\n}\n\nfunc resolveUpstreamModelForGeminiAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {\n\tentry := resolveGeminiAPIKeyConfig(cfg, auth)\n\tif entry == nil {\n\t\treturn \"\"\n\t}\n\treturn resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\nfunc resolveUpstreamModelForClaudeAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {\n\tentry := resolveClaudeAPIKeyConfig(cfg, auth)\n\tif entry == nil {\n\t\treturn \"\"\n\t}\n\treturn resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\nfunc resolveUpstreamModelForCodexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {\n\tentry := resolveCodexAPIKeyConfig(cfg, auth)\n\tif entry == nil {\n\t\treturn \"\"\n\t}\n\treturn resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\nfunc resolveUpstreamModelForVertexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {\n\tentry := resolveVertexAPIKeyConfig(cfg, auth)\n\tif entry == nil {\n\t\treturn \"\"\n\t}\n\treturn resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\nfunc resolveUpstreamModelForOpenAICompatAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {\n\tproviderKey := \"\"\n\tcompatName := \"\"\n\tif auth != nil && len(auth.Attributes) > 0 {\n\t\tproviderKey = strings.TrimSpace(auth.Attributes[\"provider_key\"])\n\t\tcompatName = strings.TrimSpace(auth.Attributes[\"compat_name\"])\n\t}\n\tif compatName == \"\" && !strings.EqualFold(strings.TrimSpace(auth.Provider), \"openai-compatibility\") {\n\t\treturn \"\"\n\t}\n\tentry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider)\n\tif entry == nil {\n\t\treturn \"\"\n\t}\n\treturn resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))\n}\n\ntype apiKeyModelAliasTable map[string]map[string]string\n\nfunc resolveOpenAICompatConfig(cfg *internalconfig.Config, providerKey, compatName, authProvider string) *internalconfig.OpenAICompatibility {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\tcandidates := make([]string, 0, 3)\n\tif v := strings.TrimSpace(compatName); v != \"\" {\n\t\tcandidates = append(candidates, v)\n\t}\n\tif v := strings.TrimSpace(providerKey); v != \"\" {\n\t\tcandidates = append(candidates, v)\n\t}\n\tif v := strings.TrimSpace(authProvider); v != \"\" {\n\t\tcandidates = append(candidates, v)\n\t}\n\tfor i := range cfg.OpenAICompatibility {\n\t\tcompat := &cfg.OpenAICompatibility[i]\n\t\tfor _, candidate := range candidates {\n\t\t\tif candidate != \"\" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {\n\t\t\t\treturn compat\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc asModelAliasEntries[T interface {\n\tGetName() string\n\tGetAlias() string\n}](models []T) []modelAliasEntry {\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]modelAliasEntry, 0, len(models))\n\tfor i := range models {\n\t\tout = append(out, models[i])\n\t}\n\treturn out\n}\n\nfunc (m *Manager) normalizeProviders(providers []string) []string {\n\tif len(providers) == 0 {\n\t\treturn nil\n\t}\n\tresult := make([]string, 0, len(providers))\n\tseen := make(map[string]struct{}, len(providers))\n\tfor _, provider := range providers {\n\t\tp := strings.TrimSpace(strings.ToLower(provider))\n\t\tif p == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[p]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[p] = struct{}{}\n\t\tresult = append(result, p)\n\t}\n\treturn result\n}\n\nfunc (m *Manager) retrySettings() (int, int, time.Duration) {\n\tif m == nil {\n\t\treturn 0, 0, 0\n\t}\n\treturn int(m.requestRetry.Load()), int(m.maxRetryCredentials.Load()), time.Duration(m.maxRetryInterval.Load())\n}\n\nfunc (m *Manager) closestCooldownWait(providers []string, model string, attempt int) (time.Duration, bool) {\n\tif m == nil || len(providers) == 0 {\n\t\treturn 0, false\n\t}\n\tnow := time.Now()\n\tdefaultRetry := int(m.requestRetry.Load())\n\tif defaultRetry < 0 {\n\t\tdefaultRetry = 0\n\t}\n\tproviderSet := make(map[string]struct{}, len(providers))\n\tfor i := range providers {\n\t\tkey := strings.TrimSpace(strings.ToLower(providers[i]))\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tproviderSet[key] = struct{}{}\n\t}\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tvar (\n\t\tfound   bool\n\t\tminWait time.Duration\n\t)\n\tfor _, auth := range m.auths {\n\t\tif auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tproviderKey := strings.TrimSpace(strings.ToLower(auth.Provider))\n\t\tif _, ok := providerSet[providerKey]; !ok {\n\t\t\tcontinue\n\t\t}\n\t\teffectiveRetry := defaultRetry\n\t\tif override, ok := auth.RequestRetryOverride(); ok {\n\t\t\teffectiveRetry = override\n\t\t}\n\t\tif effectiveRetry < 0 {\n\t\t\teffectiveRetry = 0\n\t\t}\n\t\tif attempt >= effectiveRetry {\n\t\t\tcontinue\n\t\t}\n\t\tblocked, reason, next := isAuthBlockedForModel(auth, model, now)\n\t\tif !blocked || next.IsZero() || reason == blockReasonDisabled {\n\t\t\tcontinue\n\t\t}\n\t\twait := next.Sub(now)\n\t\tif wait < 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif !found || wait < minWait {\n\t\t\tminWait = wait\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn minWait, found\n}\n\nfunc (m *Manager) shouldRetryAfterError(err error, attempt int, providers []string, model string, maxWait time.Duration) (time.Duration, bool) {\n\tif err == nil {\n\t\treturn 0, false\n\t}\n\tif maxWait <= 0 {\n\t\treturn 0, false\n\t}\n\tif status := statusCodeFromError(err); status == http.StatusOK {\n\t\treturn 0, false\n\t}\n\tif isRequestInvalidError(err) {\n\t\treturn 0, false\n\t}\n\twait, found := m.closestCooldownWait(providers, model, attempt)\n\tif !found || wait > maxWait {\n\t\treturn 0, false\n\t}\n\treturn wait, true\n}\n\nfunc waitForCooldown(ctx context.Context, wait time.Duration) error {\n\tif wait <= 0 {\n\t\treturn nil\n\t}\n\ttimer := time.NewTimer(wait)\n\tdefer timer.Stop()\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n\n// MarkResult records an execution result and notifies hooks.\nfunc (m *Manager) MarkResult(ctx context.Context, result Result) {\n\tif result.AuthID == \"\" {\n\t\treturn\n\t}\n\n\tshouldResumeModel := false\n\tshouldSuspendModel := false\n\tsuspendReason := \"\"\n\tclearModelQuota := false\n\tsetModelQuota := false\n\tvar authSnapshot *Auth\n\n\tm.mu.Lock()\n\tif auth, ok := m.auths[result.AuthID]; ok && auth != nil {\n\t\tnow := time.Now()\n\n\t\tif result.Success {\n\t\t\tif result.Model != \"\" {\n\t\t\t\tstate := ensureModelState(auth, result.Model)\n\t\t\t\tresetModelState(state, now)\n\t\t\t\tupdateAggregatedAvailability(auth, now)\n\t\t\t\tif !hasModelError(auth, now) {\n\t\t\t\t\tauth.LastError = nil\n\t\t\t\t\tauth.StatusMessage = \"\"\n\t\t\t\t\tauth.Status = StatusActive\n\t\t\t\t}\n\t\t\t\tauth.UpdatedAt = now\n\t\t\t\tshouldResumeModel = true\n\t\t\t\tclearModelQuota = true\n\t\t\t} else {\n\t\t\t\tclearAuthStateOnSuccess(auth, now)\n\t\t\t}\n\t\t} else {\n\t\t\tif result.Model != \"\" {\n\t\t\t\tstate := ensureModelState(auth, result.Model)\n\t\t\t\tstate.Unavailable = true\n\t\t\t\tstate.Status = StatusError\n\t\t\t\tstate.UpdatedAt = now\n\t\t\t\tif result.Error != nil {\n\t\t\t\t\tstate.LastError = cloneError(result.Error)\n\t\t\t\t\tstate.StatusMessage = result.Error.Message\n\t\t\t\t\tauth.LastError = cloneError(result.Error)\n\t\t\t\t\tauth.StatusMessage = result.Error.Message\n\t\t\t\t}\n\n\t\t\t\tstatusCode := statusCodeFromResult(result.Error)\n\t\t\t\tswitch statusCode {\n\t\t\t\tcase 401:\n\t\t\t\t\tnext := now.Add(30 * time.Minute)\n\t\t\t\t\tstate.NextRetryAfter = next\n\t\t\t\t\tsuspendReason = \"unauthorized\"\n\t\t\t\t\tshouldSuspendModel = true\n\t\t\t\tcase 402, 403:\n\t\t\t\t\tnext := now.Add(30 * time.Minute)\n\t\t\t\t\tstate.NextRetryAfter = next\n\t\t\t\t\tsuspendReason = \"payment_required\"\n\t\t\t\t\tshouldSuspendModel = true\n\t\t\t\tcase 404:\n\t\t\t\t\tnext := now.Add(12 * time.Hour)\n\t\t\t\t\tstate.NextRetryAfter = next\n\t\t\t\t\tsuspendReason = \"not_found\"\n\t\t\t\t\tshouldSuspendModel = true\n\t\t\t\tcase 429:\n\t\t\t\t\tvar next time.Time\n\t\t\t\t\tbackoffLevel := state.Quota.BackoffLevel\n\t\t\t\t\tif result.RetryAfter != nil {\n\t\t\t\t\t\tnext = now.Add(*result.RetryAfter)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcooldown, nextLevel := nextQuotaCooldown(backoffLevel, quotaCooldownDisabledForAuth(auth))\n\t\t\t\t\t\tif cooldown > 0 {\n\t\t\t\t\t\t\tnext = now.Add(cooldown)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbackoffLevel = nextLevel\n\t\t\t\t\t}\n\t\t\t\t\tstate.NextRetryAfter = next\n\t\t\t\t\tstate.Quota = QuotaState{\n\t\t\t\t\t\tExceeded:      true,\n\t\t\t\t\t\tReason:        \"quota\",\n\t\t\t\t\t\tNextRecoverAt: next,\n\t\t\t\t\t\tBackoffLevel:  backoffLevel,\n\t\t\t\t\t}\n\t\t\t\t\tsuspendReason = \"quota\"\n\t\t\t\t\tshouldSuspendModel = true\n\t\t\t\t\tsetModelQuota = true\n\t\t\t\tcase 408, 500, 502, 503, 504:\n\t\t\t\t\tif quotaCooldownDisabledForAuth(auth) {\n\t\t\t\t\t\tstate.NextRetryAfter = time.Time{}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnext := now.Add(1 * time.Minute)\n\t\t\t\t\t\tstate.NextRetryAfter = next\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\tstate.NextRetryAfter = time.Time{}\n\t\t\t\t}\n\n\t\t\t\tauth.Status = StatusError\n\t\t\t\tauth.UpdatedAt = now\n\t\t\t\tupdateAggregatedAvailability(auth, now)\n\t\t\t} else {\n\t\t\t\tapplyAuthFailureState(auth, result.Error, result.RetryAfter, now)\n\t\t\t}\n\t\t}\n\n\t\t_ = m.persist(ctx, auth)\n\t\tauthSnapshot = auth.Clone()\n\t}\n\tm.mu.Unlock()\n\tif m.scheduler != nil && authSnapshot != nil {\n\t\tm.scheduler.upsertAuth(authSnapshot)\n\t}\n\n\tif clearModelQuota && result.Model != \"\" {\n\t\tregistry.GetGlobalRegistry().ClearModelQuotaExceeded(result.AuthID, result.Model)\n\t}\n\tif setModelQuota && result.Model != \"\" {\n\t\tregistry.GetGlobalRegistry().SetModelQuotaExceeded(result.AuthID, result.Model)\n\t}\n\tif shouldResumeModel {\n\t\tregistry.GetGlobalRegistry().ResumeClientModel(result.AuthID, result.Model)\n\t} else if shouldSuspendModel {\n\t\tregistry.GetGlobalRegistry().SuspendClientModel(result.AuthID, result.Model, suspendReason)\n\t}\n\n\tm.hook.OnResult(ctx, result)\n}\n\nfunc ensureModelState(auth *Auth, model string) *ModelState {\n\tif auth == nil || model == \"\" {\n\t\treturn nil\n\t}\n\tif auth.ModelStates == nil {\n\t\tauth.ModelStates = make(map[string]*ModelState)\n\t}\n\tif state, ok := auth.ModelStates[model]; ok && state != nil {\n\t\treturn state\n\t}\n\tstate := &ModelState{Status: StatusActive}\n\tauth.ModelStates[model] = state\n\treturn state\n}\n\nfunc resetModelState(state *ModelState, now time.Time) {\n\tif state == nil {\n\t\treturn\n\t}\n\tstate.Unavailable = false\n\tstate.Status = StatusActive\n\tstate.StatusMessage = \"\"\n\tstate.NextRetryAfter = time.Time{}\n\tstate.LastError = nil\n\tstate.Quota = QuotaState{}\n\tstate.UpdatedAt = now\n}\n\nfunc updateAggregatedAvailability(auth *Auth, now time.Time) {\n\tif auth == nil || len(auth.ModelStates) == 0 {\n\t\treturn\n\t}\n\tallUnavailable := true\n\tearliestRetry := time.Time{}\n\tquotaExceeded := false\n\tquotaRecover := time.Time{}\n\tmaxBackoffLevel := 0\n\tfor _, state := range auth.ModelStates {\n\t\tif state == nil {\n\t\t\tcontinue\n\t\t}\n\t\tstateUnavailable := false\n\t\tif state.Status == StatusDisabled {\n\t\t\tstateUnavailable = true\n\t\t} else if state.Unavailable {\n\t\t\tif state.NextRetryAfter.IsZero() {\n\t\t\t\tstateUnavailable = false\n\t\t\t} else if state.NextRetryAfter.After(now) {\n\t\t\t\tstateUnavailable = true\n\t\t\t\tif earliestRetry.IsZero() || state.NextRetryAfter.Before(earliestRetry) {\n\t\t\t\t\tearliestRetry = state.NextRetryAfter\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tstate.Unavailable = false\n\t\t\t\tstate.NextRetryAfter = time.Time{}\n\t\t\t}\n\t\t}\n\t\tif !stateUnavailable {\n\t\t\tallUnavailable = false\n\t\t}\n\t\tif state.Quota.Exceeded {\n\t\t\tquotaExceeded = true\n\t\t\tif quotaRecover.IsZero() || (!state.Quota.NextRecoverAt.IsZero() && state.Quota.NextRecoverAt.Before(quotaRecover)) {\n\t\t\t\tquotaRecover = state.Quota.NextRecoverAt\n\t\t\t}\n\t\t\tif state.Quota.BackoffLevel > maxBackoffLevel {\n\t\t\t\tmaxBackoffLevel = state.Quota.BackoffLevel\n\t\t\t}\n\t\t}\n\t}\n\tauth.Unavailable = allUnavailable\n\tif allUnavailable {\n\t\tauth.NextRetryAfter = earliestRetry\n\t} else {\n\t\tauth.NextRetryAfter = time.Time{}\n\t}\n\tif quotaExceeded {\n\t\tauth.Quota.Exceeded = true\n\t\tauth.Quota.Reason = \"quota\"\n\t\tauth.Quota.NextRecoverAt = quotaRecover\n\t\tauth.Quota.BackoffLevel = maxBackoffLevel\n\t} else {\n\t\tauth.Quota.Exceeded = false\n\t\tauth.Quota.Reason = \"\"\n\t\tauth.Quota.NextRecoverAt = time.Time{}\n\t\tauth.Quota.BackoffLevel = 0\n\t}\n}\n\nfunc hasModelError(auth *Auth, now time.Time) bool {\n\tif auth == nil || len(auth.ModelStates) == 0 {\n\t\treturn false\n\t}\n\tfor _, state := range auth.ModelStates {\n\t\tif state == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif state.LastError != nil {\n\t\t\treturn true\n\t\t}\n\t\tif state.Status == StatusError {\n\t\t\tif state.Unavailable && (state.NextRetryAfter.IsZero() || state.NextRetryAfter.After(now)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc clearAuthStateOnSuccess(auth *Auth, now time.Time) {\n\tif auth == nil {\n\t\treturn\n\t}\n\tauth.Unavailable = false\n\tauth.Status = StatusActive\n\tauth.StatusMessage = \"\"\n\tauth.Quota.Exceeded = false\n\tauth.Quota.Reason = \"\"\n\tauth.Quota.NextRecoverAt = time.Time{}\n\tauth.Quota.BackoffLevel = 0\n\tauth.LastError = nil\n\tauth.NextRetryAfter = time.Time{}\n\tauth.UpdatedAt = now\n}\n\nfunc cloneError(err *Error) *Error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn &Error{\n\t\tCode:       err.Code,\n\t\tMessage:    err.Message,\n\t\tRetryable:  err.Retryable,\n\t\tHTTPStatus: err.HTTPStatus,\n\t}\n}\n\nfunc statusCodeFromError(err error) int {\n\tif err == nil {\n\t\treturn 0\n\t}\n\ttype statusCoder interface {\n\t\tStatusCode() int\n\t}\n\tvar sc statusCoder\n\tif errors.As(err, &sc) && sc != nil {\n\t\treturn sc.StatusCode()\n\t}\n\treturn 0\n}\n\nfunc retryAfterFromError(err error) *time.Duration {\n\tif err == nil {\n\t\treturn nil\n\t}\n\ttype retryAfterProvider interface {\n\t\tRetryAfter() *time.Duration\n\t}\n\trap, ok := err.(retryAfterProvider)\n\tif !ok || rap == nil {\n\t\treturn nil\n\t}\n\tretryAfter := rap.RetryAfter()\n\tif retryAfter == nil {\n\t\treturn nil\n\t}\n\treturn new(*retryAfter)\n}\n\nfunc statusCodeFromResult(err *Error) int {\n\tif err == nil {\n\t\treturn 0\n\t}\n\treturn err.StatusCode()\n}\n\n// isRequestInvalidError returns true if the error represents a client request\n// error that should not be retried. Specifically, it treats 400 responses with\n// \"invalid_request_error\" and all 422 responses as request-shape failures,\n// where switching auths or pooled upstream models will not help.\nfunc isRequestInvalidError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tstatus := statusCodeFromError(err)\n\tswitch status {\n\tcase http.StatusBadRequest:\n\t\treturn strings.Contains(err.Error(), \"invalid_request_error\")\n\tcase http.StatusUnprocessableEntity:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Duration, now time.Time) {\n\tif auth == nil {\n\t\treturn\n\t}\n\tauth.Unavailable = true\n\tauth.Status = StatusError\n\tauth.UpdatedAt = now\n\tif resultErr != nil {\n\t\tauth.LastError = cloneError(resultErr)\n\t\tif resultErr.Message != \"\" {\n\t\t\tauth.StatusMessage = resultErr.Message\n\t\t}\n\t}\n\tstatusCode := statusCodeFromResult(resultErr)\n\tswitch statusCode {\n\tcase 401:\n\t\tauth.StatusMessage = \"unauthorized\"\n\t\tauth.NextRetryAfter = now.Add(30 * time.Minute)\n\tcase 402, 403:\n\t\tauth.StatusMessage = \"payment_required\"\n\t\tauth.NextRetryAfter = now.Add(30 * time.Minute)\n\tcase 404:\n\t\tauth.StatusMessage = \"not_found\"\n\t\tauth.NextRetryAfter = now.Add(12 * time.Hour)\n\tcase 429:\n\t\tauth.StatusMessage = \"quota exhausted\"\n\t\tauth.Quota.Exceeded = true\n\t\tauth.Quota.Reason = \"quota\"\n\t\tvar next time.Time\n\t\tif retryAfter != nil {\n\t\t\tnext = now.Add(*retryAfter)\n\t\t} else {\n\t\t\tcooldown, nextLevel := nextQuotaCooldown(auth.Quota.BackoffLevel, quotaCooldownDisabledForAuth(auth))\n\t\t\tif cooldown > 0 {\n\t\t\t\tnext = now.Add(cooldown)\n\t\t\t}\n\t\t\tauth.Quota.BackoffLevel = nextLevel\n\t\t}\n\t\tauth.Quota.NextRecoverAt = next\n\t\tauth.NextRetryAfter = next\n\tcase 408, 500, 502, 503, 504:\n\t\tauth.StatusMessage = \"transient upstream error\"\n\t\tif quotaCooldownDisabledForAuth(auth) {\n\t\t\tauth.NextRetryAfter = time.Time{}\n\t\t} else {\n\t\t\tauth.NextRetryAfter = now.Add(1 * time.Minute)\n\t\t}\n\tdefault:\n\t\tif auth.StatusMessage == \"\" {\n\t\t\tauth.StatusMessage = \"request failed\"\n\t\t}\n\t}\n}\n\n// nextQuotaCooldown returns the next cooldown duration and updated backoff level for repeated quota errors.\nfunc nextQuotaCooldown(prevLevel int, disableCooling bool) (time.Duration, int) {\n\tif prevLevel < 0 {\n\t\tprevLevel = 0\n\t}\n\tif disableCooling {\n\t\treturn 0, prevLevel\n\t}\n\tcooldown := quotaBackoffBase * time.Duration(1<<prevLevel)\n\tif cooldown < quotaBackoffBase {\n\t\tcooldown = quotaBackoffBase\n\t}\n\tif cooldown >= quotaBackoffMax {\n\t\treturn quotaBackoffMax, prevLevel\n\t}\n\treturn cooldown, prevLevel + 1\n}\n\n// List returns all auth entries currently known by the manager.\nfunc (m *Manager) List() []*Auth {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tlist := make([]*Auth, 0, len(m.auths))\n\tfor _, auth := range m.auths {\n\t\tlist = append(list, auth.Clone())\n\t}\n\treturn list\n}\n\n// GetByID retrieves an auth entry by its ID.\n\nfunc (m *Manager) GetByID(id string) (*Auth, bool) {\n\tif id == \"\" {\n\t\treturn nil, false\n\t}\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tauth, ok := m.auths[id]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn auth.Clone(), true\n}\n\n// Executor returns the registered provider executor for a provider key.\nfunc (m *Manager) Executor(provider string) (ProviderExecutor, bool) {\n\tif m == nil {\n\t\treturn nil, false\n\t}\n\tprovider = strings.TrimSpace(provider)\n\tif provider == \"\" {\n\t\treturn nil, false\n\t}\n\n\tm.mu.RLock()\n\texecutor, okExecutor := m.executors[provider]\n\tif !okExecutor {\n\t\tlowerProvider := strings.ToLower(provider)\n\t\tif lowerProvider != provider {\n\t\t\texecutor, okExecutor = m.executors[lowerProvider]\n\t\t}\n\t}\n\tm.mu.RUnlock()\n\n\tif !okExecutor || executor == nil {\n\t\treturn nil, false\n\t}\n\treturn executor, true\n}\n\n// CloseExecutionSession asks all registered executors to release the supplied execution session.\nfunc (m *Manager) CloseExecutionSession(sessionID string) {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif m == nil || sessionID == \"\" {\n\t\treturn\n\t}\n\n\tm.mu.RLock()\n\texecutors := make([]ProviderExecutor, 0, len(m.executors))\n\tfor _, exec := range m.executors {\n\t\texecutors = append(executors, exec)\n\t}\n\tm.mu.RUnlock()\n\n\tfor i := range executors {\n\t\tif closer, ok := executors[i].(ExecutionSessionCloser); ok && closer != nil {\n\t\t\tcloser.CloseExecutionSession(sessionID)\n\t\t}\n\t}\n}\n\nfunc (m *Manager) useSchedulerFastPath() bool {\n\tif m == nil || m.scheduler == nil {\n\t\treturn false\n\t}\n\treturn isBuiltInSelector(m.selector)\n}\n\nfunc shouldRetrySchedulerPick(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tvar cooldownErr *modelCooldownError\n\tif errors.As(err, &cooldownErr) {\n\t\treturn true\n\t}\n\tvar authErr *Error\n\tif !errors.As(err, &authErr) || authErr == nil {\n\t\treturn false\n\t}\n\treturn authErr.Code == \"auth_not_found\" || authErr.Code == \"auth_unavailable\"\n}\n\nfunc (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {\n\tpinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)\n\n\tm.mu.RLock()\n\texecutor, okExecutor := m.executors[provider]\n\tif !okExecutor {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, &Error{Code: \"executor_not_found\", Message: \"executor not registered\"}\n\t}\n\tcandidates := make([]*Auth, 0, len(m.auths))\n\tmodelKey := strings.TrimSpace(model)\n\t// Always use base model name (without thinking suffix) for auth matching.\n\tif modelKey != \"\" {\n\t\tparsed := thinking.ParseSuffix(modelKey)\n\t\tif parsed.ModelName != \"\" {\n\t\t\tmodelKey = strings.TrimSpace(parsed.ModelName)\n\t\t}\n\t}\n\tregistryRef := registry.GetGlobalRegistry()\n\tfor _, candidate := range m.auths {\n\t\tif candidate.Provider != provider || candidate.Disabled {\n\t\t\tcontinue\n\t\t}\n\t\tif pinnedAuthID != \"\" && candidate.ID != pinnedAuthID {\n\t\t\tcontinue\n\t\t}\n\t\tif _, used := tried[candidate.ID]; used {\n\t\t\tcontinue\n\t\t}\n\t\tif modelKey != \"\" && registryRef != nil && !registryRef.ClientSupportsModel(candidate.ID, modelKey) {\n\t\t\tcontinue\n\t\t}\n\t\tcandidates = append(candidates, candidate)\n\t}\n\tif len(candidates) == 0 {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tselected, errPick := m.selector.Pick(ctx, provider, model, opts, candidates)\n\tif errPick != nil {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, errPick\n\t}\n\tif selected == nil {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, &Error{Code: \"auth_not_found\", Message: \"selector returned no auth\"}\n\t}\n\tauthCopy := selected.Clone()\n\tm.mu.RUnlock()\n\tif !selected.indexAssigned {\n\t\tm.mu.Lock()\n\t\tif current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {\n\t\t\tcurrent.EnsureIndex()\n\t\t\tauthCopy = current.Clone()\n\t\t}\n\t\tm.mu.Unlock()\n\t}\n\treturn authCopy, executor, nil\n}\n\nfunc (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {\n\tif !m.useSchedulerFastPath() {\n\t\treturn m.pickNextLegacy(ctx, provider, model, opts, tried)\n\t}\n\texecutor, okExecutor := m.Executor(provider)\n\tif !okExecutor {\n\t\treturn nil, nil, &Error{Code: \"executor_not_found\", Message: \"executor not registered\"}\n\t}\n\tselected, errPick := m.scheduler.pickSingle(ctx, provider, model, opts, tried)\n\tif errPick != nil && model != \"\" && shouldRetrySchedulerPick(errPick) {\n\t\tm.syncScheduler()\n\t\tselected, errPick = m.scheduler.pickSingle(ctx, provider, model, opts, tried)\n\t}\n\tif errPick != nil {\n\t\treturn nil, nil, errPick\n\t}\n\tif selected == nil {\n\t\treturn nil, nil, &Error{Code: \"auth_not_found\", Message: \"selector returned no auth\"}\n\t}\n\tauthCopy := selected.Clone()\n\tif !selected.indexAssigned {\n\t\tm.mu.Lock()\n\t\tif current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {\n\t\t\tcurrent.EnsureIndex()\n\t\t\tauthCopy = current.Clone()\n\t\t}\n\t\tm.mu.Unlock()\n\t}\n\treturn authCopy, executor, nil\n}\n\nfunc (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {\n\tpinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)\n\n\tproviderSet := make(map[string]struct{}, len(providers))\n\tfor _, provider := range providers {\n\t\tp := strings.TrimSpace(strings.ToLower(provider))\n\t\tif p == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tproviderSet[p] = struct{}{}\n\t}\n\tif len(providerSet) == 0 {\n\t\treturn nil, nil, \"\", &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\n\tm.mu.RLock()\n\tcandidates := make([]*Auth, 0, len(m.auths))\n\tmodelKey := strings.TrimSpace(model)\n\t// Always use base model name (without thinking suffix) for auth matching.\n\tif modelKey != \"\" {\n\t\tparsed := thinking.ParseSuffix(modelKey)\n\t\tif parsed.ModelName != \"\" {\n\t\t\tmodelKey = strings.TrimSpace(parsed.ModelName)\n\t\t}\n\t}\n\tregistryRef := registry.GetGlobalRegistry()\n\tfor _, candidate := range m.auths {\n\t\tif candidate == nil || candidate.Disabled {\n\t\t\tcontinue\n\t\t}\n\t\tif pinnedAuthID != \"\" && candidate.ID != pinnedAuthID {\n\t\t\tcontinue\n\t\t}\n\t\tproviderKey := strings.TrimSpace(strings.ToLower(candidate.Provider))\n\t\tif providerKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := providerSet[providerKey]; !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif _, used := tried[candidate.ID]; used {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := m.executors[providerKey]; !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif modelKey != \"\" && registryRef != nil && !registryRef.ClientSupportsModel(candidate.ID, modelKey) {\n\t\t\tcontinue\n\t\t}\n\t\tcandidates = append(candidates, candidate)\n\t}\n\tif len(candidates) == 0 {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, \"\", &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tselected, errPick := m.selector.Pick(ctx, \"mixed\", model, opts, candidates)\n\tif errPick != nil {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, \"\", errPick\n\t}\n\tif selected == nil {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, \"\", &Error{Code: \"auth_not_found\", Message: \"selector returned no auth\"}\n\t}\n\tproviderKey := strings.TrimSpace(strings.ToLower(selected.Provider))\n\texecutor, okExecutor := m.executors[providerKey]\n\tif !okExecutor {\n\t\tm.mu.RUnlock()\n\t\treturn nil, nil, \"\", &Error{Code: \"executor_not_found\", Message: \"executor not registered\"}\n\t}\n\tauthCopy := selected.Clone()\n\tm.mu.RUnlock()\n\tif !selected.indexAssigned {\n\t\tm.mu.Lock()\n\t\tif current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {\n\t\t\tcurrent.EnsureIndex()\n\t\t\tauthCopy = current.Clone()\n\t\t}\n\t\tm.mu.Unlock()\n\t}\n\treturn authCopy, executor, providerKey, nil\n}\n\nfunc (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {\n\tif !m.useSchedulerFastPath() {\n\t\treturn m.pickNextMixedLegacy(ctx, providers, model, opts, tried)\n\t}\n\n\teligibleProviders := make([]string, 0, len(providers))\n\tseenProviders := make(map[string]struct{}, len(providers))\n\tfor _, provider := range providers {\n\t\tproviderKey := strings.TrimSpace(strings.ToLower(provider))\n\t\tif providerKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, seen := seenProviders[providerKey]; seen {\n\t\t\tcontinue\n\t\t}\n\t\tif _, okExecutor := m.Executor(providerKey); !okExecutor {\n\t\t\tcontinue\n\t\t}\n\t\tseenProviders[providerKey] = struct{}{}\n\t\teligibleProviders = append(eligibleProviders, providerKey)\n\t}\n\tif len(eligibleProviders) == 0 {\n\t\treturn nil, nil, \"\", &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\n\tselected, providerKey, errPick := m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)\n\tif errPick != nil && model != \"\" && shouldRetrySchedulerPick(errPick) {\n\t\tm.syncScheduler()\n\t\tselected, providerKey, errPick = m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)\n\t}\n\tif errPick != nil {\n\t\treturn nil, nil, \"\", errPick\n\t}\n\tif selected == nil {\n\t\treturn nil, nil, \"\", &Error{Code: \"auth_not_found\", Message: \"selector returned no auth\"}\n\t}\n\texecutor, okExecutor := m.Executor(providerKey)\n\tif !okExecutor {\n\t\treturn nil, nil, \"\", &Error{Code: \"executor_not_found\", Message: \"executor not registered\"}\n\t}\n\tauthCopy := selected.Clone()\n\tif !selected.indexAssigned {\n\t\tm.mu.Lock()\n\t\tif current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {\n\t\t\tcurrent.EnsureIndex()\n\t\t\tauthCopy = current.Clone()\n\t\t}\n\t\tm.mu.Unlock()\n\t}\n\treturn authCopy, executor, providerKey, nil\n}\n\nfunc (m *Manager) persist(ctx context.Context, auth *Auth) error {\n\tif m.store == nil || auth == nil {\n\t\treturn nil\n\t}\n\tif shouldSkipPersist(ctx) {\n\t\treturn nil\n\t}\n\tif auth.Attributes != nil {\n\t\tif v := strings.ToLower(strings.TrimSpace(auth.Attributes[\"runtime_only\"])); v == \"true\" {\n\t\t\treturn nil\n\t\t}\n\t}\n\t// Skip persistence when metadata is absent (e.g., runtime-only auths).\n\tif auth.Metadata == nil {\n\t\treturn nil\n\t}\n\t_, err := m.store.Save(ctx, auth)\n\treturn err\n}\n\n// StartAutoRefresh launches a background loop that evaluates auth freshness\n// every few seconds and triggers refresh operations when required.\n// Only one loop is kept alive; starting a new one cancels the previous run.\nfunc (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) {\n\tif interval <= 0 {\n\t\tinterval = refreshCheckInterval\n\t}\n\tif m.refreshCancel != nil {\n\t\tm.refreshCancel()\n\t\tm.refreshCancel = nil\n\t}\n\tctx, cancel := context.WithCancel(parent)\n\tm.refreshCancel = cancel\n\tgo func() {\n\t\tticker := time.NewTicker(interval)\n\t\tdefer ticker.Stop()\n\t\tm.checkRefreshes(ctx)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tm.checkRefreshes(ctx)\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// StopAutoRefresh cancels the background refresh loop, if running.\nfunc (m *Manager) StopAutoRefresh() {\n\tif m.refreshCancel != nil {\n\t\tm.refreshCancel()\n\t\tm.refreshCancel = nil\n\t}\n}\n\nfunc (m *Manager) checkRefreshes(ctx context.Context) {\n\t// log.Debugf(\"checking refreshes\")\n\tnow := time.Now()\n\tsnapshot := m.snapshotAuths()\n\tfor _, a := range snapshot {\n\t\ttyp, _ := a.AccountInfo()\n\t\tif typ != \"api_key\" {\n\t\t\tif !m.shouldRefresh(a, now) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Debugf(\"checking refresh for %s, %s, %s\", a.Provider, a.ID, typ)\n\n\t\t\tif exec := m.executorFor(a.Provider); exec == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !m.markRefreshPending(a.ID, now) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tgo m.refreshAuthWithLimit(ctx, a.ID)\n\t\t}\n\t}\n}\n\nfunc (m *Manager) refreshAuthWithLimit(ctx context.Context, id string) {\n\tif m.refreshSemaphore == nil {\n\t\tm.refreshAuth(ctx, id)\n\t\treturn\n\t}\n\tselect {\n\tcase m.refreshSemaphore <- struct{}{}:\n\t\tdefer func() { <-m.refreshSemaphore }()\n\tcase <-ctx.Done():\n\t\treturn\n\t}\n\tm.refreshAuth(ctx, id)\n}\n\nfunc (m *Manager) snapshotAuths() []*Auth {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tout := make([]*Auth, 0, len(m.auths))\n\tfor _, a := range m.auths {\n\t\tout = append(out, a.Clone())\n\t}\n\treturn out\n}\n\nfunc (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {\n\tif a == nil || a.Disabled {\n\t\treturn false\n\t}\n\tif !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {\n\t\treturn false\n\t}\n\tif evaluator, ok := a.Runtime.(RefreshEvaluator); ok && evaluator != nil {\n\t\treturn evaluator.ShouldRefresh(now, a)\n\t}\n\n\tlastRefresh := a.LastRefreshedAt\n\tif lastRefresh.IsZero() {\n\t\tif ts, ok := authLastRefreshTimestamp(a); ok {\n\t\t\tlastRefresh = ts\n\t\t}\n\t}\n\n\texpiry, hasExpiry := a.ExpirationTime()\n\n\tif interval := authPreferredInterval(a); interval > 0 {\n\t\tif hasExpiry && !expiry.IsZero() {\n\t\t\tif !expiry.After(now) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif expiry.Sub(now) <= interval {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\tif lastRefresh.IsZero() {\n\t\t\treturn true\n\t\t}\n\t\treturn now.Sub(lastRefresh) >= interval\n\t}\n\n\tprovider := strings.ToLower(a.Provider)\n\tlead := ProviderRefreshLead(provider, a.Runtime)\n\tif lead == nil {\n\t\treturn false\n\t}\n\tif *lead <= 0 {\n\t\tif hasExpiry && !expiry.IsZero() {\n\t\t\treturn now.After(expiry)\n\t\t}\n\t\treturn false\n\t}\n\tif hasExpiry && !expiry.IsZero() {\n\t\treturn time.Until(expiry) <= *lead\n\t}\n\tif !lastRefresh.IsZero() {\n\t\treturn now.Sub(lastRefresh) >= *lead\n\t}\n\treturn true\n}\n\nfunc authPreferredInterval(a *Auth) time.Duration {\n\tif a == nil {\n\t\treturn 0\n\t}\n\tif d := durationFromMetadata(a.Metadata, \"refresh_interval_seconds\", \"refreshIntervalSeconds\", \"refresh_interval\", \"refreshInterval\"); d > 0 {\n\t\treturn d\n\t}\n\tif d := durationFromAttributes(a.Attributes, \"refresh_interval_seconds\", \"refreshIntervalSeconds\", \"refresh_interval\", \"refreshInterval\"); d > 0 {\n\t\treturn d\n\t}\n\treturn 0\n}\n\nfunc durationFromMetadata(meta map[string]any, keys ...string) time.Duration {\n\tif len(meta) == 0 {\n\t\treturn 0\n\t}\n\tfor _, key := range keys {\n\t\tif val, ok := meta[key]; ok {\n\t\t\tif dur := parseDurationValue(val); dur > 0 {\n\t\t\t\treturn dur\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc durationFromAttributes(attrs map[string]string, keys ...string) time.Duration {\n\tif len(attrs) == 0 {\n\t\treturn 0\n\t}\n\tfor _, key := range keys {\n\t\tif val, ok := attrs[key]; ok {\n\t\t\tif dur := parseDurationString(val); dur > 0 {\n\t\t\t\treturn dur\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc parseDurationValue(val any) time.Duration {\n\tswitch v := val.(type) {\n\tcase time.Duration:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn v\n\tcase int:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase int32:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase int64:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase uint:\n\t\tif v == 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase uint32:\n\t\tif v == 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase uint64:\n\t\tif v == 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v) * time.Second\n\tcase float32:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(float64(v) * float64(time.Second))\n\tcase float64:\n\t\tif v <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn time.Duration(v * float64(time.Second))\n\tcase json.Number:\n\t\tif i, err := v.Int64(); err == nil {\n\t\t\tif i <= 0 {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t\treturn time.Duration(i) * time.Second\n\t\t}\n\t\tif f, err := v.Float64(); err == nil && f > 0 {\n\t\t\treturn time.Duration(f * float64(time.Second))\n\t\t}\n\tcase string:\n\t\treturn parseDurationString(v)\n\t}\n\treturn 0\n}\n\nfunc parseDurationString(raw string) time.Duration {\n\ts := strings.TrimSpace(raw)\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\tif dur, err := time.ParseDuration(s); err == nil && dur > 0 {\n\t\treturn dur\n\t}\n\tif secs, err := strconv.ParseFloat(s, 64); err == nil && secs > 0 {\n\t\treturn time.Duration(secs * float64(time.Second))\n\t}\n\treturn 0\n}\n\nfunc authLastRefreshTimestamp(a *Auth) (time.Time, bool) {\n\tif a == nil {\n\t\treturn time.Time{}, false\n\t}\n\tif a.Metadata != nil {\n\t\tif ts, ok := lookupMetadataTime(a.Metadata, \"last_refresh\", \"lastRefresh\", \"last_refreshed_at\", \"lastRefreshedAt\"); ok {\n\t\t\treturn ts, true\n\t\t}\n\t}\n\tif a.Attributes != nil {\n\t\tfor _, key := range []string{\"last_refresh\", \"lastRefresh\", \"last_refreshed_at\", \"lastRefreshedAt\"} {\n\t\t\tif val := strings.TrimSpace(a.Attributes[key]); val != \"\" {\n\t\t\t\tif ts, ok := parseTimeValue(val); ok {\n\t\t\t\t\treturn ts, true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc lookupMetadataTime(meta map[string]any, keys ...string) (time.Time, bool) {\n\tfor _, key := range keys {\n\t\tif val, ok := meta[key]; ok {\n\t\t\tif ts, ok1 := parseTimeValue(val); ok1 {\n\t\t\t\treturn ts, true\n\t\t\t}\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc (m *Manager) markRefreshPending(id string, now time.Time) bool {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tauth, ok := m.auths[id]\n\tif !ok || auth == nil || auth.Disabled {\n\t\treturn false\n\t}\n\tif !auth.NextRefreshAfter.IsZero() && now.Before(auth.NextRefreshAfter) {\n\t\treturn false\n\t}\n\tauth.NextRefreshAfter = now.Add(refreshPendingBackoff)\n\tm.auths[id] = auth\n\treturn true\n}\n\nfunc (m *Manager) refreshAuth(ctx context.Context, id string) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tm.mu.RLock()\n\tauth := m.auths[id]\n\tvar exec ProviderExecutor\n\tif auth != nil {\n\t\texec = m.executors[auth.Provider]\n\t}\n\tm.mu.RUnlock()\n\tif auth == nil || exec == nil {\n\t\treturn\n\t}\n\tcloned := auth.Clone()\n\tupdated, err := exec.Refresh(ctx, cloned)\n\tif err != nil && errors.Is(err, context.Canceled) {\n\t\tlog.Debugf(\"refresh canceled for %s, %s\", auth.Provider, auth.ID)\n\t\treturn\n\t}\n\tlog.Debugf(\"refreshed %s, %s, %v\", auth.Provider, auth.ID, err)\n\tnow := time.Now()\n\tif err != nil {\n\t\tm.mu.Lock()\n\t\tif current := m.auths[id]; current != nil {\n\t\t\tcurrent.NextRefreshAfter = now.Add(refreshFailureBackoff)\n\t\t\tcurrent.LastError = &Error{Message: err.Error()}\n\t\t\tm.auths[id] = current\n\t\t\tif m.scheduler != nil {\n\t\t\t\tm.scheduler.upsertAuth(current.Clone())\n\t\t\t}\n\t\t}\n\t\tm.mu.Unlock()\n\t\treturn\n\t}\n\tif updated == nil {\n\t\tupdated = cloned\n\t}\n\t// Preserve runtime created by the executor during Refresh.\n\t// If executor didn't set one, fall back to the previous runtime.\n\tif updated.Runtime == nil {\n\t\tupdated.Runtime = auth.Runtime\n\t}\n\tupdated.LastRefreshedAt = now\n\tupdated.NextRefreshAfter = time.Time{}\n\tupdated.LastError = nil\n\tupdated.UpdatedAt = now\n\t_, _ = m.Update(ctx, updated)\n}\n\nfunc (m *Manager) executorFor(provider string) ProviderExecutor {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\treturn m.executors[provider]\n}\n\n// roundTripperContextKey is an unexported context key type to avoid collisions.\ntype roundTripperContextKey struct{}\n\n// roundTripperFor retrieves an HTTP RoundTripper for the given auth if a provider is registered.\nfunc (m *Manager) roundTripperFor(auth *Auth) http.RoundTripper {\n\tm.mu.RLock()\n\tp := m.rtProvider\n\tm.mu.RUnlock()\n\tif p == nil || auth == nil {\n\t\treturn nil\n\t}\n\treturn p.RoundTripperFor(auth)\n}\n\n// RoundTripperProvider defines a minimal provider of per-auth HTTP transports.\ntype RoundTripperProvider interface {\n\tRoundTripperFor(auth *Auth) http.RoundTripper\n}\n\n// RequestPreparer is an optional interface that provider executors can implement\n// to mutate outbound HTTP requests with provider credentials.\ntype RequestPreparer interface {\n\tPrepareRequest(req *http.Request, auth *Auth) error\n}\n\nfunc executorKeyFromAuth(auth *Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tif auth.Attributes != nil {\n\t\tproviderKey := strings.TrimSpace(auth.Attributes[\"provider_key\"])\n\t\tcompatName := strings.TrimSpace(auth.Attributes[\"compat_name\"])\n\t\tif compatName != \"\" {\n\t\t\tif providerKey == \"\" {\n\t\t\t\tproviderKey = compatName\n\t\t\t}\n\t\t\treturn strings.ToLower(providerKey)\n\t\t}\n\t}\n\treturn strings.ToLower(strings.TrimSpace(auth.Provider))\n}\n\n// logEntryWithRequestID returns a logrus entry with request_id field if available in context.\nfunc logEntryWithRequestID(ctx context.Context) *log.Entry {\n\tif ctx == nil {\n\t\treturn log.NewEntry(log.StandardLogger())\n\t}\n\tif reqID := logging.GetRequestID(ctx); reqID != \"\" {\n\t\treturn log.WithField(\"request_id\", reqID)\n\t}\n\treturn log.NewEntry(log.StandardLogger())\n}\n\nfunc debugLogAuthSelection(entry *log.Entry, auth *Auth, provider string, model string) {\n\tif !log.IsLevelEnabled(log.DebugLevel) {\n\t\treturn\n\t}\n\tif entry == nil || auth == nil {\n\t\treturn\n\t}\n\taccountType, accountInfo := auth.AccountInfo()\n\tproxyInfo := auth.ProxyInfo()\n\tsuffix := \"\"\n\tif proxyInfo != \"\" {\n\t\tsuffix = \" \" + proxyInfo\n\t}\n\tswitch accountType {\n\tcase \"api_key\":\n\t\tentry.Debugf(\"Use API key %s for model %s%s\", util.HideAPIKey(accountInfo), model, suffix)\n\tcase \"oauth\":\n\t\tident := formatOauthIdentity(auth, provider, accountInfo)\n\t\tentry.Debugf(\"Use OAuth %s for model %s%s\", ident, model, suffix)\n\t}\n}\n\nfunc formatOauthIdentity(auth *Auth, provider string, accountInfo string) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\t// Prefer the auth's provider when available.\n\tproviderName := strings.TrimSpace(auth.Provider)\n\tif providerName == \"\" {\n\t\tproviderName = strings.TrimSpace(provider)\n\t}\n\t// Only log the basename to avoid leaking host paths.\n\t// FileName may be unset for some auth backends; fall back to ID.\n\tauthFile := strings.TrimSpace(auth.FileName)\n\tif authFile == \"\" {\n\t\tauthFile = strings.TrimSpace(auth.ID)\n\t}\n\tif authFile != \"\" {\n\t\tauthFile = filepath.Base(authFile)\n\t}\n\tparts := make([]string, 0, 3)\n\tif providerName != \"\" {\n\t\tparts = append(parts, \"provider=\"+providerName)\n\t}\n\tif authFile != \"\" {\n\t\tparts = append(parts, \"auth_file=\"+authFile)\n\t}\n\tif len(parts) == 0 {\n\t\treturn accountInfo\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\n// InjectCredentials delegates per-provider HTTP request preparation when supported.\n// If the registered executor for the auth provider implements RequestPreparer,\n// it will be invoked to modify the request (e.g., add headers).\nfunc (m *Manager) InjectCredentials(req *http.Request, authID string) error {\n\tif req == nil || authID == \"\" {\n\t\treturn nil\n\t}\n\tm.mu.RLock()\n\ta := m.auths[authID]\n\tvar exec ProviderExecutor\n\tif a != nil {\n\t\texec = m.executors[executorKeyFromAuth(a)]\n\t}\n\tm.mu.RUnlock()\n\tif a == nil || exec == nil {\n\t\treturn nil\n\t}\n\tif p, ok := exec.(RequestPreparer); ok && p != nil {\n\t\treturn p.PrepareRequest(req, a)\n\t}\n\treturn nil\n}\n\n// PrepareHttpRequest injects provider credentials into the supplied HTTP request.\nfunc (m *Manager) PrepareHttpRequest(ctx context.Context, auth *Auth, req *http.Request) error {\n\tif m == nil {\n\t\treturn &Error{Code: \"provider_not_found\", Message: \"manager is nil\"}\n\t}\n\tif auth == nil {\n\t\treturn &Error{Code: \"auth_not_found\", Message: \"auth is nil\"}\n\t}\n\tif req == nil {\n\t\treturn &Error{Code: \"invalid_request\", Message: \"http request is nil\"}\n\t}\n\tif ctx != nil {\n\t\t*req = *req.WithContext(ctx)\n\t}\n\tproviderKey := executorKeyFromAuth(auth)\n\tif providerKey == \"\" {\n\t\treturn &Error{Code: \"provider_not_found\", Message: \"auth provider is empty\"}\n\t}\n\texec := m.executorFor(providerKey)\n\tif exec == nil {\n\t\treturn &Error{Code: \"provider_not_found\", Message: \"executor not registered for provider: \" + providerKey}\n\t}\n\tpreparer, ok := exec.(RequestPreparer)\n\tif !ok || preparer == nil {\n\t\treturn &Error{Code: \"not_supported\", Message: \"executor does not support http request preparation\"}\n\t}\n\treturn preparer.PrepareRequest(req, auth)\n}\n\n// NewHttpRequest constructs a new HTTP request and injects provider credentials into it.\nfunc (m *Manager) NewHttpRequest(ctx context.Context, auth *Auth, method, targetURL string, body []byte, headers http.Header) (*http.Request, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tmethod = strings.TrimSpace(method)\n\tif method == \"\" {\n\t\tmethod = http.MethodGet\n\t}\n\tvar reader io.Reader\n\tif body != nil {\n\t\treader = bytes.NewReader(body)\n\t}\n\thttpReq, err := http.NewRequestWithContext(ctx, method, targetURL, reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif headers != nil {\n\t\thttpReq.Header = headers.Clone()\n\t}\n\tif errPrepare := m.PrepareHttpRequest(ctx, auth, httpReq); errPrepare != nil {\n\t\treturn nil, errPrepare\n\t}\n\treturn httpReq, nil\n}\n\n// HttpRequest injects provider credentials into the supplied HTTP request and executes it.\nfunc (m *Manager) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {\n\tif m == nil {\n\t\treturn nil, &Error{Code: \"provider_not_found\", Message: \"manager is nil\"}\n\t}\n\tif auth == nil {\n\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"auth is nil\"}\n\t}\n\tif req == nil {\n\t\treturn nil, &Error{Code: \"invalid_request\", Message: \"http request is nil\"}\n\t}\n\tproviderKey := executorKeyFromAuth(auth)\n\tif providerKey == \"\" {\n\t\treturn nil, &Error{Code: \"provider_not_found\", Message: \"auth provider is empty\"}\n\t}\n\texec := m.executorFor(providerKey)\n\tif exec == nil {\n\t\treturn nil, &Error{Code: \"provider_not_found\", Message: \"executor not registered for provider: \" + providerKey}\n\t}\n\treturn exec.HttpRequest(ctx, auth, req)\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor_availability_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestUpdateAggregatedAvailability_UnavailableWithoutNextRetryDoesNotBlockAuth(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now()\n\tmodel := \"test-model\"\n\tauth := &Auth{\n\t\tID: \"a\",\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tStatus:      StatusError,\n\t\t\t\tUnavailable: true,\n\t\t\t},\n\t\t},\n\t}\n\n\tupdateAggregatedAvailability(auth, now)\n\n\tif auth.Unavailable {\n\t\tt.Fatalf(\"auth.Unavailable = true, want false\")\n\t}\n\tif !auth.NextRetryAfter.IsZero() {\n\t\tt.Fatalf(\"auth.NextRetryAfter = %v, want zero\", auth.NextRetryAfter)\n\t}\n}\n\nfunc TestUpdateAggregatedAvailability_FutureNextRetryBlocksAuth(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now()\n\tmodel := \"test-model\"\n\tnext := now.Add(5 * time.Minute)\n\tauth := &Auth{\n\t\tID: \"a\",\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tStatus:         StatusError,\n\t\t\t\tUnavailable:    true,\n\t\t\t\tNextRetryAfter: next,\n\t\t\t},\n\t\t},\n\t}\n\n\tupdateAggregatedAvailability(auth, now)\n\n\tif !auth.Unavailable {\n\t\tt.Fatalf(\"auth.Unavailable = false, want true\")\n\t}\n\tif auth.NextRetryAfter.IsZero() {\n\t\tt.Fatalf(\"auth.NextRetryAfter = zero, want %v\", next)\n\t}\n\tif auth.NextRetryAfter.Sub(next) > time.Second || next.Sub(auth.NextRetryAfter) > time.Second {\n\t\tt.Fatalf(\"auth.NextRetryAfter = %v, want %v\", auth.NextRetryAfter, next)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor_executor_replace_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\ntype replaceAwareExecutor struct {\n\tid string\n\n\tmu               sync.Mutex\n\tclosedSessionIDs []string\n}\n\nfunc (e *replaceAwareExecutor) Identifier() string {\n\treturn e.id\n}\n\nfunc (e *replaceAwareExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e *replaceAwareExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\tch := make(chan cliproxyexecutor.StreamChunk)\n\tclose(ch)\n\treturn &cliproxyexecutor.StreamResult{Chunks: ch}, nil\n}\n\nfunc (e *replaceAwareExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *replaceAwareExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e *replaceAwareExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {\n\treturn nil, nil\n}\n\nfunc (e *replaceAwareExecutor) CloseExecutionSession(sessionID string) {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\te.closedSessionIDs = append(e.closedSessionIDs, sessionID)\n}\n\nfunc (e *replaceAwareExecutor) ClosedSessionIDs() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tout := make([]string, len(e.closedSessionIDs))\n\tcopy(out, e.closedSessionIDs)\n\treturn out\n}\n\nfunc TestManagerRegisterExecutorClosesReplacedExecutionSessions(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, nil, nil)\n\treplaced := &replaceAwareExecutor{id: \"codex\"}\n\tcurrent := &replaceAwareExecutor{id: \"codex\"}\n\n\tmanager.RegisterExecutor(replaced)\n\tmanager.RegisterExecutor(current)\n\n\tclosed := replaced.ClosedSessionIDs()\n\tif len(closed) != 1 {\n\t\tt.Fatalf(\"expected replaced executor close calls = 1, got %d\", len(closed))\n\t}\n\tif closed[0] != CloseAllExecutionSessionsID {\n\t\tt.Fatalf(\"expected close marker %q, got %q\", CloseAllExecutionSessionsID, closed[0])\n\t}\n\tif len(current.ClosedSessionIDs()) != 0 {\n\t\tt.Fatalf(\"expected current executor to stay open\")\n\t}\n}\n\nfunc TestManagerExecutorReturnsRegisteredExecutor(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, nil, nil)\n\tcurrent := &replaceAwareExecutor{id: \"codex\"}\n\tmanager.RegisterExecutor(current)\n\n\tresolved, okResolved := manager.Executor(\"CODEX\")\n\tif !okResolved {\n\t\tt.Fatal(\"expected registered executor to be found\")\n\t}\n\tresolvedExecutor, okResolvedExecutor := resolved.(*replaceAwareExecutor)\n\tif !okResolvedExecutor {\n\t\tt.Fatalf(\"expected resolved executor type %T, got %T\", current, resolved)\n\t}\n\tif resolvedExecutor != current {\n\t\tt.Fatal(\"expected resolved executor to match registered executor\")\n\t}\n\n\t_, okMissing := manager.Executor(\"unknown\")\n\tif okMissing {\n\t\tt.Fatal(\"expected unknown provider lookup to fail\")\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor_overrides_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\nfunc TestManager_ShouldRetryAfterError_RespectsAuthRequestRetryOverride(t *testing.T) {\n\tm := NewManager(nil, nil, nil)\n\tm.SetRetryConfig(3, 30*time.Second, 0)\n\n\tmodel := \"test-model\"\n\tnext := time.Now().Add(5 * time.Second)\n\n\tauth := &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"claude\",\n\t\tMetadata: map[string]any{\n\t\t\t\"request_retry\": float64(0),\n\t\t},\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tUnavailable:    true,\n\t\t\t\tStatus:         StatusError,\n\t\t\t\tNextRetryAfter: next,\n\t\t\t},\n\t\t},\n\t}\n\tif _, errRegister := m.Register(context.Background(), auth); errRegister != nil {\n\t\tt.Fatalf(\"register auth: %v\", errRegister)\n\t}\n\n\t_, _, maxWait := m.retrySettings()\n\twait, shouldRetry := m.shouldRetryAfterError(&Error{HTTPStatus: 500, Message: \"boom\"}, 0, []string{\"claude\"}, model, maxWait)\n\tif shouldRetry {\n\t\tt.Fatalf(\"expected shouldRetry=false for request_retry=0, got true (wait=%v)\", wait)\n\t}\n\n\tauth.Metadata[\"request_retry\"] = float64(1)\n\tif _, errUpdate := m.Update(context.Background(), auth); errUpdate != nil {\n\t\tt.Fatalf(\"update auth: %v\", errUpdate)\n\t}\n\n\twait, shouldRetry = m.shouldRetryAfterError(&Error{HTTPStatus: 500, Message: \"boom\"}, 0, []string{\"claude\"}, model, maxWait)\n\tif !shouldRetry {\n\t\tt.Fatalf(\"expected shouldRetry=true for request_retry=1, got false\")\n\t}\n\tif wait <= 0 {\n\t\tt.Fatalf(\"expected wait > 0, got %v\", wait)\n\t}\n\n\t_, shouldRetry = m.shouldRetryAfterError(&Error{HTTPStatus: 500, Message: \"boom\"}, 1, []string{\"claude\"}, model, maxWait)\n\tif shouldRetry {\n\t\tt.Fatalf(\"expected shouldRetry=false on attempt=1 for request_retry=1, got true\")\n\t}\n}\n\ntype credentialRetryLimitExecutor struct {\n\tid string\n\n\tmu    sync.Mutex\n\tcalls int\n}\n\nfunc (e *credentialRetryLimitExecutor) Identifier() string {\n\treturn e.id\n}\n\nfunc (e *credentialRetryLimitExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\te.recordCall()\n\treturn cliproxyexecutor.Response{}, &Error{HTTPStatus: 500, Message: \"boom\"}\n}\n\nfunc (e *credentialRetryLimitExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\te.recordCall()\n\treturn nil, &Error{HTTPStatus: 500, Message: \"boom\"}\n}\n\nfunc (e *credentialRetryLimitExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *credentialRetryLimitExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\te.recordCall()\n\treturn cliproxyexecutor.Response{}, &Error{HTTPStatus: 500, Message: \"boom\"}\n}\n\nfunc (e *credentialRetryLimitExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {\n\treturn nil, nil\n}\n\nfunc (e *credentialRetryLimitExecutor) recordCall() {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\te.calls++\n}\n\nfunc (e *credentialRetryLimitExecutor) Calls() int {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn e.calls\n}\n\nfunc newCredentialRetryLimitTestManager(t *testing.T, maxRetryCredentials int) (*Manager, *credentialRetryLimitExecutor) {\n\tt.Helper()\n\n\tm := NewManager(nil, nil, nil)\n\tm.SetRetryConfig(0, 0, maxRetryCredentials)\n\n\texecutor := &credentialRetryLimitExecutor{id: \"claude\"}\n\tm.RegisterExecutor(executor)\n\n\tbaseID := uuid.NewString()\n\tauth1 := &Auth{ID: baseID + \"-auth-1\", Provider: \"claude\"}\n\tauth2 := &Auth{ID: baseID + \"-auth-2\", Provider: \"claude\"}\n\n\t// Auth selection requires that the global model registry knows each credential supports the model.\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(auth1.ID, \"claude\", []*registry.ModelInfo{{ID: \"test-model\"}})\n\treg.RegisterClient(auth2.ID, \"claude\", []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\treg.UnregisterClient(auth1.ID)\n\t\treg.UnregisterClient(auth2.ID)\n\t})\n\n\tif _, errRegister := m.Register(context.Background(), auth1); errRegister != nil {\n\t\tt.Fatalf(\"register auth1: %v\", errRegister)\n\t}\n\tif _, errRegister := m.Register(context.Background(), auth2); errRegister != nil {\n\t\tt.Fatalf(\"register auth2: %v\", errRegister)\n\t}\n\n\treturn m, executor\n}\n\nfunc TestManager_MaxRetryCredentials_LimitsCrossCredentialRetries(t *testing.T) {\n\trequest := cliproxyexecutor.Request{Model: \"test-model\"}\n\ttestCases := []struct {\n\t\tname   string\n\t\tinvoke func(*Manager) error\n\t}{\n\t\t{\n\t\t\tname: \"execute\",\n\t\t\tinvoke: func(m *Manager) error {\n\t\t\t\t_, errExecute := m.Execute(context.Background(), []string{\"claude\"}, request, cliproxyexecutor.Options{})\n\t\t\t\treturn errExecute\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"execute_count\",\n\t\t\tinvoke: func(m *Manager) error {\n\t\t\t\t_, errExecute := m.ExecuteCount(context.Background(), []string{\"claude\"}, request, cliproxyexecutor.Options{})\n\t\t\t\treturn errExecute\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"execute_stream\",\n\t\t\tinvoke: func(m *Manager) error {\n\t\t\t\t_, errExecute := m.ExecuteStream(context.Background(), []string{\"claude\"}, request, cliproxyexecutor.Options{})\n\t\t\t\treturn errExecute\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tlimitedManager, limitedExecutor := newCredentialRetryLimitTestManager(t, 1)\n\t\t\tif errInvoke := tc.invoke(limitedManager); errInvoke == nil {\n\t\t\t\tt.Fatalf(\"expected error for limited retry execution\")\n\t\t\t}\n\t\t\tif calls := limitedExecutor.Calls(); calls != 1 {\n\t\t\t\tt.Fatalf(\"expected 1 call with max-retry-credentials=1, got %d\", calls)\n\t\t\t}\n\n\t\t\tunlimitedManager, unlimitedExecutor := newCredentialRetryLimitTestManager(t, 0)\n\t\t\tif errInvoke := tc.invoke(unlimitedManager); errInvoke == nil {\n\t\t\t\tt.Fatalf(\"expected error for unlimited retry execution\")\n\t\t\t}\n\t\t\tif calls := unlimitedExecutor.Calls(); calls != 2 {\n\t\t\t\tt.Fatalf(\"expected 2 calls with max-retry-credentials=0, got %d\", calls)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestManager_MarkResult_RespectsAuthDisableCoolingOverride(t *testing.T) {\n\tprev := quotaCooldownDisabled.Load()\n\tquotaCooldownDisabled.Store(false)\n\tt.Cleanup(func() { quotaCooldownDisabled.Store(prev) })\n\n\tm := NewManager(nil, nil, nil)\n\n\tauth := &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"claude\",\n\t\tMetadata: map[string]any{\n\t\t\t\"disable_cooling\": true,\n\t\t},\n\t}\n\tif _, errRegister := m.Register(context.Background(), auth); errRegister != nil {\n\t\tt.Fatalf(\"register auth: %v\", errRegister)\n\t}\n\n\tmodel := \"test-model\"\n\tm.MarkResult(context.Background(), Result{\n\t\tAuthID:   \"auth-1\",\n\t\tProvider: \"claude\",\n\t\tModel:    model,\n\t\tSuccess:  false,\n\t\tError:    &Error{HTTPStatus: 500, Message: \"boom\"},\n\t})\n\n\tupdated, ok := m.GetByID(\"auth-1\")\n\tif !ok || updated == nil {\n\t\tt.Fatalf(\"expected auth to be present\")\n\t}\n\tstate := updated.ModelStates[model]\n\tif state == nil {\n\t\tt.Fatalf(\"expected model state to be present\")\n\t}\n\tif !state.NextRetryAfter.IsZero() {\n\t\tt.Fatalf(\"expected NextRetryAfter to be zero when disable_cooling=true, got %v\", state.NextRetryAfter)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor_scheduler_refresh_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\ntype schedulerProviderTestExecutor struct {\n\tprovider string\n}\n\nfunc (e schedulerProviderTestExecutor) Identifier() string { return e.provider }\n\nfunc (e schedulerProviderTestExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e schedulerProviderTestExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\treturn nil, nil\n}\n\nfunc (e schedulerProviderTestExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e schedulerProviderTestExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e schedulerProviderTestExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, nil\n}\n\nfunc TestManager_RefreshSchedulerEntry_RebuildsSupportedModelSetAfterModelRegistration(t *testing.T) {\n\tctx := context.Background()\n\n\ttestCases := []struct {\n\t\tname  string\n\t\tprime func(*Manager, *Auth) error\n\t}{\n\t\t{\n\t\t\tname: \"register\",\n\t\t\tprime: func(manager *Manager, auth *Auth) error {\n\t\t\t\t_, errRegister := manager.Register(ctx, auth)\n\t\t\t\treturn errRegister\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"update\",\n\t\t\tprime: func(manager *Manager, auth *Auth) error {\n\t\t\t\t_, errRegister := manager.Register(ctx, auth)\n\t\t\t\tif errRegister != nil {\n\t\t\t\t\treturn errRegister\n\t\t\t\t}\n\t\t\t\tupdated := auth.Clone()\n\t\t\t\tupdated.Metadata = map[string]any{\"updated\": true}\n\t\t\t\t_, errUpdate := manager.Update(ctx, updated)\n\t\t\t\treturn errUpdate\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, testCase := range testCases {\n\t\ttestCase := testCase\n\t\tt.Run(testCase.name, func(t *testing.T) {\n\t\t\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\t\t\tauth := &Auth{\n\t\t\t\tID:       \"refresh-entry-\" + testCase.name,\n\t\t\t\tProvider: \"gemini\",\n\t\t\t}\n\t\t\tif errPrime := testCase.prime(manager, auth); errPrime != nil {\n\t\t\t\tt.Fatalf(\"prime auth %s: %v\", testCase.name, errPrime)\n\t\t\t}\n\n\t\t\tregisterSchedulerModels(t, \"gemini\", \"scheduler-refresh-model\", auth.ID)\n\n\t\t\tgot, errPick := manager.scheduler.pickSingle(ctx, \"gemini\", \"scheduler-refresh-model\", cliproxyexecutor.Options{}, nil)\n\t\t\tvar authErr *Error\n\t\t\tif !errors.As(errPick, &authErr) || authErr == nil {\n\t\t\t\tt.Fatalf(\"pickSingle() before refresh error = %v, want auth_not_found\", errPick)\n\t\t\t}\n\t\t\tif authErr.Code != \"auth_not_found\" {\n\t\t\t\tt.Fatalf(\"pickSingle() before refresh code = %q, want %q\", authErr.Code, \"auth_not_found\")\n\t\t\t}\n\t\t\tif got != nil {\n\t\t\t\tt.Fatalf(\"pickSingle() before refresh auth = %v, want nil\", got)\n\t\t\t}\n\n\t\t\tmanager.RefreshSchedulerEntry(auth.ID)\n\n\t\t\tgot, errPick = manager.scheduler.pickSingle(ctx, \"gemini\", \"scheduler-refresh-model\", cliproxyexecutor.Options{}, nil)\n\t\t\tif errPick != nil {\n\t\t\t\tt.Fatalf(\"pickSingle() after refresh error = %v\", errPick)\n\t\t\t}\n\t\t\tif got == nil || got.ID != auth.ID {\n\t\t\t\tt.Fatalf(\"pickSingle() after refresh auth = %v, want %q\", got, auth.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestManager_PickNext_RebuildsSchedulerAfterModelCooldownError(t *testing.T) {\n\tctx := context.Background()\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tmanager.RegisterExecutor(schedulerProviderTestExecutor{provider: \"gemini\"})\n\n\tregisterSchedulerModels(t, \"gemini\", \"scheduler-cooldown-rebuild-model\", \"cooldown-stale-old\")\n\n\toldAuth := &Auth{\n\t\tID:       \"cooldown-stale-old\",\n\t\tProvider: \"gemini\",\n\t}\n\tif _, errRegister := manager.Register(ctx, oldAuth); errRegister != nil {\n\t\tt.Fatalf(\"register old auth: %v\", errRegister)\n\t}\n\n\tmanager.MarkResult(ctx, Result{\n\t\tAuthID:   oldAuth.ID,\n\t\tProvider: \"gemini\",\n\t\tModel:    \"scheduler-cooldown-rebuild-model\",\n\t\tSuccess:  false,\n\t\tError:    &Error{HTTPStatus: http.StatusTooManyRequests, Message: \"quota\"},\n\t})\n\n\tnewAuth := &Auth{\n\t\tID:       \"cooldown-stale-new\",\n\t\tProvider: \"gemini\",\n\t}\n\tif _, errRegister := manager.Register(ctx, newAuth); errRegister != nil {\n\t\tt.Fatalf(\"register new auth: %v\", errRegister)\n\t}\n\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(newAuth.ID, \"gemini\", []*registry.ModelInfo{{ID: \"scheduler-cooldown-rebuild-model\"}})\n\tt.Cleanup(func() {\n\t\treg.UnregisterClient(newAuth.ID)\n\t})\n\n\tgot, errPick := manager.scheduler.pickSingle(ctx, \"gemini\", \"scheduler-cooldown-rebuild-model\", cliproxyexecutor.Options{}, nil)\n\tvar cooldownErr *modelCooldownError\n\tif !errors.As(errPick, &cooldownErr) {\n\t\tt.Fatalf(\"pickSingle() before sync error = %v, want modelCooldownError\", errPick)\n\t}\n\tif got != nil {\n\t\tt.Fatalf(\"pickSingle() before sync auth = %v, want nil\", got)\n\t}\n\n\tgot, executor, errPick := manager.pickNext(ctx, \"gemini\", \"scheduler-cooldown-rebuild-model\", cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"pickNext() error = %v\", errPick)\n\t}\n\tif executor == nil {\n\t\tt.Fatal(\"pickNext() executor = nil\")\n\t}\n\tif got == nil || got.ID != newAuth.ID {\n\t\tt.Fatalf(\"pickNext() auth = %v, want %q\", got, newAuth.ID)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/conductor_update_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"testing\"\n)\n\nfunc TestManager_Update_PreservesModelStates(t *testing.T) {\n\tm := NewManager(nil, nil, nil)\n\n\tmodel := \"test-model\"\n\tbackoffLevel := 7\n\n\tif _, errRegister := m.Register(context.Background(), &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"claude\",\n\t\tMetadata: map[string]any{\"k\": \"v\"},\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tQuota: QuotaState{BackoffLevel: backoffLevel},\n\t\t\t},\n\t\t},\n\t}); errRegister != nil {\n\t\tt.Fatalf(\"register auth: %v\", errRegister)\n\t}\n\n\tif _, errUpdate := m.Update(context.Background(), &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"claude\",\n\t\tMetadata: map[string]any{\"k\": \"v2\"},\n\t}); errUpdate != nil {\n\t\tt.Fatalf(\"update auth: %v\", errUpdate)\n\t}\n\n\tupdated, ok := m.GetByID(\"auth-1\")\n\tif !ok || updated == nil {\n\t\tt.Fatalf(\"expected auth to be present\")\n\t}\n\tif len(updated.ModelStates) == 0 {\n\t\tt.Fatalf(\"expected ModelStates to be preserved\")\n\t}\n\tstate := updated.ModelStates[model]\n\tif state == nil {\n\t\tt.Fatalf(\"expected model state to be present\")\n\t}\n\tif state.Quota.BackoffLevel != backoffLevel {\n\t\tt.Fatalf(\"expected BackoffLevel to be %d, got %d\", backoffLevel, state.Quota.BackoffLevel)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/errors.go",
    "content": "package auth\n\n// Error describes an authentication related failure in a provider agnostic format.\ntype Error struct {\n\t// Code is a short machine readable identifier.\n\tCode string `json:\"code,omitempty\"`\n\t// Message is a human readable description of the failure.\n\tMessage string `json:\"message\"`\n\t// Retryable indicates whether a retry might fix the issue automatically.\n\tRetryable bool `json:\"retryable\"`\n\t// HTTPStatus optionally records an HTTP-like status code for the error.\n\tHTTPStatus int `json:\"http_status,omitempty\"`\n}\n\n// Error implements the error interface.\nfunc (e *Error) Error() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.Code == \"\" {\n\t\treturn e.Message\n\t}\n\treturn e.Code + \": \" + e.Message\n}\n\n// StatusCode implements optional status accessor for manager decision making.\nfunc (e *Error) StatusCode() int {\n\tif e == nil {\n\t\treturn 0\n\t}\n\treturn e.HTTPStatus\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/oauth_model_alias.go",
    "content": "package auth\n\nimport (\n\t\"strings\"\n\n\tinternalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n)\n\ntype modelAliasEntry interface {\n\tGetName() string\n\tGetAlias() string\n}\n\ntype oauthModelAliasTable struct {\n\t// reverse maps channel -> alias (lower) -> original upstream model name.\n\treverse map[string]map[string]string\n}\n\nfunc compileOAuthModelAliasTable(aliases map[string][]internalconfig.OAuthModelAlias) *oauthModelAliasTable {\n\tif len(aliases) == 0 {\n\t\treturn &oauthModelAliasTable{}\n\t}\n\tout := &oauthModelAliasTable{\n\t\treverse: make(map[string]map[string]string, len(aliases)),\n\t}\n\tfor rawChannel, entries := range aliases {\n\t\tchannel := strings.ToLower(strings.TrimSpace(rawChannel))\n\t\tif channel == \"\" || len(entries) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\trev := make(map[string]string, len(entries))\n\t\tfor _, entry := range entries {\n\t\t\tname := strings.TrimSpace(entry.Name)\n\t\t\talias := strings.TrimSpace(entry.Alias)\n\t\t\tif name == \"\" || alias == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.EqualFold(name, alias) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\taliasKey := strings.ToLower(alias)\n\t\t\tif _, exists := rev[aliasKey]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trev[aliasKey] = name\n\t\t}\n\t\tif len(rev) > 0 {\n\t\t\tout.reverse[channel] = rev\n\t\t}\n\t}\n\tif len(out.reverse) == 0 {\n\t\tout.reverse = nil\n\t}\n\treturn out\n}\n\n// SetOAuthModelAlias updates the OAuth model name alias table used during execution.\n// The alias is applied per-auth channel to resolve the upstream model name while keeping the\n// client-visible model name unchanged for translation/response formatting.\nfunc (m *Manager) SetOAuthModelAlias(aliases map[string][]internalconfig.OAuthModelAlias) {\n\tif m == nil {\n\t\treturn\n\t}\n\ttable := compileOAuthModelAliasTable(aliases)\n\t// atomic.Value requires non-nil store values.\n\tif table == nil {\n\t\ttable = &oauthModelAliasTable{}\n\t}\n\tm.oauthModelAlias.Store(table)\n}\n\n// applyOAuthModelAlias resolves the upstream model from OAuth model alias.\n// If an alias exists, the returned model is the upstream model.\nfunc (m *Manager) applyOAuthModelAlias(auth *Auth, requestedModel string) string {\n\tupstreamModel := m.resolveOAuthUpstreamModel(auth, requestedModel)\n\tif upstreamModel == \"\" {\n\t\treturn requestedModel\n\t}\n\treturn upstreamModel\n}\n\nfunc modelAliasLookupCandidates(requestedModel string) (thinking.SuffixResult, []string) {\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn thinking.SuffixResult{}, nil\n\t}\n\trequestResult := thinking.ParseSuffix(requestedModel)\n\tbase := requestResult.ModelName\n\tif base == \"\" {\n\t\tbase = requestedModel\n\t}\n\tcandidates := []string{base}\n\tif base != requestedModel {\n\t\tcandidates = append(candidates, requestedModel)\n\t}\n\treturn requestResult, candidates\n}\n\nfunc preserveResolvedModelSuffix(resolved string, requestResult thinking.SuffixResult) string {\n\tresolved = strings.TrimSpace(resolved)\n\tif resolved == \"\" {\n\t\treturn \"\"\n\t}\n\tif thinking.ParseSuffix(resolved).HasSuffix {\n\t\treturn resolved\n\t}\n\tif requestResult.HasSuffix && requestResult.RawSuffix != \"\" {\n\t\treturn resolved + \"(\" + requestResult.RawSuffix + \")\"\n\t}\n\treturn resolved\n}\n\nfunc resolveModelAliasPoolFromConfigModels(requestedModel string, models []modelAliasEntry) []string {\n\trequestedModel = strings.TrimSpace(requestedModel)\n\tif requestedModel == \"\" {\n\t\treturn nil\n\t}\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\n\trequestResult, candidates := modelAliasLookupCandidates(requestedModel)\n\tif len(candidates) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]string, 0)\n\tseen := make(map[string]struct{})\n\tfor i := range models {\n\t\tname := strings.TrimSpace(models[i].GetName())\n\t\talias := strings.TrimSpace(models[i].GetAlias())\n\t\tfor _, candidate := range candidates {\n\t\t\tif candidate == \"\" || alias == \"\" || !strings.EqualFold(alias, candidate) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresolved := candidate\n\t\t\tif name != \"\" {\n\t\t\t\tresolved = name\n\t\t\t}\n\t\t\tresolved = preserveResolvedModelSuffix(resolved, requestResult)\n\t\t\tkey := strings.ToLower(strings.TrimSpace(resolved))\n\t\t\tif key == \"\" {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif _, exists := seen[key]; exists {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tseen[key] = struct{}{}\n\t\t\tout = append(out, resolved)\n\t\t\tbreak\n\t\t}\n\t}\n\tif len(out) > 0 {\n\t\treturn out\n\t}\n\n\tfor i := range models {\n\t\tname := strings.TrimSpace(models[i].GetName())\n\t\tfor _, candidate := range candidates {\n\t\t\tif candidate == \"\" || name == \"\" || !strings.EqualFold(name, candidate) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn []string{preserveResolvedModelSuffix(name, requestResult)}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc resolveModelAliasFromConfigModels(requestedModel string, models []modelAliasEntry) string {\n\tresolved := resolveModelAliasPoolFromConfigModels(requestedModel, models)\n\tif len(resolved) > 0 {\n\t\treturn resolved[0]\n\t}\n\treturn \"\"\n}\n\n// resolveOAuthUpstreamModel resolves the upstream model name from OAuth model alias.\n// If an alias exists, returns the original (upstream) model name that corresponds\n// to the requested alias.\n//\n// If the requested model contains a thinking suffix (e.g., \"gemini-2.5-pro(8192)\"),\n// the suffix is preserved in the returned model name. However, if the alias's\n// original name already contains a suffix, the config suffix takes priority.\nfunc (m *Manager) resolveOAuthUpstreamModel(auth *Auth, requestedModel string) string {\n\treturn resolveUpstreamModelFromAliasTable(m, auth, requestedModel, modelAliasChannel(auth))\n}\n\nfunc resolveUpstreamModelFromAliasTable(m *Manager, auth *Auth, requestedModel, channel string) string {\n\tif m == nil || auth == nil {\n\t\treturn \"\"\n\t}\n\tif channel == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Extract thinking suffix from requested model using ParseSuffix\n\trequestResult := thinking.ParseSuffix(requestedModel)\n\tbaseModel := requestResult.ModelName\n\n\t// Candidate keys to match: base model and raw input (handles suffix-parsing edge cases).\n\tcandidates := []string{baseModel}\n\tif baseModel != requestedModel {\n\t\tcandidates = append(candidates, requestedModel)\n\t}\n\n\traw := m.oauthModelAlias.Load()\n\ttable, _ := raw.(*oauthModelAliasTable)\n\tif table == nil || table.reverse == nil {\n\t\treturn \"\"\n\t}\n\trev := table.reverse[channel]\n\tif rev == nil {\n\t\treturn \"\"\n\t}\n\n\tfor _, candidate := range candidates {\n\t\tkey := strings.ToLower(strings.TrimSpace(candidate))\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\toriginal := strings.TrimSpace(rev[key])\n\t\tif original == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.EqualFold(original, baseModel) {\n\t\t\treturn \"\"\n\t\t}\n\n\t\t// If config already has suffix, it takes priority.\n\t\tif thinking.ParseSuffix(original).HasSuffix {\n\t\t\treturn original\n\t\t}\n\t\t// Preserve user's thinking suffix on the resolved model.\n\t\tif requestResult.HasSuffix && requestResult.RawSuffix != \"\" {\n\t\t\treturn original + \"(\" + requestResult.RawSuffix + \")\"\n\t\t}\n\t\treturn original\n\t}\n\n\treturn \"\"\n}\n\n// modelAliasChannel extracts the OAuth model alias channel from an Auth object.\n// It determines the provider and auth kind from the Auth's attributes and delegates\n// to OAuthModelAliasChannel for the actual channel resolution.\nfunc modelAliasChannel(auth *Auth) string {\n\tif auth == nil {\n\t\treturn \"\"\n\t}\n\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\tauthKind := \"\"\n\tif auth.Attributes != nil {\n\t\tauthKind = strings.ToLower(strings.TrimSpace(auth.Attributes[\"auth_kind\"]))\n\t}\n\tif authKind == \"\" {\n\t\tif kind, _ := auth.AccountInfo(); strings.EqualFold(kind, \"api_key\") {\n\t\t\tauthKind = \"apikey\"\n\t\t}\n\t}\n\treturn OAuthModelAliasChannel(provider, authKind)\n}\n\n// OAuthModelAliasChannel returns the OAuth model alias channel name for a given provider\n// and auth kind. Returns empty string if the provider/authKind combination doesn't support\n// OAuth model alias (e.g., API key authentication).\n//\n// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.\nfunc OAuthModelAliasChannel(provider, authKind string) string {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tauthKind = strings.ToLower(strings.TrimSpace(authKind))\n\tswitch provider {\n\tcase \"gemini\":\n\t\t// gemini provider uses gemini-api-key config, not oauth-model-alias.\n\t\t// OAuth-based gemini auth is converted to \"gemini-cli\" by the synthesizer.\n\t\treturn \"\"\n\tcase \"vertex\":\n\t\tif authKind == \"apikey\" {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn \"vertex\"\n\tcase \"claude\":\n\t\tif authKind == \"apikey\" {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn \"claude\"\n\tcase \"codex\":\n\t\tif authKind == \"apikey\" {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn \"codex\"\n\tcase \"gemini-cli\", \"aistudio\", \"antigravity\", \"qwen\", \"iflow\", \"kimi\":\n\t\treturn provider\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/oauth_model_alias_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\n\tinternalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\tname    string\n\t\taliases map[string][]internalconfig.OAuthModelAlias\n\t\tchannel string\n\t\tinput   string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname: \"numeric suffix preserved\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro(8192)\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25(8192)\",\n\t\t},\n\t\t{\n\t\t\tname: \"level suffix preserved\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"claude\": {{Name: \"claude-sonnet-4-5-20250514\", Alias: \"claude-sonnet-4-5\"}},\n\t\t\t},\n\t\t\tchannel: \"claude\",\n\t\t\tinput:   \"claude-sonnet-4-5(high)\",\n\t\t\twant:    \"claude-sonnet-4-5-20250514(high)\",\n\t\t},\n\t\t{\n\t\t\tname: \"no suffix unchanged\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25\",\n\t\t},\n\t\t{\n\t\t\tname: \"config suffix takes priority\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"claude\": {{Name: \"claude-sonnet-4-5-20250514(low)\", Alias: \"claude-sonnet-4-5\"}},\n\t\t\t},\n\t\t\tchannel: \"claude\",\n\t\t\tinput:   \"claude-sonnet-4-5(high)\",\n\t\t\twant:    \"claude-sonnet-4-5-20250514(low)\",\n\t\t},\n\t\t{\n\t\t\tname: \"auto suffix preserved\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro(auto)\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25(auto)\",\n\t\t},\n\t\t{\n\t\t\tname: \"none suffix preserved\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro(none)\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25(none)\",\n\t\t},\n\t\t{\n\t\t\tname: \"kimi suffix preserved\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"kimi\": {{Name: \"kimi-k2.5\", Alias: \"k2.5\"}},\n\t\t\t},\n\t\t\tchannel: \"kimi\",\n\t\t\tinput:   \"k2.5(high)\",\n\t\t\twant:    \"kimi-k2.5(high)\",\n\t\t},\n\t\t{\n\t\t\tname: \"case insensitive alias lookup with suffix\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"Gemini-2.5-Pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro(high)\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25(high)\",\n\t\t},\n\t\t{\n\t\t\tname: \"no alias returns empty\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"unknown-model(high)\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"wrong channel returns empty\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"claude\",\n\t\t\tinput:   \"gemini-2.5-pro(high)\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty suffix filtered out\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro()\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25\",\n\t\t},\n\t\t{\n\t\t\tname: \"incomplete suffix treated as no suffix\",\n\t\t\taliases: map[string][]internalconfig.OAuthModelAlias{\n\t\t\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro(high\"}},\n\t\t\t},\n\t\t\tchannel: \"gemini-cli\",\n\t\t\tinput:   \"gemini-2.5-pro(high\",\n\t\t\twant:    \"gemini-2.5-pro-exp-03-25\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tmgr := NewManager(nil, nil, nil)\n\t\t\tmgr.SetConfig(&internalconfig.Config{})\n\t\t\tmgr.SetOAuthModelAlias(tt.aliases)\n\n\t\t\tauth := createAuthForChannel(tt.channel)\n\t\t\tgot := mgr.resolveOAuthUpstreamModel(auth, tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"resolveOAuthUpstreamModel(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc createAuthForChannel(channel string) *Auth {\n\tswitch channel {\n\tcase \"gemini-cli\":\n\t\treturn &Auth{Provider: \"gemini-cli\"}\n\tcase \"claude\":\n\t\treturn &Auth{Provider: \"claude\", Attributes: map[string]string{\"auth_kind\": \"oauth\"}}\n\tcase \"vertex\":\n\t\treturn &Auth{Provider: \"vertex\", Attributes: map[string]string{\"auth_kind\": \"oauth\"}}\n\tcase \"codex\":\n\t\treturn &Auth{Provider: \"codex\", Attributes: map[string]string{\"auth_kind\": \"oauth\"}}\n\tcase \"aistudio\":\n\t\treturn &Auth{Provider: \"aistudio\"}\n\tcase \"antigravity\":\n\t\treturn &Auth{Provider: \"antigravity\"}\n\tcase \"qwen\":\n\t\treturn &Auth{Provider: \"qwen\"}\n\tcase \"iflow\":\n\t\treturn &Auth{Provider: \"iflow\"}\n\tcase \"kimi\":\n\t\treturn &Auth{Provider: \"kimi\"}\n\tdefault:\n\t\treturn &Auth{Provider: channel}\n\t}\n}\n\nfunc TestOAuthModelAliasChannel_Kimi(t *testing.T) {\n\tt.Parallel()\n\n\tif got := OAuthModelAliasChannel(\"kimi\", \"oauth\"); got != \"kimi\" {\n\t\tt.Fatalf(\"OAuthModelAliasChannel() = %q, want %q\", got, \"kimi\")\n\t}\n}\n\nfunc TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {\n\tt.Parallel()\n\n\taliases := map[string][]internalconfig.OAuthModelAlias{\n\t\t\"gemini-cli\": {{Name: \"gemini-2.5-pro-exp-03-25\", Alias: \"gemini-2.5-pro\"}},\n\t}\n\n\tmgr := NewManager(nil, nil, nil)\n\tmgr.SetConfig(&internalconfig.Config{})\n\tmgr.SetOAuthModelAlias(aliases)\n\n\tauth := &Auth{ID: \"test-auth-id\", Provider: \"gemini-cli\"}\n\n\tresolvedModel := mgr.applyOAuthModelAlias(auth, \"gemini-2.5-pro(8192)\")\n\tif resolvedModel != \"gemini-2.5-pro-exp-03-25(8192)\" {\n\t\tt.Errorf(\"applyOAuthModelAlias() model = %q, want %q\", resolvedModel, \"gemini-2.5-pro-exp-03-25(8192)\")\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/openai_compat_pool_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\n\tinternalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\ntype openAICompatPoolExecutor struct {\n\tid string\n\n\tmu                sync.Mutex\n\texecuteModels     []string\n\tcountModels       []string\n\tstreamModels      []string\n\texecuteErrors     map[string]error\n\tcountErrors       map[string]error\n\tstreamFirstErrors map[string]error\n\tstreamPayloads    map[string][]cliproxyexecutor.StreamChunk\n}\n\nfunc (e *openAICompatPoolExecutor) Identifier() string { return e.id }\n\nfunc (e *openAICompatPoolExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\t_ = ctx\n\t_ = auth\n\t_ = opts\n\te.mu.Lock()\n\te.executeModels = append(e.executeModels, req.Model)\n\terr := e.executeErrors[req.Model]\n\te.mu.Unlock()\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\treturn cliproxyexecutor.Response{Payload: []byte(req.Model)}, nil\n}\n\nfunc (e *openAICompatPoolExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\t_ = ctx\n\t_ = auth\n\t_ = opts\n\te.mu.Lock()\n\te.streamModels = append(e.streamModels, req.Model)\n\terr := e.streamFirstErrors[req.Model]\n\tpayloadChunks, hasCustomChunks := e.streamPayloads[req.Model]\n\tchunks := append([]cliproxyexecutor.StreamChunk(nil), payloadChunks...)\n\te.mu.Unlock()\n\tch := make(chan cliproxyexecutor.StreamChunk, max(1, len(chunks)))\n\tif err != nil {\n\t\tch <- cliproxyexecutor.StreamChunk{Err: err}\n\t\tclose(ch)\n\t\treturn &cliproxyexecutor.StreamResult{Headers: http.Header{\"X-Model\": {req.Model}}, Chunks: ch}, nil\n\t}\n\tif !hasCustomChunks {\n\t\tch <- cliproxyexecutor.StreamChunk{Payload: []byte(req.Model)}\n\t} else {\n\t\tfor _, chunk := range chunks {\n\t\t\tch <- chunk\n\t\t}\n\t}\n\tclose(ch)\n\treturn &cliproxyexecutor.StreamResult{Headers: http.Header{\"X-Model\": {req.Model}}, Chunks: ch}, nil\n}\n\nfunc (e *openAICompatPoolExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e *openAICompatPoolExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\t_ = ctx\n\t_ = auth\n\t_ = opts\n\te.mu.Lock()\n\te.countModels = append(e.countModels, req.Model)\n\terr := e.countErrors[req.Model]\n\te.mu.Unlock()\n\tif err != nil {\n\t\treturn cliproxyexecutor.Response{}, err\n\t}\n\treturn cliproxyexecutor.Response{Payload: []byte(req.Model)}, nil\n}\n\nfunc (e *openAICompatPoolExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {\n\t_ = ctx\n\t_ = auth\n\t_ = req\n\treturn nil, &Error{HTTPStatus: http.StatusNotImplemented, Message: \"HttpRequest not implemented\"}\n}\n\nfunc (e *openAICompatPoolExecutor) ExecuteModels() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tout := make([]string, len(e.executeModels))\n\tcopy(out, e.executeModels)\n\treturn out\n}\n\nfunc (e *openAICompatPoolExecutor) CountModels() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tout := make([]string, len(e.countModels))\n\tcopy(out, e.countModels)\n\treturn out\n}\n\nfunc (e *openAICompatPoolExecutor) StreamModels() []string {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tout := make([]string, len(e.streamModels))\n\tcopy(out, e.streamModels)\n\treturn out\n}\n\nfunc newOpenAICompatPoolTestManager(t *testing.T, alias string, models []internalconfig.OpenAICompatibilityModel, executor *openAICompatPoolExecutor) *Manager {\n\tt.Helper()\n\tcfg := &internalconfig.Config{\n\t\tOpenAICompatibility: []internalconfig.OpenAICompatibility{{\n\t\t\tName:   \"pool\",\n\t\t\tModels: models,\n\t\t}},\n\t}\n\tm := NewManager(nil, nil, nil)\n\tm.SetConfig(cfg)\n\tif executor == nil {\n\t\texecutor = &openAICompatPoolExecutor{id: \"pool\"}\n\t}\n\tm.RegisterExecutor(executor)\n\n\tauth := &Auth{\n\t\tID:       \"pool-auth-\" + t.Name(),\n\t\tProvider: \"pool\",\n\t\tStatus:   StatusActive,\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\":      \"test-key\",\n\t\t\t\"compat_name\":  \"pool\",\n\t\t\t\"provider_key\": \"pool\",\n\t\t},\n\t}\n\tif _, err := m.Register(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"register auth: %v\", err)\n\t}\n\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(auth.ID, \"pool\", []*registry.ModelInfo{{ID: alias}})\n\tt.Cleanup(func() {\n\t\treg.UnregisterClient(auth.ID)\n\t})\n\treturn m\n}\n\nfunc TestManagerExecuteCount_OpenAICompatAliasPoolStopsOnInvalidRequest(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\tinvalidErr := &Error{HTTPStatus: http.StatusUnprocessableEntity, Message: \"unprocessable entity\"}\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:          \"pool\",\n\t\tcountErrors: map[string]error{\"qwen3.5-plus\": invalidErr},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\t_, err := m.ExecuteCount(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err == nil || err.Error() != invalidErr.Error() {\n\t\tt.Fatalf(\"execute count error = %v, want %v\", err, invalidErr)\n\t}\n\tgot := executor.CountModels()\n\tif len(got) != 1 || got[0] != \"qwen3.5-plus\" {\n\t\tt.Fatalf(\"count calls = %v, want only first invalid model\", got)\n\t}\n}\nfunc TestResolveModelAliasPoolFromConfigModels(t *testing.T) {\n\tmodels := []modelAliasEntry{\n\t\tinternalconfig.OpenAICompatibilityModel{Name: \"qwen3.5-plus\", Alias: \"claude-opus-4.66\"},\n\t\tinternalconfig.OpenAICompatibilityModel{Name: \"glm-5\", Alias: \"claude-opus-4.66\"},\n\t\tinternalconfig.OpenAICompatibilityModel{Name: \"kimi-k2.5\", Alias: \"claude-opus-4.66\"},\n\t}\n\tgot := resolveModelAliasPoolFromConfigModels(\"claude-opus-4.66(8192)\", models)\n\twant := []string{\"qwen3.5-plus(8192)\", \"glm-5(8192)\", \"kimi-k2.5(8192)\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"pool len = %d, want %d (%v)\", len(got), len(want), got)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"pool[%d] = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestManagerExecute_OpenAICompatAliasPoolRotatesWithinAuth(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\texecutor := &openAICompatPoolExecutor{id: \"pool\"}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tfor i := 0; i < 3; i++ {\n\t\tresp, err := m.Execute(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"execute %d: %v\", i, err)\n\t\t}\n\t\tif len(resp.Payload) == 0 {\n\t\t\tt.Fatalf(\"execute %d returned empty payload\", i)\n\t\t}\n\t}\n\n\tgot := executor.ExecuteModels()\n\twant := []string{\"qwen3.5-plus\", \"glm-5\", \"qwen3.5-plus\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"execute calls = %v, want %v\", got, want)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"execute call %d model = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestManagerExecute_OpenAICompatAliasPoolStopsOnBadRequest(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\tinvalidErr := &Error{HTTPStatus: http.StatusBadRequest, Message: \"invalid_request_error: malformed payload\"}\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:            \"pool\",\n\t\texecuteErrors: map[string]error{\"qwen3.5-plus\": invalidErr},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\t_, err := m.Execute(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err == nil || err.Error() != invalidErr.Error() {\n\t\tt.Fatalf(\"execute error = %v, want %v\", err, invalidErr)\n\t}\n\tgot := executor.ExecuteModels()\n\tif len(got) != 1 || got[0] != \"qwen3.5-plus\" {\n\t\tt.Fatalf(\"execute calls = %v, want only first invalid model\", got)\n\t}\n}\nfunc TestManagerExecute_OpenAICompatAliasPoolFallsBackWithinSameAuth(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:            \"pool\",\n\t\texecuteErrors: map[string]error{\"qwen3.5-plus\": &Error{HTTPStatus: http.StatusTooManyRequests, Message: \"quota\"}},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tresp, err := m.Execute(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"execute: %v\", err)\n\t}\n\tif string(resp.Payload) != \"glm-5\" {\n\t\tt.Fatalf(\"payload = %q, want %q\", string(resp.Payload), \"glm-5\")\n\t}\n\tgot := executor.ExecuteModels()\n\twant := []string{\"qwen3.5-plus\", \"glm-5\"}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"execute call %d model = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestManagerExecuteStream_OpenAICompatAliasPoolRetriesOnEmptyBootstrap(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\texecutor := &openAICompatPoolExecutor{\n\t\tid: \"pool\",\n\t\tstreamPayloads: map[string][]cliproxyexecutor.StreamChunk{\n\t\t\t\"qwen3.5-plus\": {},\n\t\t},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tstreamResult, err := m.ExecuteStream(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"execute stream: %v\", err)\n\t}\n\tvar payload []byte\n\tfor chunk := range streamResult.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tpayload = append(payload, chunk.Payload...)\n\t}\n\tif string(payload) != \"glm-5\" {\n\t\tt.Fatalf(\"payload = %q, want %q\", string(payload), \"glm-5\")\n\t}\n\tgot := executor.StreamModels()\n\twant := []string{\"qwen3.5-plus\", \"glm-5\"}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"stream call %d model = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestManagerExecuteStream_OpenAICompatAliasPoolFallsBackBeforeFirstByte(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:                \"pool\",\n\t\tstreamFirstErrors: map[string]error{\"qwen3.5-plus\": &Error{HTTPStatus: http.StatusTooManyRequests, Message: \"quota\"}},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tstreamResult, err := m.ExecuteStream(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"execute stream: %v\", err)\n\t}\n\tvar payload []byte\n\tfor chunk := range streamResult.Chunks {\n\t\tif chunk.Err != nil {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tpayload = append(payload, chunk.Payload...)\n\t}\n\tif string(payload) != \"glm-5\" {\n\t\tt.Fatalf(\"payload = %q, want %q\", string(payload), \"glm-5\")\n\t}\n\tgot := executor.StreamModels()\n\twant := []string{\"qwen3.5-plus\", \"glm-5\"}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"stream call %d model = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n\tif gotHeader := streamResult.Headers.Get(\"X-Model\"); gotHeader != \"glm-5\" {\n\t\tt.Fatalf(\"header X-Model = %q, want %q\", gotHeader, \"glm-5\")\n\t}\n}\n\nfunc TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidRequest(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\tinvalidErr := &Error{HTTPStatus: http.StatusUnprocessableEntity, Message: \"unprocessable entity\"}\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:                \"pool\",\n\t\tstreamFirstErrors: map[string]error{\"qwen3.5-plus\": invalidErr},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\t_, err := m.ExecuteStream(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err == nil || err.Error() != invalidErr.Error() {\n\t\tt.Fatalf(\"execute stream error = %v, want %v\", err, invalidErr)\n\t}\n\tgot := executor.StreamModels()\n\tif len(got) != 1 || got[0] != \"qwen3.5-plus\" {\n\t\tt.Fatalf(\"stream calls = %v, want only first invalid model\", got)\n\t}\n}\nfunc TestManagerExecuteCount_OpenAICompatAliasPoolRotatesWithinAuth(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\texecutor := &openAICompatPoolExecutor{id: \"pool\"}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tfor i := 0; i < 2; i++ {\n\t\tresp, err := m.ExecuteCount(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"execute count %d: %v\", i, err)\n\t\t}\n\t\tif len(resp.Payload) == 0 {\n\t\t\tt.Fatalf(\"execute count %d returned empty payload\", i)\n\t\t}\n\t}\n\n\tgot := executor.CountModels()\n\twant := []string{\"qwen3.5-plus\", \"glm-5\"}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"count call %d model = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidBootstrap(t *testing.T) {\n\talias := \"claude-opus-4.66\"\n\tinvalidErr := &Error{HTTPStatus: http.StatusBadRequest, Message: \"invalid_request_error: malformed payload\"}\n\texecutor := &openAICompatPoolExecutor{\n\t\tid:                \"pool\",\n\t\tstreamFirstErrors: map[string]error{\"qwen3.5-plus\": invalidErr},\n\t}\n\tm := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{\n\t\t{Name: \"qwen3.5-plus\", Alias: alias},\n\t\t{Name: \"glm-5\", Alias: alias},\n\t}, executor)\n\n\tstreamResult, err := m.ExecuteStream(context.Background(), []string{\"pool\"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{})\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid request error\")\n\t}\n\tif err != invalidErr {\n\t\tt.Fatalf(\"error = %v, want %v\", err, invalidErr)\n\t}\n\tif streamResult != nil {\n\t\tt.Fatalf(\"streamResult = %#v, want nil on invalid bootstrap\", streamResult)\n\t}\n\tif got := executor.StreamModels(); len(got) != 1 || got[0] != \"qwen3.5-plus\" {\n\t\tt.Fatalf(\"stream calls = %v, want only first upstream model\", got)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/persist_policy.go",
    "content": "package auth\n\nimport \"context\"\n\ntype skipPersistContextKey struct{}\n\n// WithSkipPersist returns a derived context that disables persistence for Manager Update/Register calls.\n// It is intended for code paths that are reacting to file watcher events, where the file on disk is\n// already the source of truth and persisting again would create a write-back loop.\nfunc WithSkipPersist(ctx context.Context) context.Context {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\treturn context.WithValue(ctx, skipPersistContextKey{}, true)\n}\n\nfunc shouldSkipPersist(ctx context.Context) bool {\n\tif ctx == nil {\n\t\treturn false\n\t}\n\tv := ctx.Value(skipPersistContextKey{})\n\tenabled, ok := v.(bool)\n\treturn ok && enabled\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/persist_policy_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"sync/atomic\"\n\t\"testing\"\n)\n\ntype countingStore struct {\n\tsaveCount atomic.Int32\n}\n\nfunc (s *countingStore) List(context.Context) ([]*Auth, error) { return nil, nil }\n\nfunc (s *countingStore) Save(context.Context, *Auth) (string, error) {\n\ts.saveCount.Add(1)\n\treturn \"\", nil\n}\n\nfunc (s *countingStore) Delete(context.Context, string) error { return nil }\n\nfunc TestWithSkipPersist_DisablesUpdatePersistence(t *testing.T) {\n\tstore := &countingStore{}\n\tmgr := NewManager(store, nil, nil)\n\tauth := &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"antigravity\",\n\t\tMetadata: map[string]any{\"type\": \"antigravity\"},\n\t}\n\n\tif _, err := mgr.Update(context.Background(), auth); err != nil {\n\t\tt.Fatalf(\"Update returned error: %v\", err)\n\t}\n\tif got := store.saveCount.Load(); got != 1 {\n\t\tt.Fatalf(\"expected 1 Save call, got %d\", got)\n\t}\n\n\tctxSkip := WithSkipPersist(context.Background())\n\tif _, err := mgr.Update(ctxSkip, auth); err != nil {\n\t\tt.Fatalf(\"Update(skipPersist) returned error: %v\", err)\n\t}\n\tif got := store.saveCount.Load(); got != 1 {\n\t\tt.Fatalf(\"expected Save call count to remain 1, got %d\", got)\n\t}\n}\n\nfunc TestWithSkipPersist_DisablesRegisterPersistence(t *testing.T) {\n\tstore := &countingStore{}\n\tmgr := NewManager(store, nil, nil)\n\tauth := &Auth{\n\t\tID:       \"auth-1\",\n\t\tProvider: \"antigravity\",\n\t\tMetadata: map[string]any{\"type\": \"antigravity\"},\n\t}\n\n\tif _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil {\n\t\tt.Fatalf(\"Register(skipPersist) returned error: %v\", err)\n\t}\n\tif got := store.saveCount.Load(); got != 0 {\n\t\tt.Fatalf(\"expected 0 Save calls, got %d\", got)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/scheduler.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\n// schedulerStrategy identifies which built-in routing semantics the scheduler should apply.\ntype schedulerStrategy int\n\nconst (\n\tschedulerStrategyCustom schedulerStrategy = iota\n\tschedulerStrategyRoundRobin\n\tschedulerStrategyFillFirst\n)\n\n// scheduledState describes how an auth currently participates in a model shard.\ntype scheduledState int\n\nconst (\n\tscheduledStateReady scheduledState = iota\n\tscheduledStateCooldown\n\tscheduledStateBlocked\n\tscheduledStateDisabled\n)\n\n// authScheduler keeps the incremental provider/model scheduling state used by Manager.\ntype authScheduler struct {\n\tmu            sync.Mutex\n\tstrategy      schedulerStrategy\n\tproviders     map[string]*providerScheduler\n\tauthProviders map[string]string\n\tmixedCursors  map[string]int\n}\n\n// providerScheduler stores auth metadata and model shards for a single provider.\ntype providerScheduler struct {\n\tproviderKey string\n\tauths       map[string]*scheduledAuthMeta\n\tmodelShards map[string]*modelScheduler\n}\n\n// scheduledAuthMeta stores the immutable scheduling fields derived from an auth snapshot.\ntype scheduledAuthMeta struct {\n\tauth              *Auth\n\tproviderKey       string\n\tpriority          int\n\tvirtualParent     string\n\twebsocketEnabled  bool\n\tsupportedModelSet map[string]struct{}\n}\n\n// modelScheduler tracks ready and blocked auths for one provider/model combination.\ntype modelScheduler struct {\n\tmodelKey        string\n\tentries         map[string]*scheduledAuth\n\tpriorityOrder   []int\n\treadyByPriority map[int]*readyBucket\n\tblocked         cooldownQueue\n}\n\n// scheduledAuth stores the runtime scheduling state for a single auth inside a model shard.\ntype scheduledAuth struct {\n\tmeta        *scheduledAuthMeta\n\tauth        *Auth\n\tstate       scheduledState\n\tnextRetryAt time.Time\n}\n\n// readyBucket keeps the ready views for one priority level.\ntype readyBucket struct {\n\tall readyView\n\tws  readyView\n}\n\n// readyView holds the selection order for flat or grouped round-robin traversal.\ntype readyView struct {\n\tflat         []*scheduledAuth\n\tcursor       int\n\tparentOrder  []string\n\tparentCursor int\n\tchildren     map[string]*childBucket\n}\n\n// childBucket keeps the per-parent rotation state for grouped Gemini virtual auths.\ntype childBucket struct {\n\titems  []*scheduledAuth\n\tcursor int\n}\n\n// cooldownQueue is the blocked auth collection ordered by next retry time during rebuilds.\ntype cooldownQueue []*scheduledAuth\n\n// newAuthScheduler constructs an empty scheduler configured for the supplied selector strategy.\nfunc newAuthScheduler(selector Selector) *authScheduler {\n\treturn &authScheduler{\n\t\tstrategy:      selectorStrategy(selector),\n\t\tproviders:     make(map[string]*providerScheduler),\n\t\tauthProviders: make(map[string]string),\n\t\tmixedCursors:  make(map[string]int),\n\t}\n}\n\n// selectorStrategy maps a selector implementation to the scheduler semantics it should emulate.\nfunc selectorStrategy(selector Selector) schedulerStrategy {\n\tswitch selector.(type) {\n\tcase *FillFirstSelector:\n\t\treturn schedulerStrategyFillFirst\n\tcase nil, *RoundRobinSelector:\n\t\treturn schedulerStrategyRoundRobin\n\tdefault:\n\t\treturn schedulerStrategyCustom\n\t}\n}\n\n// setSelector updates the active built-in strategy and resets mixed-provider cursors.\nfunc (s *authScheduler) setSelector(selector Selector) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.strategy = selectorStrategy(selector)\n\tclear(s.mixedCursors)\n}\n\n// rebuild recreates the complete scheduler state from an auth snapshot.\nfunc (s *authScheduler) rebuild(auths []*Auth) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.providers = make(map[string]*providerScheduler)\n\ts.authProviders = make(map[string]string)\n\ts.mixedCursors = make(map[string]int)\n\tnow := time.Now()\n\tfor _, auth := range auths {\n\t\ts.upsertAuthLocked(auth, now)\n\t}\n}\n\n// upsertAuth incrementally synchronizes one auth into the scheduler.\nfunc (s *authScheduler) upsertAuth(auth *Auth) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.upsertAuthLocked(auth, time.Now())\n}\n\n// removeAuth deletes one auth from every scheduler shard that references it.\nfunc (s *authScheduler) removeAuth(authID string) {\n\tif s == nil {\n\t\treturn\n\t}\n\tauthID = strings.TrimSpace(authID)\n\tif authID == \"\" {\n\t\treturn\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.removeAuthLocked(authID)\n}\n\n// pickSingle returns the next auth for a single provider/model request using scheduler state.\nfunc (s *authScheduler) pickSingle(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, error) {\n\tif s == nil {\n\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tproviderKey := strings.ToLower(strings.TrimSpace(provider))\n\tmodelKey := canonicalModelKey(model)\n\tpinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)\n\tpreferWebsocket := cliproxyexecutor.DownstreamWebsocket(ctx) && providerKey == \"codex\" && pinnedAuthID == \"\"\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tproviderState := s.providers[providerKey]\n\tif providerState == nil {\n\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tshard := providerState.ensureModelLocked(modelKey, time.Now())\n\tif shard == nil {\n\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tpredicate := func(entry *scheduledAuth) bool {\n\t\tif entry == nil || entry.auth == nil {\n\t\t\treturn false\n\t\t}\n\t\tif pinnedAuthID != \"\" && entry.auth.ID != pinnedAuthID {\n\t\t\treturn false\n\t\t}\n\t\tif len(tried) > 0 {\n\t\t\tif _, ok := tried[entry.auth.ID]; ok {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\tif picked := shard.pickReadyLocked(preferWebsocket, s.strategy, predicate); picked != nil {\n\t\treturn picked, nil\n\t}\n\treturn nil, shard.unavailableErrorLocked(provider, model, predicate)\n}\n\n// pickMixed returns the next auth and provider for a mixed-provider request.\nfunc (s *authScheduler) pickMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, string, error) {\n\tif s == nil {\n\t\treturn nil, \"\", &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tnormalized := normalizeProviderKeys(providers)\n\tif len(normalized) == 0 {\n\t\treturn nil, \"\", &Error{Code: \"provider_not_found\", Message: \"no provider supplied\"}\n\t}\n\tpinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)\n\tmodelKey := canonicalModelKey(model)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif pinnedAuthID != \"\" {\n\t\tproviderKey := s.authProviders[pinnedAuthID]\n\t\tif providerKey == \"\" || !containsProvider(normalized, providerKey) {\n\t\t\treturn nil, \"\", &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t\t}\n\t\tproviderState := s.providers[providerKey]\n\t\tif providerState == nil {\n\t\t\treturn nil, \"\", &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t\t}\n\t\tshard := providerState.ensureModelLocked(modelKey, time.Now())\n\t\tpredicate := func(entry *scheduledAuth) bool {\n\t\t\tif entry == nil || entry.auth == nil || entry.auth.ID != pinnedAuthID {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif len(tried) == 0 {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t_, ok := tried[pinnedAuthID]\n\t\t\treturn !ok\n\t\t}\n\t\tif picked := shard.pickReadyLocked(false, s.strategy, predicate); picked != nil {\n\t\t\treturn picked, providerKey, nil\n\t\t}\n\t\treturn nil, \"\", shard.unavailableErrorLocked(\"mixed\", model, predicate)\n\t}\n\n\tpredicate := triedPredicate(tried)\n\tcandidateShards := make([]*modelScheduler, len(normalized))\n\tbestPriority := 0\n\thasCandidate := false\n\tnow := time.Now()\n\tfor providerIndex, providerKey := range normalized {\n\t\tproviderState := s.providers[providerKey]\n\t\tif providerState == nil {\n\t\t\tcontinue\n\t\t}\n\t\tshard := providerState.ensureModelLocked(modelKey, now)\n\t\tcandidateShards[providerIndex] = shard\n\t\tif shard == nil {\n\t\t\tcontinue\n\t\t}\n\t\tpriorityReady, okPriority := shard.highestReadyPriorityLocked(false, predicate)\n\t\tif !okPriority {\n\t\t\tcontinue\n\t\t}\n\t\tif !hasCandidate || priorityReady > bestPriority {\n\t\t\tbestPriority = priorityReady\n\t\t\thasCandidate = true\n\t\t}\n\t}\n\tif !hasCandidate {\n\t\treturn nil, \"\", s.mixedUnavailableErrorLocked(normalized, model, tried)\n\t}\n\n\tif s.strategy == schedulerStrategyFillFirst {\n\t\tfor providerIndex, providerKey := range normalized {\n\t\t\tshard := candidateShards[providerIndex]\n\t\t\tif shard == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpicked := shard.pickReadyAtPriorityLocked(false, bestPriority, s.strategy, predicate)\n\t\t\tif picked != nil {\n\t\t\t\treturn picked, providerKey, nil\n\t\t\t}\n\t\t}\n\t\treturn nil, \"\", s.mixedUnavailableErrorLocked(normalized, model, tried)\n\t}\n\n\tcursorKey := strings.Join(normalized, \",\") + \":\" + modelKey\n\tstart := 0\n\tif len(normalized) > 0 {\n\t\tstart = s.mixedCursors[cursorKey] % len(normalized)\n\t}\n\tfor offset := 0; offset < len(normalized); offset++ {\n\t\tproviderIndex := (start + offset) % len(normalized)\n\t\tproviderKey := normalized[providerIndex]\n\t\tshard := candidateShards[providerIndex]\n\t\tif shard == nil {\n\t\t\tcontinue\n\t\t}\n\t\tpicked := shard.pickReadyAtPriorityLocked(false, bestPriority, schedulerStrategyRoundRobin, predicate)\n\t\tif picked == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.mixedCursors[cursorKey] = providerIndex + 1\n\t\treturn picked, providerKey, nil\n\t}\n\treturn nil, \"\", s.mixedUnavailableErrorLocked(normalized, model, tried)\n}\n\n// mixedUnavailableErrorLocked synthesizes the mixed-provider cooldown or unavailable error.\nfunc (s *authScheduler) mixedUnavailableErrorLocked(providers []string, model string, tried map[string]struct{}) error {\n\tnow := time.Now()\n\ttotal := 0\n\tcooldownCount := 0\n\tearliest := time.Time{}\n\tfor _, providerKey := range providers {\n\t\tproviderState := s.providers[providerKey]\n\t\tif providerState == nil {\n\t\t\tcontinue\n\t\t}\n\t\tshard := providerState.ensureModelLocked(canonicalModelKey(model), now)\n\t\tif shard == nil {\n\t\t\tcontinue\n\t\t}\n\t\tlocalTotal, localCooldownCount, localEarliest := shard.availabilitySummaryLocked(triedPredicate(tried))\n\t\ttotal += localTotal\n\t\tcooldownCount += localCooldownCount\n\t\tif !localEarliest.IsZero() && (earliest.IsZero() || localEarliest.Before(earliest)) {\n\t\t\tearliest = localEarliest\n\t\t}\n\t}\n\tif total == 0 {\n\t\treturn &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tif cooldownCount == total && !earliest.IsZero() {\n\t\tresetIn := earliest.Sub(now)\n\t\tif resetIn < 0 {\n\t\t\tresetIn = 0\n\t\t}\n\t\treturn newModelCooldownError(model, \"\", resetIn)\n\t}\n\treturn &Error{Code: \"auth_unavailable\", Message: \"no auth available\"}\n}\n\n// triedPredicate builds a filter that excludes auths already attempted for the current request.\nfunc triedPredicate(tried map[string]struct{}) func(*scheduledAuth) bool {\n\tif len(tried) == 0 {\n\t\treturn func(entry *scheduledAuth) bool { return entry != nil && entry.auth != nil }\n\t}\n\treturn func(entry *scheduledAuth) bool {\n\t\tif entry == nil || entry.auth == nil {\n\t\t\treturn false\n\t\t}\n\t\t_, ok := tried[entry.auth.ID]\n\t\treturn !ok\n\t}\n}\n\n// normalizeProviderKeys lowercases, trims, and de-duplicates provider keys while preserving order.\nfunc normalizeProviderKeys(providers []string) []string {\n\tseen := make(map[string]struct{}, len(providers))\n\tout := make([]string, 0, len(providers))\n\tfor _, provider := range providers {\n\t\tproviderKey := strings.ToLower(strings.TrimSpace(provider))\n\t\tif providerKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[providerKey]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[providerKey] = struct{}{}\n\t\tout = append(out, providerKey)\n\t}\n\treturn out\n}\n\n// containsProvider reports whether provider is present in the normalized provider list.\nfunc containsProvider(providers []string, provider string) bool {\n\tfor _, candidate := range providers {\n\t\tif candidate == provider {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// upsertAuthLocked updates one auth in-place while the scheduler mutex is held.\nfunc (s *authScheduler) upsertAuthLocked(auth *Auth, now time.Time) {\n\tif auth == nil {\n\t\treturn\n\t}\n\tauthID := strings.TrimSpace(auth.ID)\n\tproviderKey := strings.ToLower(strings.TrimSpace(auth.Provider))\n\tif authID == \"\" || providerKey == \"\" || auth.Disabled {\n\t\ts.removeAuthLocked(authID)\n\t\treturn\n\t}\n\tif previousProvider := s.authProviders[authID]; previousProvider != \"\" && previousProvider != providerKey {\n\t\tif previousState := s.providers[previousProvider]; previousState != nil {\n\t\t\tpreviousState.removeAuthLocked(authID)\n\t\t}\n\t}\n\tmeta := buildScheduledAuthMeta(auth)\n\ts.authProviders[authID] = providerKey\n\ts.ensureProviderLocked(providerKey).upsertAuthLocked(meta, now)\n}\n\n// removeAuthLocked removes one auth from the scheduler while the scheduler mutex is held.\nfunc (s *authScheduler) removeAuthLocked(authID string) {\n\tif authID == \"\" {\n\t\treturn\n\t}\n\tif providerKey := s.authProviders[authID]; providerKey != \"\" {\n\t\tif providerState := s.providers[providerKey]; providerState != nil {\n\t\t\tproviderState.removeAuthLocked(authID)\n\t\t}\n\t\tdelete(s.authProviders, authID)\n\t}\n}\n\n// ensureProviderLocked returns the provider scheduler for providerKey, creating it when needed.\nfunc (s *authScheduler) ensureProviderLocked(providerKey string) *providerScheduler {\n\tif s.providers == nil {\n\t\ts.providers = make(map[string]*providerScheduler)\n\t}\n\tproviderState := s.providers[providerKey]\n\tif providerState == nil {\n\t\tproviderState = &providerScheduler{\n\t\t\tproviderKey: providerKey,\n\t\t\tauths:       make(map[string]*scheduledAuthMeta),\n\t\t\tmodelShards: make(map[string]*modelScheduler),\n\t\t}\n\t\ts.providers[providerKey] = providerState\n\t}\n\treturn providerState\n}\n\n// buildScheduledAuthMeta extracts the scheduling metadata needed for shard bookkeeping.\nfunc buildScheduledAuthMeta(auth *Auth) *scheduledAuthMeta {\n\tproviderKey := strings.ToLower(strings.TrimSpace(auth.Provider))\n\tvirtualParent := \"\"\n\tif auth.Attributes != nil {\n\t\tvirtualParent = strings.TrimSpace(auth.Attributes[\"gemini_virtual_parent\"])\n\t}\n\treturn &scheduledAuthMeta{\n\t\tauth:              auth,\n\t\tproviderKey:       providerKey,\n\t\tpriority:          authPriority(auth),\n\t\tvirtualParent:     virtualParent,\n\t\twebsocketEnabled:  authWebsocketsEnabled(auth),\n\t\tsupportedModelSet: supportedModelSetForAuth(auth.ID),\n\t}\n}\n\n// supportedModelSetForAuth snapshots the registry models currently registered for an auth.\nfunc supportedModelSetForAuth(authID string) map[string]struct{} {\n\tauthID = strings.TrimSpace(authID)\n\tif authID == \"\" {\n\t\treturn nil\n\t}\n\tmodels := registry.GetGlobalRegistry().GetModelsForClient(authID)\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tset := make(map[string]struct{}, len(models))\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tmodelKey := canonicalModelKey(model.ID)\n\t\tif modelKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tset[modelKey] = struct{}{}\n\t}\n\treturn set\n}\n\n// upsertAuthLocked updates every existing model shard that can reference the auth metadata.\nfunc (p *providerScheduler) upsertAuthLocked(meta *scheduledAuthMeta, now time.Time) {\n\tif p == nil || meta == nil || meta.auth == nil {\n\t\treturn\n\t}\n\tp.auths[meta.auth.ID] = meta\n\tfor modelKey, shard := range p.modelShards {\n\t\tif shard == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !meta.supportsModel(modelKey) {\n\t\t\tshard.removeEntryLocked(meta.auth.ID)\n\t\t\tcontinue\n\t\t}\n\t\tshard.upsertEntryLocked(meta, now)\n\t}\n}\n\n// removeAuthLocked removes an auth from all model shards owned by the provider scheduler.\nfunc (p *providerScheduler) removeAuthLocked(authID string) {\n\tif p == nil || authID == \"\" {\n\t\treturn\n\t}\n\tdelete(p.auths, authID)\n\tfor _, shard := range p.modelShards {\n\t\tif shard != nil {\n\t\t\tshard.removeEntryLocked(authID)\n\t\t}\n\t}\n}\n\n// ensureModelLocked returns the shard for modelKey, building it lazily from provider auths.\nfunc (p *providerScheduler) ensureModelLocked(modelKey string, now time.Time) *modelScheduler {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tmodelKey = canonicalModelKey(modelKey)\n\tif shard, ok := p.modelShards[modelKey]; ok && shard != nil {\n\t\tshard.promoteExpiredLocked(now)\n\t\treturn shard\n\t}\n\tshard := &modelScheduler{\n\t\tmodelKey:        modelKey,\n\t\tentries:         make(map[string]*scheduledAuth),\n\t\treadyByPriority: make(map[int]*readyBucket),\n\t}\n\tfor _, meta := range p.auths {\n\t\tif meta == nil || !meta.supportsModel(modelKey) {\n\t\t\tcontinue\n\t\t}\n\t\tshard.upsertEntryLocked(meta, now)\n\t}\n\tp.modelShards[modelKey] = shard\n\treturn shard\n}\n\n// supportsModel reports whether the auth metadata currently supports modelKey.\nfunc (m *scheduledAuthMeta) supportsModel(modelKey string) bool {\n\tmodelKey = canonicalModelKey(modelKey)\n\tif modelKey == \"\" {\n\t\treturn true\n\t}\n\tif len(m.supportedModelSet) == 0 {\n\t\treturn false\n\t}\n\t_, ok := m.supportedModelSet[modelKey]\n\treturn ok\n}\n\n// upsertEntryLocked updates or inserts one auth entry and rebuilds indexes when ordering changes.\nfunc (m *modelScheduler) upsertEntryLocked(meta *scheduledAuthMeta, now time.Time) {\n\tif m == nil || meta == nil || meta.auth == nil {\n\t\treturn\n\t}\n\tentry, ok := m.entries[meta.auth.ID]\n\tif !ok || entry == nil {\n\t\tentry = &scheduledAuth{}\n\t\tm.entries[meta.auth.ID] = entry\n\t}\n\tpreviousState := entry.state\n\tpreviousNextRetryAt := entry.nextRetryAt\n\tpreviousPriority := 0\n\tpreviousParent := \"\"\n\tpreviousWebsocketEnabled := false\n\tif entry.meta != nil {\n\t\tpreviousPriority = entry.meta.priority\n\t\tpreviousParent = entry.meta.virtualParent\n\t\tpreviousWebsocketEnabled = entry.meta.websocketEnabled\n\t}\n\n\tentry.meta = meta\n\tentry.auth = meta.auth\n\tentry.nextRetryAt = time.Time{}\n\tblocked, reason, next := isAuthBlockedForModel(meta.auth, m.modelKey, now)\n\tswitch {\n\tcase !blocked:\n\t\tentry.state = scheduledStateReady\n\tcase reason == blockReasonCooldown:\n\t\tentry.state = scheduledStateCooldown\n\t\tentry.nextRetryAt = next\n\tcase reason == blockReasonDisabled:\n\t\tentry.state = scheduledStateDisabled\n\tdefault:\n\t\tentry.state = scheduledStateBlocked\n\t\tentry.nextRetryAt = next\n\t}\n\n\tif ok && previousState == entry.state && previousNextRetryAt.Equal(entry.nextRetryAt) && previousPriority == meta.priority && previousParent == meta.virtualParent && previousWebsocketEnabled == meta.websocketEnabled {\n\t\treturn\n\t}\n\tm.rebuildIndexesLocked()\n}\n\n// removeEntryLocked deletes one auth entry and rebuilds the shard indexes if needed.\nfunc (m *modelScheduler) removeEntryLocked(authID string) {\n\tif m == nil || authID == \"\" {\n\t\treturn\n\t}\n\tif _, ok := m.entries[authID]; !ok {\n\t\treturn\n\t}\n\tdelete(m.entries, authID)\n\tm.rebuildIndexesLocked()\n}\n\n// promoteExpiredLocked reevaluates blocked auths whose retry time has elapsed.\nfunc (m *modelScheduler) promoteExpiredLocked(now time.Time) {\n\tif m == nil || len(m.blocked) == 0 {\n\t\treturn\n\t}\n\tchanged := false\n\tfor _, entry := range m.blocked {\n\t\tif entry == nil || entry.auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif entry.nextRetryAt.IsZero() || entry.nextRetryAt.After(now) {\n\t\t\tcontinue\n\t\t}\n\t\tblocked, reason, next := isAuthBlockedForModel(entry.auth, m.modelKey, now)\n\t\tswitch {\n\t\tcase !blocked:\n\t\t\tentry.state = scheduledStateReady\n\t\t\tentry.nextRetryAt = time.Time{}\n\t\tcase reason == blockReasonCooldown:\n\t\t\tentry.state = scheduledStateCooldown\n\t\t\tentry.nextRetryAt = next\n\t\tcase reason == blockReasonDisabled:\n\t\t\tentry.state = scheduledStateDisabled\n\t\t\tentry.nextRetryAt = time.Time{}\n\t\tdefault:\n\t\t\tentry.state = scheduledStateBlocked\n\t\t\tentry.nextRetryAt = next\n\t\t}\n\t\tchanged = true\n\t}\n\tif changed {\n\t\tm.rebuildIndexesLocked()\n\t}\n}\n\n// pickReadyLocked selects the next ready auth from the highest available priority bucket.\nfunc (m *modelScheduler) pickReadyLocked(preferWebsocket bool, strategy schedulerStrategy, predicate func(*scheduledAuth) bool) *Auth {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tm.promoteExpiredLocked(time.Now())\n\tpriorityReady, okPriority := m.highestReadyPriorityLocked(preferWebsocket, predicate)\n\tif !okPriority {\n\t\treturn nil\n\t}\n\treturn m.pickReadyAtPriorityLocked(preferWebsocket, priorityReady, strategy, predicate)\n}\n\n// highestReadyPriorityLocked returns the highest priority bucket that still has a matching ready auth.\n// The caller must ensure expired entries are already promoted when needed.\nfunc (m *modelScheduler) highestReadyPriorityLocked(preferWebsocket bool, predicate func(*scheduledAuth) bool) (int, bool) {\n\tif m == nil {\n\t\treturn 0, false\n\t}\n\tfor _, priority := range m.priorityOrder {\n\t\tbucket := m.readyByPriority[priority]\n\t\tif bucket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tview := &bucket.all\n\t\tif preferWebsocket && len(bucket.ws.flat) > 0 {\n\t\t\tview = &bucket.ws\n\t\t}\n\t\tif view.pickFirst(predicate) != nil {\n\t\t\treturn priority, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\n// pickReadyAtPriorityLocked selects the next ready auth from a specific priority bucket.\n// The caller must ensure expired entries are already promoted when needed.\nfunc (m *modelScheduler) pickReadyAtPriorityLocked(preferWebsocket bool, priority int, strategy schedulerStrategy, predicate func(*scheduledAuth) bool) *Auth {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tbucket := m.readyByPriority[priority]\n\tif bucket == nil {\n\t\treturn nil\n\t}\n\tview := &bucket.all\n\tif preferWebsocket && len(bucket.ws.flat) > 0 {\n\t\tview = &bucket.ws\n\t}\n\tvar picked *scheduledAuth\n\tif strategy == schedulerStrategyFillFirst {\n\t\tpicked = view.pickFirst(predicate)\n\t} else {\n\t\tpicked = view.pickRoundRobin(predicate)\n\t}\n\tif picked == nil || picked.auth == nil {\n\t\treturn nil\n\t}\n\treturn picked.auth\n}\n\n// unavailableErrorLocked returns the correct unavailable or cooldown error for the shard.\nfunc (m *modelScheduler) unavailableErrorLocked(provider, model string, predicate func(*scheduledAuth) bool) error {\n\tnow := time.Now()\n\ttotal, cooldownCount, earliest := m.availabilitySummaryLocked(predicate)\n\tif total == 0 {\n\t\treturn &Error{Code: \"auth_not_found\", Message: \"no auth available\"}\n\t}\n\tif cooldownCount == total && !earliest.IsZero() {\n\t\tproviderForError := provider\n\t\tif providerForError == \"mixed\" {\n\t\t\tproviderForError = \"\"\n\t\t}\n\t\tresetIn := earliest.Sub(now)\n\t\tif resetIn < 0 {\n\t\t\tresetIn = 0\n\t\t}\n\t\treturn newModelCooldownError(model, providerForError, resetIn)\n\t}\n\treturn &Error{Code: \"auth_unavailable\", Message: \"no auth available\"}\n}\n\n// availabilitySummaryLocked summarizes total candidates, cooldown count, and earliest retry time.\nfunc (m *modelScheduler) availabilitySummaryLocked(predicate func(*scheduledAuth) bool) (int, int, time.Time) {\n\tif m == nil {\n\t\treturn 0, 0, time.Time{}\n\t}\n\ttotal := 0\n\tcooldownCount := 0\n\tearliest := time.Time{}\n\tfor _, entry := range m.entries {\n\t\tif predicate != nil && !predicate(entry) {\n\t\t\tcontinue\n\t\t}\n\t\ttotal++\n\t\tif entry == nil || entry.auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif entry.state != scheduledStateCooldown {\n\t\t\tcontinue\n\t\t}\n\t\tcooldownCount++\n\t\tif !entry.nextRetryAt.IsZero() && (earliest.IsZero() || entry.nextRetryAt.Before(earliest)) {\n\t\t\tearliest = entry.nextRetryAt\n\t\t}\n\t}\n\treturn total, cooldownCount, earliest\n}\n\n// rebuildIndexesLocked reconstructs ready and blocked views from the current entry map.\nfunc (m *modelScheduler) rebuildIndexesLocked() {\n\tm.readyByPriority = make(map[int]*readyBucket)\n\tm.priorityOrder = m.priorityOrder[:0]\n\tm.blocked = m.blocked[:0]\n\tpriorityBuckets := make(map[int][]*scheduledAuth)\n\tfor _, entry := range m.entries {\n\t\tif entry == nil || entry.auth == nil {\n\t\t\tcontinue\n\t\t}\n\t\tswitch entry.state {\n\t\tcase scheduledStateReady:\n\t\t\tpriority := entry.meta.priority\n\t\t\tpriorityBuckets[priority] = append(priorityBuckets[priority], entry)\n\t\tcase scheduledStateCooldown, scheduledStateBlocked:\n\t\t\tm.blocked = append(m.blocked, entry)\n\t\t}\n\t}\n\tfor priority, entries := range priorityBuckets {\n\t\tsort.Slice(entries, func(i, j int) bool {\n\t\t\treturn entries[i].auth.ID < entries[j].auth.ID\n\t\t})\n\t\tm.readyByPriority[priority] = buildReadyBucket(entries)\n\t\tm.priorityOrder = append(m.priorityOrder, priority)\n\t}\n\tsort.Slice(m.priorityOrder, func(i, j int) bool {\n\t\treturn m.priorityOrder[i] > m.priorityOrder[j]\n\t})\n\tsort.Slice(m.blocked, func(i, j int) bool {\n\t\tleft := m.blocked[i]\n\t\tright := m.blocked[j]\n\t\tif left == nil || right == nil {\n\t\t\treturn left != nil\n\t\t}\n\t\tif left.nextRetryAt.Equal(right.nextRetryAt) {\n\t\t\treturn left.auth.ID < right.auth.ID\n\t\t}\n\t\tif left.nextRetryAt.IsZero() {\n\t\t\treturn false\n\t\t}\n\t\tif right.nextRetryAt.IsZero() {\n\t\t\treturn true\n\t\t}\n\t\treturn left.nextRetryAt.Before(right.nextRetryAt)\n\t})\n}\n\n// buildReadyBucket prepares the general and websocket-only ready views for one priority bucket.\nfunc buildReadyBucket(entries []*scheduledAuth) *readyBucket {\n\tbucket := &readyBucket{}\n\tbucket.all = buildReadyView(entries)\n\twsEntries := make([]*scheduledAuth, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif entry != nil && entry.meta != nil && entry.meta.websocketEnabled {\n\t\t\twsEntries = append(wsEntries, entry)\n\t\t}\n\t}\n\tbucket.ws = buildReadyView(wsEntries)\n\treturn bucket\n}\n\n// buildReadyView creates either a flat view or a grouped parent/child view for rotation.\nfunc buildReadyView(entries []*scheduledAuth) readyView {\n\tview := readyView{flat: append([]*scheduledAuth(nil), entries...)}\n\tif len(entries) == 0 {\n\t\treturn view\n\t}\n\tgroups := make(map[string][]*scheduledAuth)\n\tfor _, entry := range entries {\n\t\tif entry == nil || entry.meta == nil || entry.meta.virtualParent == \"\" {\n\t\t\treturn view\n\t\t}\n\t\tgroups[entry.meta.virtualParent] = append(groups[entry.meta.virtualParent], entry)\n\t}\n\tif len(groups) <= 1 {\n\t\treturn view\n\t}\n\tview.children = make(map[string]*childBucket, len(groups))\n\tview.parentOrder = make([]string, 0, len(groups))\n\tfor parent := range groups {\n\t\tview.parentOrder = append(view.parentOrder, parent)\n\t}\n\tsort.Strings(view.parentOrder)\n\tfor _, parent := range view.parentOrder {\n\t\tview.children[parent] = &childBucket{items: append([]*scheduledAuth(nil), groups[parent]...)}\n\t}\n\treturn view\n}\n\n// pickFirst returns the first ready entry that satisfies predicate without advancing cursors.\nfunc (v *readyView) pickFirst(predicate func(*scheduledAuth) bool) *scheduledAuth {\n\tfor _, entry := range v.flat {\n\t\tif predicate == nil || predicate(entry) {\n\t\t\treturn entry\n\t\t}\n\t}\n\treturn nil\n}\n\n// pickRoundRobin returns the next ready entry using flat or grouped round-robin traversal.\nfunc (v *readyView) pickRoundRobin(predicate func(*scheduledAuth) bool) *scheduledAuth {\n\tif len(v.parentOrder) > 1 && len(v.children) > 0 {\n\t\treturn v.pickGroupedRoundRobin(predicate)\n\t}\n\tif len(v.flat) == 0 {\n\t\treturn nil\n\t}\n\tstart := 0\n\tif len(v.flat) > 0 {\n\t\tstart = v.cursor % len(v.flat)\n\t}\n\tfor offset := 0; offset < len(v.flat); offset++ {\n\t\tindex := (start + offset) % len(v.flat)\n\t\tentry := v.flat[index]\n\t\tif predicate != nil && !predicate(entry) {\n\t\t\tcontinue\n\t\t}\n\t\tv.cursor = index + 1\n\t\treturn entry\n\t}\n\treturn nil\n}\n\n// pickGroupedRoundRobin rotates across parents first and then within the selected parent.\nfunc (v *readyView) pickGroupedRoundRobin(predicate func(*scheduledAuth) bool) *scheduledAuth {\n\tstart := 0\n\tif len(v.parentOrder) > 0 {\n\t\tstart = v.parentCursor % len(v.parentOrder)\n\t}\n\tfor offset := 0; offset < len(v.parentOrder); offset++ {\n\t\tparentIndex := (start + offset) % len(v.parentOrder)\n\t\tparent := v.parentOrder[parentIndex]\n\t\tchild := v.children[parent]\n\t\tif child == nil || len(child.items) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\titemStart := child.cursor % len(child.items)\n\t\tfor itemOffset := 0; itemOffset < len(child.items); itemOffset++ {\n\t\t\titemIndex := (itemStart + itemOffset) % len(child.items)\n\t\t\tentry := child.items[itemIndex]\n\t\t\tif predicate != nil && !predicate(entry) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchild.cursor = itemIndex + 1\n\t\t\tv.parentCursor = parentIndex + 1\n\t\t\treturn entry\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/scheduler_benchmark_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\ntype schedulerBenchmarkExecutor struct {\n\tid string\n}\n\nfunc (e schedulerBenchmarkExecutor) Identifier() string { return e.id }\n\nfunc (e schedulerBenchmarkExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e schedulerBenchmarkExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\treturn nil, nil\n}\n\nfunc (e schedulerBenchmarkExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (e schedulerBenchmarkExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (e schedulerBenchmarkExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, nil\n}\n\nfunc benchmarkManagerSetup(b *testing.B, total int, mixed bool, withPriority bool) (*Manager, []string, string) {\n\tb.Helper()\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tproviders := []string{\"gemini\"}\n\tmanager.executors[\"gemini\"] = schedulerBenchmarkExecutor{id: \"gemini\"}\n\tif mixed {\n\t\tproviders = []string{\"gemini\", \"claude\"}\n\t\tmanager.executors[\"claude\"] = schedulerBenchmarkExecutor{id: \"claude\"}\n\t}\n\n\treg := registry.GetGlobalRegistry()\n\tmodel := \"bench-model\"\n\tfor index := 0; index < total; index++ {\n\t\tprovider := providers[0]\n\t\tif mixed && index%2 == 1 {\n\t\t\tprovider = providers[1]\n\t\t}\n\t\tauth := &Auth{ID: fmt.Sprintf(\"bench-%s-%04d\", provider, index), Provider: provider}\n\t\tif withPriority {\n\t\t\tpriority := \"0\"\n\t\t\tif index%2 == 0 {\n\t\t\t\tpriority = \"10\"\n\t\t\t}\n\t\t\tauth.Attributes = map[string]string{\"priority\": priority}\n\t\t}\n\t\t_, errRegister := manager.Register(context.Background(), auth)\n\t\tif errRegister != nil {\n\t\t\tb.Fatalf(\"Register(%s) error = %v\", auth.ID, errRegister)\n\t\t}\n\t\treg.RegisterClient(auth.ID, provider, []*registry.ModelInfo{{ID: model}})\n\t}\n\tmanager.syncScheduler()\n\tb.Cleanup(func() {\n\t\tfor index := 0; index < total; index++ {\n\t\t\tprovider := providers[0]\n\t\t\tif mixed && index%2 == 1 {\n\t\t\t\tprovider = providers[1]\n\t\t\t}\n\t\t\treg.UnregisterClient(fmt.Sprintf(\"bench-%s-%04d\", provider, index))\n\t\t}\n\t})\n\n\treturn manager, providers, model\n}\n\nfunc BenchmarkManagerPickNext500(b *testing.B) {\n\tmanager, _, model := benchmarkManagerSetup(b, 500, false, false)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, errWarm := manager.pickNext(ctx, \"gemini\", model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNext error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, errPick := manager.pickNext(ctx, \"gemini\", model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil {\n\t\t\tb.Fatalf(\"pickNext failed: auth=%v exec=%v err=%v\", auth, exec, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNext1000(b *testing.B) {\n\tmanager, _, model := benchmarkManagerSetup(b, 1000, false, false)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, errWarm := manager.pickNext(ctx, \"gemini\", model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNext error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, errPick := manager.pickNext(ctx, \"gemini\", model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil {\n\t\t\tb.Fatalf(\"pickNext failed: auth=%v exec=%v err=%v\", auth, exec, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNextPriority500(b *testing.B) {\n\tmanager, _, model := benchmarkManagerSetup(b, 500, false, true)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, errWarm := manager.pickNext(ctx, \"gemini\", model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNext error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, errPick := manager.pickNext(ctx, \"gemini\", model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil {\n\t\t\tb.Fatalf(\"pickNext failed: auth=%v exec=%v err=%v\", auth, exec, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNextPriority1000(b *testing.B) {\n\tmanager, _, model := benchmarkManagerSetup(b, 1000, false, true)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, errWarm := manager.pickNext(ctx, \"gemini\", model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNext error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, errPick := manager.pickNext(ctx, \"gemini\", model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil {\n\t\t\tb.Fatalf(\"pickNext failed: auth=%v exec=%v err=%v\", auth, exec, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNextMixed500(b *testing.B) {\n\tmanager, providers, model := benchmarkManagerSetup(b, 500, true, false)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, _, errWarm := manager.pickNextMixed(ctx, providers, model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNextMixed error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, provider, errPick := manager.pickNextMixed(ctx, providers, model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil || provider == \"\" {\n\t\t\tb.Fatalf(\"pickNextMixed failed: auth=%v exec=%v provider=%q err=%v\", auth, exec, provider, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNextMixedPriority500(b *testing.B) {\n\tmanager, providers, model := benchmarkManagerSetup(b, 500, true, true)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, _, errWarm := manager.pickNextMixed(ctx, providers, model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNextMixed error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, exec, provider, errPick := manager.pickNextMixed(ctx, providers, model, opts, tried)\n\t\tif errPick != nil || auth == nil || exec == nil || provider == \"\" {\n\t\t\tb.Fatalf(\"pickNextMixed failed: auth=%v exec=%v provider=%q err=%v\", auth, exec, provider, errPick)\n\t\t}\n\t}\n}\n\nfunc BenchmarkManagerPickNextAndMarkResult1000(b *testing.B) {\n\tmanager, _, model := benchmarkManagerSetup(b, 1000, false, false)\n\tctx := context.Background()\n\topts := cliproxyexecutor.Options{}\n\ttried := map[string]struct{}{}\n\tif _, _, errWarm := manager.pickNext(ctx, \"gemini\", model, opts, tried); errWarm != nil {\n\t\tb.Fatalf(\"warmup pickNext error = %v\", errWarm)\n\t}\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tauth, _, errPick := manager.pickNext(ctx, \"gemini\", model, opts, tried)\n\t\tif errPick != nil || auth == nil {\n\t\t\tb.Fatalf(\"pickNext failed: auth=%v err=%v\", auth, errPick)\n\t\t}\n\t\tmanager.MarkResult(ctx, Result{AuthID: auth.ID, Provider: \"gemini\", Model: model, Success: true})\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/scheduler_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\ntype schedulerTestExecutor struct{}\n\nfunc (schedulerTestExecutor) Identifier() string { return \"test\" }\n\nfunc (schedulerTestExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (schedulerTestExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {\n\treturn nil, nil\n}\n\nfunc (schedulerTestExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {\n\treturn auth, nil\n}\n\nfunc (schedulerTestExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {\n\treturn cliproxyexecutor.Response{}, nil\n}\n\nfunc (schedulerTestExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {\n\treturn nil, nil\n}\n\ntype trackingSelector struct {\n\tcalls      int\n\tlastAuthID []string\n}\n\nfunc (s *trackingSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {\n\ts.calls++\n\ts.lastAuthID = s.lastAuthID[:0]\n\tfor _, auth := range auths {\n\t\ts.lastAuthID = append(s.lastAuthID, auth.ID)\n\t}\n\tif len(auths) == 0 {\n\t\treturn nil, nil\n\t}\n\treturn auths[len(auths)-1], nil\n}\n\nfunc newSchedulerForTest(selector Selector, auths ...*Auth) *authScheduler {\n\tscheduler := newAuthScheduler(selector)\n\tscheduler.rebuild(auths)\n\treturn scheduler\n}\n\nfunc registerSchedulerModels(t *testing.T, provider string, model string, authIDs ...string) {\n\tt.Helper()\n\treg := registry.GetGlobalRegistry()\n\tfor _, authID := range authIDs {\n\t\treg.RegisterClient(authID, provider, []*registry.ModelInfo{{ID: model}})\n\t}\n\tt.Cleanup(func() {\n\t\tfor _, authID := range authIDs {\n\t\t\treg.UnregisterClient(authID)\n\t\t}\n\t})\n}\n\nfunc TestSchedulerPick_RoundRobinHighestPriority(t *testing.T) {\n\tt.Parallel()\n\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{ID: \"low\", Provider: \"gemini\", Attributes: map[string]string{\"priority\": \"0\"}},\n\t\t&Auth{ID: \"high-b\", Provider: \"gemini\", Attributes: map[string]string{\"priority\": \"10\"}},\n\t\t&Auth{ID: \"high-a\", Provider: \"gemini\", Attributes: map[string]string{\"priority\": \"10\"}},\n\t)\n\n\twant := []string{\"high-a\", \"high-b\", \"high-a\"}\n\tfor index, wantID := range want {\n\t\tgot, errPick := scheduler.pickSingle(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth = nil\", index)\n\t\t}\n\t\tif got.ID != wantID {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth.ID = %q, want %q\", index, got.ID, wantID)\n\t\t}\n\t}\n}\n\nfunc TestSchedulerPick_FillFirstSticksToFirstReady(t *testing.T) {\n\tt.Parallel()\n\n\tscheduler := newSchedulerForTest(\n\t\t&FillFirstSelector{},\n\t\t&Auth{ID: \"b\", Provider: \"gemini\"},\n\t\t&Auth{ID: \"a\", Provider: \"gemini\"},\n\t\t&Auth{ID: \"c\", Provider: \"gemini\"},\n\t)\n\n\tfor index := 0; index < 3; index++ {\n\t\tgot, errPick := scheduler.pickSingle(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth = nil\", index)\n\t\t}\n\t\tif got.ID != \"a\" {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth.ID = %q, want %q\", index, got.ID, \"a\")\n\t\t}\n\t}\n}\n\nfunc TestSchedulerPick_PromotesExpiredCooldownBeforePick(t *testing.T) {\n\tt.Parallel()\n\n\tmodel := \"gemini-2.5-pro\"\n\tregisterSchedulerModels(t, \"gemini\", model, \"cooldown-expired\")\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{\n\t\t\tID:       \"cooldown-expired\",\n\t\t\tProvider: \"gemini\",\n\t\t\tModelStates: map[string]*ModelState{\n\t\t\t\tmodel: {\n\t\t\t\t\tStatus:         StatusError,\n\t\t\t\t\tUnavailable:    true,\n\t\t\t\t\tNextRetryAfter: time.Now().Add(-1 * time.Second),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t)\n\n\tgot, errPick := scheduler.pickSingle(context.Background(), \"gemini\", model, cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"pickSingle() error = %v\", errPick)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"pickSingle() auth = nil\")\n\t}\n\tif got.ID != \"cooldown-expired\" {\n\t\tt.Fatalf(\"pickSingle() auth.ID = %q, want %q\", got.ID, \"cooldown-expired\")\n\t}\n}\n\nfunc TestSchedulerPick_GeminiVirtualParentUsesTwoLevelRotation(t *testing.T) {\n\tt.Parallel()\n\n\tregisterSchedulerModels(t, \"gemini-cli\", \"gemini-2.5-pro\", \"cred-a::proj-1\", \"cred-a::proj-2\", \"cred-b::proj-1\", \"cred-b::proj-2\")\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{ID: \"cred-a::proj-1\", Provider: \"gemini-cli\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a\"}},\n\t\t&Auth{ID: \"cred-a::proj-2\", Provider: \"gemini-cli\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a\"}},\n\t\t&Auth{ID: \"cred-b::proj-1\", Provider: \"gemini-cli\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-b\"}},\n\t\t&Auth{ID: \"cred-b::proj-2\", Provider: \"gemini-cli\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-b\"}},\n\t)\n\n\twantParents := []string{\"cred-a\", \"cred-b\", \"cred-a\", \"cred-b\"}\n\twantIDs := []string{\"cred-a::proj-1\", \"cred-b::proj-1\", \"cred-a::proj-2\", \"cred-b::proj-2\"}\n\tfor index := range wantIDs {\n\t\tgot, errPick := scheduler.pickSingle(context.Background(), \"gemini-cli\", \"gemini-2.5-pro\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth = nil\", index)\n\t\t}\n\t\tif got.ID != wantIDs[index] {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth.ID = %q, want %q\", index, got.ID, wantIDs[index])\n\t\t}\n\t\tif got.Attributes[\"gemini_virtual_parent\"] != wantParents[index] {\n\t\t\tt.Fatalf(\"pickSingle() #%d parent = %q, want %q\", index, got.Attributes[\"gemini_virtual_parent\"], wantParents[index])\n\t\t}\n\t}\n}\n\nfunc TestSchedulerPick_CodexWebsocketPrefersWebsocketEnabledSubset(t *testing.T) {\n\tt.Parallel()\n\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{ID: \"codex-http\", Provider: \"codex\"},\n\t\t&Auth{ID: \"codex-ws-a\", Provider: \"codex\", Attributes: map[string]string{\"websockets\": \"true\"}},\n\t\t&Auth{ID: \"codex-ws-b\", Provider: \"codex\", Attributes: map[string]string{\"websockets\": \"true\"}},\n\t)\n\n\tctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())\n\twant := []string{\"codex-ws-a\", \"codex-ws-b\", \"codex-ws-a\"}\n\tfor index, wantID := range want {\n\t\tgot, errPick := scheduler.pickSingle(ctx, \"codex\", \"\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth = nil\", index)\n\t\t}\n\t\tif got.ID != wantID {\n\t\t\tt.Fatalf(\"pickSingle() #%d auth.ID = %q, want %q\", index, got.ID, wantID)\n\t\t}\n\t}\n}\n\nfunc TestSchedulerPick_MixedProvidersUsesProviderRotationOverReadyCandidates(t *testing.T) {\n\tt.Parallel()\n\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{ID: \"gemini-a\", Provider: \"gemini\"},\n\t\t&Auth{ID: \"gemini-b\", Provider: \"gemini\"},\n\t\t&Auth{ID: \"claude-a\", Provider: \"claude\"},\n\t)\n\n\twantProviders := []string{\"gemini\", \"claude\", \"gemini\", \"claude\"}\n\twantIDs := []string{\"gemini-a\", \"claude-a\", \"gemini-b\", \"claude-a\"}\n\tfor index := range wantProviders {\n\t\tgot, provider, errPick := scheduler.pickMixed(context.Background(), []string{\"gemini\", \"claude\"}, \"\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickMixed() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickMixed() #%d auth = nil\", index)\n\t\t}\n\t\tif provider != wantProviders[index] {\n\t\t\tt.Fatalf(\"pickMixed() #%d provider = %q, want %q\", index, provider, wantProviders[index])\n\t\t}\n\t\tif got.ID != wantIDs[index] {\n\t\t\tt.Fatalf(\"pickMixed() #%d auth.ID = %q, want %q\", index, got.ID, wantIDs[index])\n\t\t}\n\t}\n}\n\nfunc TestSchedulerPick_MixedProvidersPrefersHighestPriorityTier(t *testing.T) {\n\tt.Parallel()\n\n\tmodel := \"gpt-default\"\n\tregisterSchedulerModels(t, \"provider-low\", model, \"low\")\n\tregisterSchedulerModels(t, \"provider-high-a\", model, \"high-a\")\n\tregisterSchedulerModels(t, \"provider-high-b\", model, \"high-b\")\n\n\tscheduler := newSchedulerForTest(\n\t\t&RoundRobinSelector{},\n\t\t&Auth{ID: \"low\", Provider: \"provider-low\", Attributes: map[string]string{\"priority\": \"4\"}},\n\t\t&Auth{ID: \"high-a\", Provider: \"provider-high-a\", Attributes: map[string]string{\"priority\": \"7\"}},\n\t\t&Auth{ID: \"high-b\", Provider: \"provider-high-b\", Attributes: map[string]string{\"priority\": \"7\"}},\n\t)\n\n\tproviders := []string{\"provider-low\", \"provider-high-a\", \"provider-high-b\"}\n\twantProviders := []string{\"provider-high-a\", \"provider-high-b\", \"provider-high-a\", \"provider-high-b\"}\n\twantIDs := []string{\"high-a\", \"high-b\", \"high-a\", \"high-b\"}\n\tfor index := range wantProviders {\n\t\tgot, provider, errPick := scheduler.pickMixed(context.Background(), providers, model, cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickMixed() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickMixed() #%d auth = nil\", index)\n\t\t}\n\t\tif provider != wantProviders[index] {\n\t\t\tt.Fatalf(\"pickMixed() #%d provider = %q, want %q\", index, provider, wantProviders[index])\n\t\t}\n\t\tif got.ID != wantIDs[index] {\n\t\t\tt.Fatalf(\"pickMixed() #%d auth.ID = %q, want %q\", index, got.ID, wantIDs[index])\n\t\t}\n\t}\n}\n\nfunc TestManager_PickNextMixed_UsesProviderRotationBeforeCredentialRotation(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tmanager.executors[\"gemini\"] = schedulerTestExecutor{}\n\tmanager.executors[\"claude\"] = schedulerTestExecutor{}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"gemini-a\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(gemini-a) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"gemini-b\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(gemini-b) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"claude-a\", Provider: \"claude\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(claude-a) error = %v\", errRegister)\n\t}\n\n\twantProviders := []string{\"gemini\", \"claude\", \"gemini\", \"claude\"}\n\twantIDs := []string{\"gemini-a\", \"claude-a\", \"gemini-b\", \"claude-a\"}\n\tfor index := range wantProviders {\n\t\tgot, _, provider, errPick := manager.pickNextMixed(context.Background(), []string{\"gemini\", \"claude\"}, \"\", cliproxyexecutor.Options{}, map[string]struct{}{})\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d auth = nil\", index)\n\t\t}\n\t\tif provider != wantProviders[index] {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d provider = %q, want %q\", index, provider, wantProviders[index])\n\t\t}\n\t\tif got.ID != wantIDs[index] {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d auth.ID = %q, want %q\", index, got.ID, wantIDs[index])\n\t\t}\n\t}\n}\n\nfunc TestManagerCustomSelector_FallsBackToLegacyPath(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &trackingSelector{}\n\tmanager := NewManager(nil, selector, nil)\n\tmanager.executors[\"gemini\"] = schedulerTestExecutor{}\n\tmanager.auths[\"auth-a\"] = &Auth{ID: \"auth-a\", Provider: \"gemini\"}\n\tmanager.auths[\"auth-b\"] = &Auth{ID: \"auth-b\", Provider: \"gemini\"}\n\n\tgot, _, errPick := manager.pickNext(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, map[string]struct{}{})\n\tif errPick != nil {\n\t\tt.Fatalf(\"pickNext() error = %v\", errPick)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"pickNext() auth = nil\")\n\t}\n\tif selector.calls != 1 {\n\t\tt.Fatalf(\"selector.calls = %d, want %d\", selector.calls, 1)\n\t}\n\tif len(selector.lastAuthID) != 2 {\n\t\tt.Fatalf(\"len(selector.lastAuthID) = %d, want %d\", len(selector.lastAuthID), 2)\n\t}\n\tif got.ID != selector.lastAuthID[len(selector.lastAuthID)-1] {\n\t\tt.Fatalf(\"pickNext() auth.ID = %q, want selector-picked %q\", got.ID, selector.lastAuthID[len(selector.lastAuthID)-1])\n\t}\n}\n\nfunc TestManager_InitializesSchedulerForBuiltInSelector(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tif manager.scheduler == nil {\n\t\tt.Fatalf(\"manager.scheduler = nil\")\n\t}\n\tif manager.scheduler.strategy != schedulerStrategyRoundRobin {\n\t\tt.Fatalf(\"manager.scheduler.strategy = %v, want %v\", manager.scheduler.strategy, schedulerStrategyRoundRobin)\n\t}\n\n\tmanager.SetSelector(&FillFirstSelector{})\n\tif manager.scheduler.strategy != schedulerStrategyFillFirst {\n\t\tt.Fatalf(\"manager.scheduler.strategy = %v, want %v\", manager.scheduler.strategy, schedulerStrategyFillFirst)\n\t}\n}\n\nfunc TestManager_SchedulerTracksRegisterAndUpdate(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"auth-b\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(auth-b) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"auth-a\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(auth-a) error = %v\", errRegister)\n\t}\n\n\tgot, errPick := manager.scheduler.pickSingle(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"scheduler.pickSingle() error = %v\", errPick)\n\t}\n\tif got == nil || got.ID != \"auth-a\" {\n\t\tt.Fatalf(\"scheduler.pickSingle() auth = %v, want auth-a\", got)\n\t}\n\n\tif _, errUpdate := manager.Update(context.Background(), &Auth{ID: \"auth-a\", Provider: \"gemini\", Disabled: true}); errUpdate != nil {\n\t\tt.Fatalf(\"Update(auth-a) error = %v\", errUpdate)\n\t}\n\n\tgot, errPick = manager.scheduler.pickSingle(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"scheduler.pickSingle() after update error = %v\", errPick)\n\t}\n\tif got == nil || got.ID != \"auth-b\" {\n\t\tt.Fatalf(\"scheduler.pickSingle() after update auth = %v, want auth-b\", got)\n\t}\n}\n\nfunc TestManager_PickNextMixed_UsesSchedulerRotation(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tmanager.executors[\"gemini\"] = schedulerTestExecutor{}\n\tmanager.executors[\"claude\"] = schedulerTestExecutor{}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"gemini-a\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(gemini-a) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"gemini-b\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(gemini-b) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"claude-a\", Provider: \"claude\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(claude-a) error = %v\", errRegister)\n\t}\n\n\twantProviders := []string{\"gemini\", \"claude\", \"gemini\", \"claude\"}\n\twantIDs := []string{\"gemini-a\", \"claude-a\", \"gemini-b\", \"claude-a\"}\n\tfor index := range wantProviders {\n\t\tgot, _, provider, errPick := manager.pickNextMixed(context.Background(), []string{\"gemini\", \"claude\"}, \"\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d auth = nil\", index)\n\t\t}\n\t\tif provider != wantProviders[index] {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d provider = %q, want %q\", index, provider, wantProviders[index])\n\t\t}\n\t\tif got.ID != wantIDs[index] {\n\t\t\tt.Fatalf(\"pickNextMixed() #%d auth.ID = %q, want %q\", index, got.ID, wantIDs[index])\n\t\t}\n\t}\n}\n\nfunc TestManager_PickNextMixed_SkipsProvidersWithoutExecutors(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\tmanager.executors[\"claude\"] = schedulerTestExecutor{}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"gemini-a\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(gemini-a) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"claude-a\", Provider: \"claude\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(claude-a) error = %v\", errRegister)\n\t}\n\n\tgot, _, provider, errPick := manager.pickNextMixed(context.Background(), []string{\"gemini\", \"claude\"}, \"\", cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"pickNextMixed() error = %v\", errPick)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"pickNextMixed() auth = nil\")\n\t}\n\tif provider != \"claude\" {\n\t\tt.Fatalf(\"pickNextMixed() provider = %q, want %q\", provider, \"claude\")\n\t}\n\tif got.ID != \"claude-a\" {\n\t\tt.Fatalf(\"pickNextMixed() auth.ID = %q, want %q\", got.ID, \"claude-a\")\n\t}\n}\n\nfunc TestManager_SchedulerTracksMarkResultCooldownAndRecovery(t *testing.T) {\n\tt.Parallel()\n\n\tmanager := NewManager(nil, &RoundRobinSelector{}, nil)\n\treg := registry.GetGlobalRegistry()\n\treg.RegisterClient(\"auth-a\", \"gemini\", []*registry.ModelInfo{{ID: \"test-model\"}})\n\treg.RegisterClient(\"auth-b\", \"gemini\", []*registry.ModelInfo{{ID: \"test-model\"}})\n\tt.Cleanup(func() {\n\t\treg.UnregisterClient(\"auth-a\")\n\t\treg.UnregisterClient(\"auth-b\")\n\t})\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"auth-a\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(auth-a) error = %v\", errRegister)\n\t}\n\tif _, errRegister := manager.Register(context.Background(), &Auth{ID: \"auth-b\", Provider: \"gemini\"}); errRegister != nil {\n\t\tt.Fatalf(\"Register(auth-b) error = %v\", errRegister)\n\t}\n\n\tmanager.MarkResult(context.Background(), Result{\n\t\tAuthID:   \"auth-a\",\n\t\tProvider: \"gemini\",\n\t\tModel:    \"test-model\",\n\t\tSuccess:  false,\n\t\tError:    &Error{HTTPStatus: 429, Message: \"quota\"},\n\t})\n\n\tgot, errPick := manager.scheduler.pickSingle(context.Background(), \"gemini\", \"test-model\", cliproxyexecutor.Options{}, nil)\n\tif errPick != nil {\n\t\tt.Fatalf(\"scheduler.pickSingle() after cooldown error = %v\", errPick)\n\t}\n\tif got == nil || got.ID != \"auth-b\" {\n\t\tt.Fatalf(\"scheduler.pickSingle() after cooldown auth = %v, want auth-b\", got)\n\t}\n\n\tmanager.MarkResult(context.Background(), Result{\n\t\tAuthID:   \"auth-a\",\n\t\tProvider: \"gemini\",\n\t\tModel:    \"test-model\",\n\t\tSuccess:  true,\n\t})\n\n\tseen := make(map[string]struct{}, 2)\n\tfor index := 0; index < 2; index++ {\n\t\tgot, errPick = manager.scheduler.pickSingle(context.Background(), \"gemini\", \"test-model\", cliproxyexecutor.Options{}, nil)\n\t\tif errPick != nil {\n\t\t\tt.Fatalf(\"scheduler.pickSingle() after recovery #%d error = %v\", index, errPick)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"scheduler.pickSingle() after recovery #%d auth = nil\", index)\n\t\t}\n\t\tseen[got.ID] = struct{}{}\n\t}\n\tif len(seen) != 2 {\n\t\tt.Fatalf(\"len(seen) = %d, want %d\", len(seen), 2)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/selector.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"math\"\n\t\"math/rand/v2\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\n// RoundRobinSelector provides a simple provider scoped round-robin selection strategy.\ntype RoundRobinSelector struct {\n\tmu      sync.Mutex\n\tcursors map[string]int\n\tmaxKeys int\n}\n\n// FillFirstSelector selects the first available credential (deterministic ordering).\n// This \"burns\" one account before moving to the next, which can help stagger\n// rolling-window subscription caps (e.g. chat message limits).\ntype FillFirstSelector struct{}\n\ntype blockReason int\n\nconst (\n\tblockReasonNone blockReason = iota\n\tblockReasonCooldown\n\tblockReasonDisabled\n\tblockReasonOther\n)\n\ntype modelCooldownError struct {\n\tmodel    string\n\tresetIn  time.Duration\n\tprovider string\n}\n\nfunc newModelCooldownError(model, provider string, resetIn time.Duration) *modelCooldownError {\n\tif resetIn < 0 {\n\t\tresetIn = 0\n\t}\n\treturn &modelCooldownError{\n\t\tmodel:    model,\n\t\tprovider: provider,\n\t\tresetIn:  resetIn,\n\t}\n}\n\nfunc (e *modelCooldownError) Error() string {\n\tmodelName := e.model\n\tif modelName == \"\" {\n\t\tmodelName = \"requested model\"\n\t}\n\tmessage := fmt.Sprintf(\"All credentials for model %s are cooling down\", modelName)\n\tif e.provider != \"\" {\n\t\tmessage = fmt.Sprintf(\"%s via provider %s\", message, e.provider)\n\t}\n\tresetSeconds := int(math.Ceil(e.resetIn.Seconds()))\n\tif resetSeconds < 0 {\n\t\tresetSeconds = 0\n\t}\n\tdisplayDuration := e.resetIn\n\tif displayDuration > 0 && displayDuration < time.Second {\n\t\tdisplayDuration = time.Second\n\t} else {\n\t\tdisplayDuration = displayDuration.Round(time.Second)\n\t}\n\terrorBody := map[string]any{\n\t\t\"code\":          \"model_cooldown\",\n\t\t\"message\":       message,\n\t\t\"model\":         e.model,\n\t\t\"reset_time\":    displayDuration.String(),\n\t\t\"reset_seconds\": resetSeconds,\n\t}\n\tif e.provider != \"\" {\n\t\terrorBody[\"provider\"] = e.provider\n\t}\n\tpayload := map[string]any{\"error\": errorBody}\n\tdata, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Sprintf(`{\"error\":{\"code\":\"model_cooldown\",\"message\":\"%s\"}}`, message)\n\t}\n\treturn string(data)\n}\n\nfunc (e *modelCooldownError) StatusCode() int {\n\treturn http.StatusTooManyRequests\n}\n\nfunc (e *modelCooldownError) Headers() http.Header {\n\theaders := make(http.Header)\n\theaders.Set(\"Content-Type\", \"application/json\")\n\tresetSeconds := int(math.Ceil(e.resetIn.Seconds()))\n\tif resetSeconds < 0 {\n\t\tresetSeconds = 0\n\t}\n\theaders.Set(\"Retry-After\", strconv.Itoa(resetSeconds))\n\treturn headers\n}\n\nfunc authPriority(auth *Auth) int {\n\tif auth == nil || auth.Attributes == nil {\n\t\treturn 0\n\t}\n\traw := strings.TrimSpace(auth.Attributes[\"priority\"])\n\tif raw == \"\" {\n\t\treturn 0\n\t}\n\tparsed, err := strconv.Atoi(raw)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn parsed\n}\n\nfunc canonicalModelKey(model string) string {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn \"\"\n\t}\n\tparsed := thinking.ParseSuffix(model)\n\tmodelName := strings.TrimSpace(parsed.ModelName)\n\tif modelName == \"\" {\n\t\treturn model\n\t}\n\treturn modelName\n}\n\nfunc authWebsocketsEnabled(auth *Auth) bool {\n\tif auth == nil {\n\t\treturn false\n\t}\n\tif len(auth.Attributes) > 0 {\n\t\tif raw := strings.TrimSpace(auth.Attributes[\"websockets\"]); raw != \"\" {\n\t\t\tparsed, errParse := strconv.ParseBool(raw)\n\t\t\tif errParse == nil {\n\t\t\t\treturn parsed\n\t\t\t}\n\t\t}\n\t}\n\tif len(auth.Metadata) == 0 {\n\t\treturn false\n\t}\n\traw, ok := auth.Metadata[\"websockets\"]\n\tif !ok || raw == nil {\n\t\treturn false\n\t}\n\tswitch v := raw.(type) {\n\tcase bool:\n\t\treturn v\n\tcase string:\n\t\tparsed, errParse := strconv.ParseBool(strings.TrimSpace(v))\n\t\tif errParse == nil {\n\t\t\treturn parsed\n\t\t}\n\tdefault:\n\t}\n\treturn false\n}\n\nfunc preferCodexWebsocketAuths(ctx context.Context, provider string, available []*Auth) []*Auth {\n\tif len(available) == 0 {\n\t\treturn available\n\t}\n\tif !cliproxyexecutor.DownstreamWebsocket(ctx) {\n\t\treturn available\n\t}\n\tif !strings.EqualFold(strings.TrimSpace(provider), \"codex\") {\n\t\treturn available\n\t}\n\n\twsEnabled := make([]*Auth, 0, len(available))\n\tfor i := 0; i < len(available); i++ {\n\t\tcandidate := available[i]\n\t\tif authWebsocketsEnabled(candidate) {\n\t\t\twsEnabled = append(wsEnabled, candidate)\n\t\t}\n\t}\n\tif len(wsEnabled) > 0 {\n\t\treturn wsEnabled\n\t}\n\treturn available\n}\n\nfunc collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {\n\tavailable = make(map[int][]*Auth)\n\tfor i := 0; i < len(auths); i++ {\n\t\tcandidate := auths[i]\n\t\tblocked, reason, next := isAuthBlockedForModel(candidate, model, now)\n\t\tif !blocked {\n\t\t\tpriority := authPriority(candidate)\n\t\t\tavailable[priority] = append(available[priority], candidate)\n\t\t\tcontinue\n\t\t}\n\t\tif reason == blockReasonCooldown {\n\t\t\tcooldownCount++\n\t\t\tif !next.IsZero() && (earliest.IsZero() || next.Before(earliest)) {\n\t\t\t\tearliest = next\n\t\t\t}\n\t\t}\n\t}\n\treturn available, cooldownCount, earliest\n}\n\nfunc getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]*Auth, error) {\n\tif len(auths) == 0 {\n\t\treturn nil, &Error{Code: \"auth_not_found\", Message: \"no auth candidates\"}\n\t}\n\n\tavailableByPriority, cooldownCount, earliest := collectAvailableByPriority(auths, model, now)\n\tif len(availableByPriority) == 0 {\n\t\tif cooldownCount == len(auths) && !earliest.IsZero() {\n\t\t\tproviderForError := provider\n\t\t\tif providerForError == \"mixed\" {\n\t\t\t\tproviderForError = \"\"\n\t\t\t}\n\t\t\tresetIn := earliest.Sub(now)\n\t\t\tif resetIn < 0 {\n\t\t\t\tresetIn = 0\n\t\t\t}\n\t\t\treturn nil, newModelCooldownError(model, providerForError, resetIn)\n\t\t}\n\t\treturn nil, &Error{Code: \"auth_unavailable\", Message: \"no auth available\"}\n\t}\n\n\tbestPriority := 0\n\tfound := false\n\tfor priority := range availableByPriority {\n\t\tif !found || priority > bestPriority {\n\t\t\tbestPriority = priority\n\t\t\tfound = true\n\t\t}\n\t}\n\n\tavailable := availableByPriority[bestPriority]\n\tif len(available) > 1 {\n\t\tsort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })\n\t}\n\treturn available, nil\n}\n\n// Pick selects the next available auth for the provider in a round-robin manner.\n// For gemini-cli virtual auths (identified by the gemini_virtual_parent attribute),\n// a two-level round-robin is used: first cycling across credential groups (parent\n// accounts), then cycling within each group's project auths.\nfunc (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {\n\t_ = opts\n\tnow := time.Now()\n\tavailable, err := getAvailableAuths(auths, provider, model, now)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tavailable = preferCodexWebsocketAuths(ctx, provider, available)\n\tkey := provider + \":\" + canonicalModelKey(model)\n\ts.mu.Lock()\n\tif s.cursors == nil {\n\t\ts.cursors = make(map[string]int)\n\t}\n\tlimit := s.maxKeys\n\tif limit <= 0 {\n\t\tlimit = 4096\n\t}\n\n\t// Check if any available auth has gemini_virtual_parent attribute,\n\t// indicating gemini-cli virtual auths that should use credential-level polling.\n\tgroups, parentOrder := groupByVirtualParent(available)\n\tif len(parentOrder) > 1 {\n\t\t// Two-level round-robin: first select a credential group, then pick within it.\n\t\tgroupKey := key + \"::group\"\n\t\ts.ensureCursorKey(groupKey, limit)\n\t\tif _, exists := s.cursors[groupKey]; !exists {\n\t\t\t// Seed with a random initial offset so the starting credential is randomized.\n\t\t\ts.cursors[groupKey] = rand.IntN(len(parentOrder))\n\t\t}\n\t\tgroupIndex := s.cursors[groupKey]\n\t\tif groupIndex >= 2_147_483_640 {\n\t\t\tgroupIndex = 0\n\t\t}\n\t\ts.cursors[groupKey] = groupIndex + 1\n\n\t\tselectedParent := parentOrder[groupIndex%len(parentOrder)]\n\t\tgroup := groups[selectedParent]\n\n\t\t// Second level: round-robin within the selected credential group.\n\t\tinnerKey := key + \"::cred:\" + selectedParent\n\t\ts.ensureCursorKey(innerKey, limit)\n\t\tinnerIndex := s.cursors[innerKey]\n\t\tif innerIndex >= 2_147_483_640 {\n\t\t\tinnerIndex = 0\n\t\t}\n\t\ts.cursors[innerKey] = innerIndex + 1\n\t\ts.mu.Unlock()\n\t\treturn group[innerIndex%len(group)], nil\n\t}\n\n\t// Flat round-robin for non-grouped auths (original behavior).\n\ts.ensureCursorKey(key, limit)\n\tindex := s.cursors[key]\n\tif index >= 2_147_483_640 {\n\t\tindex = 0\n\t}\n\ts.cursors[key] = index + 1\n\ts.mu.Unlock()\n\treturn available[index%len(available)], nil\n}\n\n// ensureCursorKey ensures the cursor map has capacity for the given key.\n// Must be called with s.mu held.\nfunc (s *RoundRobinSelector) ensureCursorKey(key string, limit int) {\n\tif _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {\n\t\ts.cursors = make(map[string]int)\n\t}\n}\n\n// groupByVirtualParent groups auths by their gemini_virtual_parent attribute.\n// Returns a map of parentID -> auths and a sorted slice of parent IDs for stable iteration.\n// Only auths with a non-empty gemini_virtual_parent are grouped; if any auth lacks\n// this attribute, nil/nil is returned so the caller falls back to flat round-robin.\nfunc groupByVirtualParent(auths []*Auth) (map[string][]*Auth, []string) {\n\tif len(auths) == 0 {\n\t\treturn nil, nil\n\t}\n\tgroups := make(map[string][]*Auth)\n\tfor _, a := range auths {\n\t\tparent := \"\"\n\t\tif a.Attributes != nil {\n\t\t\tparent = strings.TrimSpace(a.Attributes[\"gemini_virtual_parent\"])\n\t\t}\n\t\tif parent == \"\" {\n\t\t\t// Non-virtual auth present; fall back to flat round-robin.\n\t\t\treturn nil, nil\n\t\t}\n\t\tgroups[parent] = append(groups[parent], a)\n\t}\n\t// Collect parent IDs in sorted order for stable cursor indexing.\n\tparentOrder := make([]string, 0, len(groups))\n\tfor p := range groups {\n\t\tparentOrder = append(parentOrder, p)\n\t}\n\tsort.Strings(parentOrder)\n\treturn groups, parentOrder\n}\n\n// Pick selects the first available auth for the provider in a deterministic manner.\nfunc (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {\n\t_ = opts\n\tnow := time.Now()\n\tavailable, err := getAvailableAuths(auths, provider, model, now)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tavailable = preferCodexWebsocketAuths(ctx, provider, available)\n\treturn available[0], nil\n}\n\nfunc isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, blockReason, time.Time) {\n\tif auth == nil {\n\t\treturn true, blockReasonOther, time.Time{}\n\t}\n\tif auth.Disabled || auth.Status == StatusDisabled {\n\t\treturn true, blockReasonDisabled, time.Time{}\n\t}\n\tif model != \"\" {\n\t\tif len(auth.ModelStates) > 0 {\n\t\t\tstate, ok := auth.ModelStates[model]\n\t\t\tif (!ok || state == nil) && model != \"\" {\n\t\t\t\tbaseModel := canonicalModelKey(model)\n\t\t\t\tif baseModel != \"\" && baseModel != model {\n\t\t\t\t\tstate, ok = auth.ModelStates[baseModel]\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ok && state != nil {\n\t\t\t\tif state.Status == StatusDisabled {\n\t\t\t\t\treturn true, blockReasonDisabled, time.Time{}\n\t\t\t\t}\n\t\t\t\tif state.Unavailable {\n\t\t\t\t\tif state.NextRetryAfter.IsZero() {\n\t\t\t\t\t\treturn false, blockReasonNone, time.Time{}\n\t\t\t\t\t}\n\t\t\t\t\tif state.NextRetryAfter.After(now) {\n\t\t\t\t\t\tnext := state.NextRetryAfter\n\t\t\t\t\t\tif !state.Quota.NextRecoverAt.IsZero() && state.Quota.NextRecoverAt.After(now) {\n\t\t\t\t\t\t\tnext = state.Quota.NextRecoverAt\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif next.Before(now) {\n\t\t\t\t\t\t\tnext = now\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif state.Quota.Exceeded {\n\t\t\t\t\t\t\treturn true, blockReasonCooldown, next\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true, blockReasonOther, next\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn false, blockReasonNone, time.Time{}\n\t\t\t}\n\t\t}\n\t\treturn false, blockReasonNone, time.Time{}\n\t}\n\tif auth.Unavailable && auth.NextRetryAfter.After(now) {\n\t\tnext := auth.NextRetryAfter\n\t\tif !auth.Quota.NextRecoverAt.IsZero() && auth.Quota.NextRecoverAt.After(now) {\n\t\t\tnext = auth.Quota.NextRecoverAt\n\t\t}\n\t\tif next.Before(now) {\n\t\t\tnext = now\n\t\t}\n\t\tif auth.Quota.Exceeded {\n\t\t\treturn true, blockReasonCooldown, next\n\t\t}\n\t\treturn true, blockReasonOther, next\n\t}\n\treturn false, blockReasonNone, time.Time{}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/selector_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n)\n\nfunc TestFillFirstSelectorPick_Deterministic(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &FillFirstSelector{}\n\tauths := []*Auth{\n\t\t{ID: \"b\"},\n\t\t{ID: \"a\"},\n\t\t{ID: \"c\"},\n\t}\n\n\tgot, err := selector.Pick(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, auths)\n\tif err != nil {\n\t\tt.Fatalf(\"Pick() error = %v\", err)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"Pick() auth = nil\")\n\t}\n\tif got.ID != \"a\" {\n\t\tt.Fatalf(\"Pick() auth.ID = %q, want %q\", got.ID, \"a\")\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_CyclesDeterministic(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\tauths := []*Auth{\n\t\t{ID: \"b\"},\n\t\t{ID: \"a\"},\n\t\t{ID: \"c\"},\n\t}\n\n\twant := []string{\"a\", \"b\", \"c\", \"a\", \"b\"}\n\tfor i, id := range want {\n\t\tgot, err := selector.Pick(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, auths)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Pick() #%d error = %v\", i, err)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"Pick() #%d auth = nil\", i)\n\t\t}\n\t\tif got.ID != id {\n\t\t\tt.Fatalf(\"Pick() #%d auth.ID = %q, want %q\", i, got.ID, id)\n\t\t}\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_PriorityBuckets(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\tauths := []*Auth{\n\t\t{ID: \"c\", Attributes: map[string]string{\"priority\": \"0\"}},\n\t\t{ID: \"a\", Attributes: map[string]string{\"priority\": \"10\"}},\n\t\t{ID: \"b\", Attributes: map[string]string{\"priority\": \"10\"}},\n\t}\n\n\twant := []string{\"a\", \"b\", \"a\", \"b\"}\n\tfor i, id := range want {\n\t\tgot, err := selector.Pick(context.Background(), \"mixed\", \"\", cliproxyexecutor.Options{}, auths)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Pick() #%d error = %v\", i, err)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"Pick() #%d auth = nil\", i)\n\t\t}\n\t\tif got.ID != id {\n\t\t\tt.Fatalf(\"Pick() #%d auth.ID = %q, want %q\", i, got.ID, id)\n\t\t}\n\t\tif got.ID == \"c\" {\n\t\t\tt.Fatalf(\"Pick() #%d unexpectedly selected lower priority auth\", i)\n\t\t}\n\t}\n}\n\nfunc TestFillFirstSelectorPick_PriorityFallbackCooldown(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &FillFirstSelector{}\n\tnow := time.Now()\n\tmodel := \"test-model\"\n\n\thigh := &Auth{\n\t\tID:         \"high\",\n\t\tAttributes: map[string]string{\"priority\": \"10\"},\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tStatus:         StatusActive,\n\t\t\t\tUnavailable:    true,\n\t\t\t\tNextRetryAfter: now.Add(30 * time.Minute),\n\t\t\t\tQuota: QuotaState{\n\t\t\t\t\tExceeded: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tlow := &Auth{ID: \"low\", Attributes: map[string]string{\"priority\": \"0\"}}\n\n\tgot, err := selector.Pick(context.Background(), \"mixed\", model, cliproxyexecutor.Options{}, []*Auth{high, low})\n\tif err != nil {\n\t\tt.Fatalf(\"Pick() error = %v\", err)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"Pick() auth = nil\")\n\t}\n\tif got.ID != \"low\" {\n\t\tt.Fatalf(\"Pick() auth.ID = %q, want %q\", got.ID, \"low\")\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_Concurrent(t *testing.T) {\n\tselector := &RoundRobinSelector{}\n\tauths := []*Auth{\n\t\t{ID: \"b\"},\n\t\t{ID: \"a\"},\n\t\t{ID: \"c\"},\n\t}\n\n\tstart := make(chan struct{})\n\tvar wg sync.WaitGroup\n\terrCh := make(chan error, 1)\n\n\tgoroutines := 32\n\titerations := 100\n\tfor i := 0; i < goroutines; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\t<-start\n\t\t\tfor j := 0; j < iterations; j++ {\n\t\t\t\tgot, err := selector.Pick(context.Background(), \"gemini\", \"\", cliproxyexecutor.Options{}, auths)\n\t\t\t\tif err != nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase errCh <- err:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif got == nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase errCh <- errors.New(\"Pick() returned nil auth\"):\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif got.ID == \"\" {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase errCh <- errors.New(\"Pick() returned auth with empty ID\"):\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\tclose(start)\n\twg.Wait()\n\n\tselect {\n\tcase err := <-errCh:\n\t\tt.Fatalf(\"concurrent Pick() error = %v\", err)\n\tdefault:\n\t}\n}\n\nfunc TestSelectorPick_AllCooldownReturnsModelCooldownError(t *testing.T) {\n\tt.Parallel()\n\n\tmodel := \"test-model\"\n\tnow := time.Now()\n\tnext := now.Add(60 * time.Second)\n\tauths := []*Auth{\n\t\t{\n\t\t\tID: \"a\",\n\t\t\tModelStates: map[string]*ModelState{\n\t\t\t\tmodel: {\n\t\t\t\t\tStatus:         StatusActive,\n\t\t\t\t\tUnavailable:    true,\n\t\t\t\t\tNextRetryAfter: next,\n\t\t\t\t\tQuota: QuotaState{\n\t\t\t\t\t\tExceeded:      true,\n\t\t\t\t\t\tNextRecoverAt: next,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tID: \"b\",\n\t\t\tModelStates: map[string]*ModelState{\n\t\t\t\tmodel: {\n\t\t\t\t\tStatus:         StatusActive,\n\t\t\t\t\tUnavailable:    true,\n\t\t\t\t\tNextRetryAfter: next,\n\t\t\t\t\tQuota: QuotaState{\n\t\t\t\t\t\tExceeded:      true,\n\t\t\t\t\t\tNextRecoverAt: next,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tt.Run(\"mixed provider redacts provider field\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tselector := &FillFirstSelector{}\n\t\t_, err := selector.Pick(context.Background(), \"mixed\", model, cliproxyexecutor.Options{}, auths)\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"Pick() error = nil\")\n\t\t}\n\n\t\tvar mce *modelCooldownError\n\t\tif !errors.As(err, &mce) {\n\t\t\tt.Fatalf(\"Pick() error = %T, want *modelCooldownError\", err)\n\t\t}\n\t\tif mce.StatusCode() != http.StatusTooManyRequests {\n\t\t\tt.Fatalf(\"StatusCode() = %d, want %d\", mce.StatusCode(), http.StatusTooManyRequests)\n\t\t}\n\n\t\theaders := mce.Headers()\n\t\tif got := headers.Get(\"Retry-After\"); got == \"\" {\n\t\t\tt.Fatalf(\"Headers().Get(Retry-After) = empty\")\n\t\t}\n\n\t\tvar payload map[string]any\n\t\tif err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {\n\t\t\tt.Fatalf(\"json.Unmarshal(Error()) error = %v\", err)\n\t\t}\n\t\trawErr, ok := payload[\"error\"].(map[string]any)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"Error() payload missing error object: %v\", payload)\n\t\t}\n\t\tif got, _ := rawErr[\"code\"].(string); got != \"model_cooldown\" {\n\t\t\tt.Fatalf(\"Error().error.code = %q, want %q\", got, \"model_cooldown\")\n\t\t}\n\t\tif _, ok := rawErr[\"provider\"]; ok {\n\t\t\tt.Fatalf(\"Error().error.provider exists for mixed provider: %v\", rawErr[\"provider\"])\n\t\t}\n\t})\n\n\tt.Run(\"non-mixed provider includes provider field\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tselector := &FillFirstSelector{}\n\t\t_, err := selector.Pick(context.Background(), \"gemini\", model, cliproxyexecutor.Options{}, auths)\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"Pick() error = nil\")\n\t\t}\n\n\t\tvar mce *modelCooldownError\n\t\tif !errors.As(err, &mce) {\n\t\t\tt.Fatalf(\"Pick() error = %T, want *modelCooldownError\", err)\n\t\t}\n\n\t\tvar payload map[string]any\n\t\tif err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {\n\t\t\tt.Fatalf(\"json.Unmarshal(Error()) error = %v\", err)\n\t\t}\n\t\trawErr, ok := payload[\"error\"].(map[string]any)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"Error() payload missing error object: %v\", payload)\n\t\t}\n\t\tif got, _ := rawErr[\"provider\"].(string); got != \"gemini\" {\n\t\t\tt.Fatalf(\"Error().error.provider = %q, want %q\", got, \"gemini\")\n\t\t}\n\t})\n}\n\nfunc TestIsAuthBlockedForModel_UnavailableWithoutNextRetryIsNotBlocked(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now()\n\tmodel := \"test-model\"\n\tauth := &Auth{\n\t\tID: \"a\",\n\t\tModelStates: map[string]*ModelState{\n\t\t\tmodel: {\n\t\t\t\tStatus:      StatusActive,\n\t\t\t\tUnavailable: true,\n\t\t\t\tQuota: QuotaState{\n\t\t\t\t\tExceeded: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tblocked, reason, next := isAuthBlockedForModel(auth, model, now)\n\tif blocked {\n\t\tt.Fatalf(\"blocked = true, want false\")\n\t}\n\tif reason != blockReasonNone {\n\t\tt.Fatalf(\"reason = %v, want %v\", reason, blockReasonNone)\n\t}\n\tif !next.IsZero() {\n\t\tt.Fatalf(\"next = %v, want zero\", next)\n\t}\n}\n\nfunc TestFillFirstSelectorPick_ThinkingSuffixFallsBackToBaseModelState(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &FillFirstSelector{}\n\tnow := time.Now()\n\n\tbaseModel := \"test-model\"\n\trequestedModel := \"test-model(high)\"\n\n\thigh := &Auth{\n\t\tID:         \"high\",\n\t\tAttributes: map[string]string{\"priority\": \"10\"},\n\t\tModelStates: map[string]*ModelState{\n\t\t\tbaseModel: {\n\t\t\t\tStatus:         StatusActive,\n\t\t\t\tUnavailable:    true,\n\t\t\t\tNextRetryAfter: now.Add(30 * time.Minute),\n\t\t\t\tQuota: QuotaState{\n\t\t\t\t\tExceeded: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tlow := &Auth{\n\t\tID:         \"low\",\n\t\tAttributes: map[string]string{\"priority\": \"0\"},\n\t}\n\n\tgot, err := selector.Pick(context.Background(), \"mixed\", requestedModel, cliproxyexecutor.Options{}, []*Auth{high, low})\n\tif err != nil {\n\t\tt.Fatalf(\"Pick() error = %v\", err)\n\t}\n\tif got == nil {\n\t\tt.Fatalf(\"Pick() auth = nil\")\n\t}\n\tif got.ID != \"low\" {\n\t\tt.Fatalf(\"Pick() auth.ID = %q, want %q\", got.ID, \"low\")\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_ThinkingSuffixSharesCursor(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\tauths := []*Auth{\n\t\t{ID: \"b\"},\n\t\t{ID: \"a\"},\n\t}\n\n\tfirst, err := selector.Pick(context.Background(), \"gemini\", \"test-model(high)\", cliproxyexecutor.Options{}, auths)\n\tif err != nil {\n\t\tt.Fatalf(\"Pick() first error = %v\", err)\n\t}\n\tsecond, err := selector.Pick(context.Background(), \"gemini\", \"test-model(low)\", cliproxyexecutor.Options{}, auths)\n\tif err != nil {\n\t\tt.Fatalf(\"Pick() second error = %v\", err)\n\t}\n\tif first == nil || second == nil {\n\t\tt.Fatalf(\"Pick() returned nil auth\")\n\t}\n\tif first.ID != \"a\" {\n\t\tt.Fatalf(\"Pick() first auth.ID = %q, want %q\", first.ID, \"a\")\n\t}\n\tif second.ID != \"b\" {\n\t\tt.Fatalf(\"Pick() second auth.ID = %q, want %q\", second.ID, \"b\")\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{maxKeys: 2}\n\tauths := []*Auth{{ID: \"a\"}}\n\n\t_, _ = selector.Pick(context.Background(), \"gemini\", \"m1\", cliproxyexecutor.Options{}, auths)\n\t_, _ = selector.Pick(context.Background(), \"gemini\", \"m2\", cliproxyexecutor.Options{}, auths)\n\t_, _ = selector.Pick(context.Background(), \"gemini\", \"m3\", cliproxyexecutor.Options{}, auths)\n\n\tselector.mu.Lock()\n\tdefer selector.mu.Unlock()\n\n\tif selector.cursors == nil {\n\t\tt.Fatalf(\"selector.cursors = nil\")\n\t}\n\tif len(selector.cursors) != 1 {\n\t\tt.Fatalf(\"len(selector.cursors) = %d, want %d\", len(selector.cursors), 1)\n\t}\n\tif _, ok := selector.cursors[\"gemini:m3\"]; !ok {\n\t\tt.Fatalf(\"selector.cursors missing key %q\", \"gemini:m3\")\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_GeminiCLICredentialGrouping(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\n\t// Simulate two gemini-cli credentials, each with multiple projects:\n\t// Credential A (parent = \"cred-a.json\") has 3 projects\n\t// Credential B (parent = \"cred-b.json\") has 2 projects\n\tauths := []*Auth{\n\t\t{ID: \"cred-a.json::proj-a1\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-a.json::proj-a2\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-a.json::proj-a3\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-b.json::proj-b1\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-b.json\"}},\n\t\t{ID: \"cred-b.json::proj-b2\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-b.json\"}},\n\t}\n\n\t// Two-level round-robin: consecutive picks must alternate between credentials.\n\t// Credential group order is randomized, but within each call the group cursor\n\t// advances by 1, so consecutive picks should cycle through different parents.\n\tpicks := make([]string, 6)\n\tparents := make([]string, 6)\n\tfor i := 0; i < 6; i++ {\n\t\tgot, err := selector.Pick(context.Background(), \"gemini-cli\", \"gemini-2.5-pro\", cliproxyexecutor.Options{}, auths)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Pick() #%d error = %v\", i, err)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"Pick() #%d auth = nil\", i)\n\t\t}\n\t\tpicks[i] = got.ID\n\t\tparents[i] = got.Attributes[\"gemini_virtual_parent\"]\n\t}\n\n\t// Verify property: consecutive picks must alternate between credential groups.\n\tfor i := 1; i < len(parents); i++ {\n\t\tif parents[i] == parents[i-1] {\n\t\t\tt.Fatalf(\"Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials\",\n\t\t\t\ti-1, i, parents[i], picks[i-1], picks[i])\n\t\t}\n\t}\n\n\t// Verify property: each credential's projects are picked in sequence (round-robin within group).\n\tcredPicks := map[string][]string{}\n\tfor i, id := range picks {\n\t\tcredPicks[parents[i]] = append(credPicks[parents[i]], id)\n\t}\n\tfor parent, ids := range credPicks {\n\t\tfor i := 1; i < len(ids); i++ {\n\t\t\tif ids[i] == ids[i-1] {\n\t\t\t\tt.Fatalf(\"Credential %q picked same project %q twice in a row\", parent, ids[i])\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_SingleParentFallsBackToFlat(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\n\t// All auths from the same parent - should fall back to flat round-robin\n\t// because there's only one credential group (no benefit from two-level).\n\tauths := []*Auth{\n\t\t{ID: \"cred-a.json::proj-a1\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-a.json::proj-a2\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-a.json::proj-a3\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t}\n\n\t// With single parent group, parentOrder has length 1, so it uses flat round-robin.\n\t// Sorted by ID: proj-a1, proj-a2, proj-a3\n\twant := []string{\n\t\t\"cred-a.json::proj-a1\",\n\t\t\"cred-a.json::proj-a2\",\n\t\t\"cred-a.json::proj-a3\",\n\t\t\"cred-a.json::proj-a1\",\n\t}\n\n\tfor i, expectedID := range want {\n\t\tgot, err := selector.Pick(context.Background(), \"gemini-cli\", \"gemini-2.5-pro\", cliproxyexecutor.Options{}, auths)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Pick() #%d error = %v\", i, err)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"Pick() #%d auth = nil\", i)\n\t\t}\n\t\tif got.ID != expectedID {\n\t\t\tt.Fatalf(\"Pick() #%d auth.ID = %q, want %q\", i, got.ID, expectedID)\n\t\t}\n\t}\n}\n\nfunc TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat(t *testing.T) {\n\tt.Parallel()\n\n\tselector := &RoundRobinSelector{}\n\n\t// Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects\n\t// alongside virtual ones). Should fall back to flat round-robin.\n\tauths := []*Auth{\n\t\t{ID: \"cred-a.json::proj-a1\", Attributes: map[string]string{\"gemini_virtual_parent\": \"cred-a.json\"}},\n\t\t{ID: \"cred-regular.json\"}, // no gemini_virtual_parent\n\t}\n\n\t// groupByVirtualParent returns nil when any auth lacks the attribute,\n\t// so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json\n\twant := []string{\n\t\t\"cred-a.json::proj-a1\",\n\t\t\"cred-regular.json\",\n\t\t\"cred-a.json::proj-a1\",\n\t}\n\n\tfor i, expectedID := range want {\n\t\tgot, err := selector.Pick(context.Background(), \"gemini-cli\", \"\", cliproxyexecutor.Options{}, auths)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Pick() #%d error = %v\", i, err)\n\t\t}\n\t\tif got == nil {\n\t\t\tt.Fatalf(\"Pick() #%d auth = nil\", i)\n\t\t}\n\t\tif got.ID != expectedID {\n\t\t\tt.Fatalf(\"Pick() #%d auth.ID = %q, want %q\", i, got.ID, expectedID)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/status.go",
    "content": "package auth\n\n// Status represents the lifecycle state of an Auth entry.\ntype Status string\n\nconst (\n\t// StatusUnknown means the auth state could not be determined.\n\tStatusUnknown Status = \"unknown\"\n\t// StatusActive indicates the auth is valid and ready for execution.\n\tStatusActive Status = \"active\"\n\t// StatusPending indicates the auth is waiting for an external action, such as MFA.\n\tStatusPending Status = \"pending\"\n\t// StatusRefreshing indicates the auth is undergoing a refresh flow.\n\tStatusRefreshing Status = \"refreshing\"\n\t// StatusError indicates the auth is temporarily unavailable due to errors.\n\tStatusError Status = \"error\"\n\t// StatusDisabled marks the auth as intentionally disabled.\n\tStatusDisabled Status = \"disabled\"\n)\n"
  },
  {
    "path": "sdk/cliproxy/auth/store.go",
    "content": "package auth\n\nimport \"context\"\n\n// Store abstracts persistence of Auth state across restarts.\ntype Store interface {\n\t// List returns all auth records stored in the backend.\n\tList(ctx context.Context) ([]*Auth, error)\n\t// Save persists the provided auth record, replacing any existing one with same ID.\n\tSave(ctx context.Context, auth *Auth) (string, error)\n\t// Delete removes the auth record identified by id.\n\tDelete(ctx context.Context, id string) error\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/types.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tbaseauth \"github.com/router-for-me/CLIProxyAPI/v6/internal/auth\"\n)\n\n// PostAuthHook defines a function that is called after an Auth record is created\n// but before it is persisted to storage. This allows for modification of the\n// Auth record (e.g., injecting metadata) based on external context.\ntype PostAuthHook func(context.Context, *Auth) error\n\n// RequestInfo holds information extracted from the HTTP request.\n// It is injected into the context passed to PostAuthHook.\ntype RequestInfo struct {\n\tQuery   url.Values\n\tHeaders http.Header\n}\n\ntype requestInfoKey struct{}\n\n// WithRequestInfo returns a new context with the given RequestInfo attached.\nfunc WithRequestInfo(ctx context.Context, info *RequestInfo) context.Context {\n\treturn context.WithValue(ctx, requestInfoKey{}, info)\n}\n\n// GetRequestInfo retrieves the RequestInfo from the context, if present.\nfunc GetRequestInfo(ctx context.Context) *RequestInfo {\n\tif val, ok := ctx.Value(requestInfoKey{}).(*RequestInfo); ok {\n\t\treturn val\n\t}\n\treturn nil\n}\n\n// Auth encapsulates the runtime state and metadata associated with a single credential.\ntype Auth struct {\n\t// ID uniquely identifies the auth record across restarts.\n\tID string `json:\"id\"`\n\t// Index is a stable runtime identifier derived from auth metadata (not persisted).\n\tIndex string `json:\"-\"`\n\t// Provider is the upstream provider key (e.g. \"gemini\", \"claude\").\n\tProvider string `json:\"provider\"`\n\t// Prefix optionally namespaces models for routing (e.g., \"teamA/gemini-3-pro-preview\").\n\tPrefix string `json:\"prefix,omitempty\"`\n\t// FileName stores the relative or absolute path of the backing auth file.\n\tFileName string `json:\"-\"`\n\t// Storage holds the token persistence implementation used during login flows.\n\tStorage baseauth.TokenStorage `json:\"-\"`\n\t// Label is an optional human readable label for logging.\n\tLabel string `json:\"label,omitempty\"`\n\t// Status is the lifecycle status managed by the AuthManager.\n\tStatus Status `json:\"status\"`\n\t// StatusMessage holds a short description for the current status.\n\tStatusMessage string `json:\"status_message,omitempty\"`\n\t// Disabled indicates the auth is intentionally disabled by operator.\n\tDisabled bool `json:\"disabled\"`\n\t// Unavailable flags transient provider unavailability (e.g. quota exceeded).\n\tUnavailable bool `json:\"unavailable\"`\n\t// ProxyURL overrides the global proxy setting for this auth if provided.\n\tProxyURL string `json:\"proxy_url,omitempty\"`\n\t// Attributes stores provider specific metadata needed by executors (immutable configuration).\n\tAttributes map[string]string `json:\"attributes,omitempty\"`\n\t// Metadata stores runtime mutable provider state (e.g. tokens, cookies).\n\tMetadata map[string]any `json:\"metadata,omitempty\"`\n\t// Quota captures recent quota information for load balancers.\n\tQuota QuotaState `json:\"quota\"`\n\t// LastError stores the last failure encountered while executing or refreshing.\n\tLastError *Error `json:\"last_error,omitempty\"`\n\t// CreatedAt is the creation timestamp in UTC.\n\tCreatedAt time.Time `json:\"created_at\"`\n\t// UpdatedAt is the last modification timestamp in UTC.\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\t// LastRefreshedAt records the last successful refresh time in UTC.\n\tLastRefreshedAt time.Time `json:\"last_refreshed_at\"`\n\t// NextRefreshAfter is the earliest time a refresh should retrigger.\n\tNextRefreshAfter time.Time `json:\"next_refresh_after\"`\n\t// NextRetryAfter is the earliest time a retry should retrigger.\n\tNextRetryAfter time.Time `json:\"next_retry_after\"`\n\t// ModelStates tracks per-model runtime availability data.\n\tModelStates map[string]*ModelState `json:\"model_states,omitempty\"`\n\n\t// Runtime carries non-serialisable data used during execution (in-memory only).\n\tRuntime any `json:\"-\"`\n\n\tindexAssigned bool `json:\"-\"`\n}\n\n// QuotaState contains limiter tracking data for a credential.\ntype QuotaState struct {\n\t// Exceeded indicates the credential recently hit a quota error.\n\tExceeded bool `json:\"exceeded\"`\n\t// Reason provides an optional provider specific human readable description.\n\tReason string `json:\"reason,omitempty\"`\n\t// NextRecoverAt is when the credential may become available again.\n\tNextRecoverAt time.Time `json:\"next_recover_at\"`\n\t// BackoffLevel stores the progressive cooldown exponent used for rate limits.\n\tBackoffLevel int `json:\"backoff_level,omitempty\"`\n}\n\n// ModelState captures the execution state for a specific model under an auth entry.\ntype ModelState struct {\n\t// Status reflects the lifecycle status for this model.\n\tStatus Status `json:\"status\"`\n\t// StatusMessage provides an optional short description of the status.\n\tStatusMessage string `json:\"status_message,omitempty\"`\n\t// Unavailable mirrors whether the model is temporarily blocked for retries.\n\tUnavailable bool `json:\"unavailable\"`\n\t// NextRetryAfter defines the per-model retry time.\n\tNextRetryAfter time.Time `json:\"next_retry_after\"`\n\t// LastError records the latest error observed for this model.\n\tLastError *Error `json:\"last_error,omitempty\"`\n\t// Quota retains quota information if this model hit rate limits.\n\tQuota QuotaState `json:\"quota\"`\n\t// UpdatedAt tracks the last update timestamp for this model state.\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation.\nfunc (a *Auth) Clone() *Auth {\n\tif a == nil {\n\t\treturn nil\n\t}\n\tcopyAuth := *a\n\tif len(a.Attributes) > 0 {\n\t\tcopyAuth.Attributes = make(map[string]string, len(a.Attributes))\n\t\tfor key, value := range a.Attributes {\n\t\t\tcopyAuth.Attributes[key] = value\n\t\t}\n\t}\n\tif len(a.Metadata) > 0 {\n\t\tcopyAuth.Metadata = make(map[string]any, len(a.Metadata))\n\t\tfor key, value := range a.Metadata {\n\t\t\tcopyAuth.Metadata[key] = value\n\t\t}\n\t}\n\tif len(a.ModelStates) > 0 {\n\t\tcopyAuth.ModelStates = make(map[string]*ModelState, len(a.ModelStates))\n\t\tfor key, state := range a.ModelStates {\n\t\t\tcopyAuth.ModelStates[key] = state.Clone()\n\t\t}\n\t}\n\tcopyAuth.Runtime = a.Runtime\n\treturn &copyAuth\n}\n\nfunc stableAuthIndex(seed string) string {\n\tseed = strings.TrimSpace(seed)\n\tif seed == \"\" {\n\t\treturn \"\"\n\t}\n\tsum := sha256.Sum256([]byte(seed))\n\treturn hex.EncodeToString(sum[:8])\n}\n\nfunc (a *Auth) indexSeed() string {\n\tif a == nil {\n\t\treturn \"\"\n\t}\n\n\tif fileName := strings.TrimSpace(a.FileName); fileName != \"\" {\n\t\treturn \"file:\" + fileName\n\t}\n\n\tproviderKey := strings.ToLower(strings.TrimSpace(a.Provider))\n\tcompatName := \"\"\n\tbaseURL := \"\"\n\tapiKey := \"\"\n\tsource := \"\"\n\tif a.Attributes != nil {\n\t\tif value := strings.TrimSpace(a.Attributes[\"provider_key\"]); value != \"\" {\n\t\t\tproviderKey = strings.ToLower(value)\n\t\t}\n\t\tcompatName = strings.ToLower(strings.TrimSpace(a.Attributes[\"compat_name\"]))\n\t\tbaseURL = strings.TrimSpace(a.Attributes[\"base_url\"])\n\t\tapiKey = strings.TrimSpace(a.Attributes[\"api_key\"])\n\t\tsource = strings.TrimSpace(a.Attributes[\"source\"])\n\t}\n\n\tproxyURL := strings.TrimSpace(a.ProxyURL)\n\thasCredentialIdentity := compatName != \"\" || baseURL != \"\" || proxyURL != \"\" || apiKey != \"\" || source != \"\"\n\tif providerKey != \"\" && hasCredentialIdentity {\n\t\tparts := []string{\"provider=\" + providerKey}\n\t\tif compatName != \"\" {\n\t\t\tparts = append(parts, \"compat=\"+compatName)\n\t\t}\n\t\tif baseURL != \"\" {\n\t\t\tparts = append(parts, \"base=\"+baseURL)\n\t\t}\n\t\tif proxyURL != \"\" {\n\t\t\tparts = append(parts, \"proxy=\"+proxyURL)\n\t\t}\n\t\tif apiKey != \"\" {\n\t\t\tparts = append(parts, \"api_key=\"+apiKey)\n\t\t}\n\t\tif source != \"\" {\n\t\t\tparts = append(parts, \"source=\"+source)\n\t\t}\n\t\treturn \"config:\" + strings.Join(parts, \"\\x00\")\n\t}\n\n\tif id := strings.TrimSpace(a.ID); id != \"\" {\n\t\treturn \"id:\" + id\n\t}\n\n\treturn \"\"\n}\n\n// EnsureIndex returns a stable index derived from the auth file name or credential identity.\nfunc (a *Auth) EnsureIndex() string {\n\tif a == nil {\n\t\treturn \"\"\n\t}\n\tif a.indexAssigned && a.Index != \"\" {\n\t\treturn a.Index\n\t}\n\n\tseed := a.indexSeed()\n\tif seed == \"\" {\n\t\treturn \"\"\n\t}\n\n\tidx := stableAuthIndex(seed)\n\ta.Index = idx\n\ta.indexAssigned = true\n\treturn idx\n}\n\n// Clone duplicates a model state including nested error details.\nfunc (m *ModelState) Clone() *ModelState {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tcopyState := *m\n\tif m.LastError != nil {\n\t\tcopyState.LastError = &Error{\n\t\t\tCode:       m.LastError.Code,\n\t\t\tMessage:    m.LastError.Message,\n\t\t\tRetryable:  m.LastError.Retryable,\n\t\t\tHTTPStatus: m.LastError.HTTPStatus,\n\t\t}\n\t}\n\treturn &copyState\n}\n\nfunc (a *Auth) ProxyInfo() string {\n\tif a == nil {\n\t\treturn \"\"\n\t}\n\tproxyStr := strings.TrimSpace(a.ProxyURL)\n\tif proxyStr == \"\" {\n\t\treturn \"\"\n\t}\n\tif idx := strings.Index(proxyStr, \"://\"); idx > 0 {\n\t\treturn \"via \" + proxyStr[:idx] + \" proxy\"\n\t}\n\treturn \"via proxy\"\n}\n\n// DisableCoolingOverride returns the auth-file scoped disable_cooling override when present.\n// The value is read from metadata key \"disable_cooling\" (or legacy \"disable-cooling\").\nfunc (a *Auth) DisableCoolingOverride() (bool, bool) {\n\tif a == nil || a.Metadata == nil {\n\t\treturn false, false\n\t}\n\tif val, ok := a.Metadata[\"disable_cooling\"]; ok {\n\t\tif parsed, okParse := parseBoolAny(val); okParse {\n\t\t\treturn parsed, true\n\t\t}\n\t}\n\tif val, ok := a.Metadata[\"disable-cooling\"]; ok {\n\t\tif parsed, okParse := parseBoolAny(val); okParse {\n\t\t\treturn parsed, true\n\t\t}\n\t}\n\treturn false, false\n}\n\n// ToolPrefixDisabled returns whether the proxy_ tool name prefix should be\n// skipped for this auth. When true, tool names are sent to Anthropic unchanged.\n// The value is read from metadata key \"tool_prefix_disabled\" (or \"tool-prefix-disabled\").\nfunc (a *Auth) ToolPrefixDisabled() bool {\n\tif a == nil || a.Metadata == nil {\n\t\treturn false\n\t}\n\tfor _, key := range []string{\"tool_prefix_disabled\", \"tool-prefix-disabled\"} {\n\t\tif val, ok := a.Metadata[key]; ok {\n\t\t\tif parsed, okParse := parseBoolAny(val); okParse {\n\t\t\t\treturn parsed\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// RequestRetryOverride returns the auth-file scoped request_retry override when present.\n// The value is read from metadata key \"request_retry\" (or legacy \"request-retry\").\nfunc (a *Auth) RequestRetryOverride() (int, bool) {\n\tif a == nil || a.Metadata == nil {\n\t\treturn 0, false\n\t}\n\tif val, ok := a.Metadata[\"request_retry\"]; ok {\n\t\tif parsed, okParse := parseIntAny(val); okParse {\n\t\t\tif parsed < 0 {\n\t\t\t\tparsed = 0\n\t\t\t}\n\t\t\treturn parsed, true\n\t\t}\n\t}\n\tif val, ok := a.Metadata[\"request-retry\"]; ok {\n\t\tif parsed, okParse := parseIntAny(val); okParse {\n\t\t\tif parsed < 0 {\n\t\t\t\tparsed = 0\n\t\t\t}\n\t\t\treturn parsed, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseBoolAny(val any) (bool, bool) {\n\tswitch typed := val.(type) {\n\tcase bool:\n\t\treturn typed, true\n\tcase string:\n\t\ttrimmed := strings.TrimSpace(typed)\n\t\tif trimmed == \"\" {\n\t\t\treturn false, false\n\t\t}\n\t\tparsed, err := strconv.ParseBool(trimmed)\n\t\tif err != nil {\n\t\t\treturn false, false\n\t\t}\n\t\treturn parsed, true\n\tcase float64:\n\t\treturn typed != 0, true\n\tcase json.Number:\n\t\tparsed, err := typed.Int64()\n\t\tif err != nil {\n\t\t\treturn false, false\n\t\t}\n\t\treturn parsed != 0, true\n\tdefault:\n\t\treturn false, false\n\t}\n}\n\nfunc parseIntAny(val any) (int, bool) {\n\tswitch typed := val.(type) {\n\tcase int:\n\t\treturn typed, true\n\tcase int32:\n\t\treturn int(typed), true\n\tcase int64:\n\t\treturn int(typed), true\n\tcase float64:\n\t\treturn int(typed), true\n\tcase json.Number:\n\t\tparsed, err := typed.Int64()\n\t\tif err != nil {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int(parsed), true\n\tcase string:\n\t\ttrimmed := strings.TrimSpace(typed)\n\t\tif trimmed == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tparsed, err := strconv.Atoi(trimmed)\n\t\tif err != nil {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn parsed, true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc (a *Auth) AccountInfo() (string, string) {\n\tif a == nil {\n\t\treturn \"\", \"\"\n\t}\n\t// For Gemini CLI, include project ID in the OAuth account info if present.\n\tif strings.ToLower(a.Provider) == \"gemini-cli\" {\n\t\tif a.Metadata != nil {\n\t\t\temail, _ := a.Metadata[\"email\"].(string)\n\t\t\temail = strings.TrimSpace(email)\n\t\t\tif email != \"\" {\n\t\t\t\tif p, ok := a.Metadata[\"project_id\"].(string); ok {\n\t\t\t\t\tp = strings.TrimSpace(p)\n\t\t\t\t\tif p != \"\" {\n\t\t\t\t\t\treturn \"oauth\", email + \" (\" + p + \")\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn \"oauth\", email\n\t\t\t}\n\t\t}\n\t}\n\n\t// For iFlow provider, prioritize OAuth type if email is present\n\tif strings.ToLower(a.Provider) == \"iflow\" {\n\t\tif a.Metadata != nil {\n\t\t\tif email, ok := a.Metadata[\"email\"].(string); ok {\n\t\t\t\temail = strings.TrimSpace(email)\n\t\t\t\tif email != \"\" {\n\t\t\t\t\treturn \"oauth\", email\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check metadata for email first (OAuth-style auth)\n\tif a.Metadata != nil {\n\t\tif v, ok := a.Metadata[\"email\"].(string); ok {\n\t\t\temail := strings.TrimSpace(v)\n\t\t\tif email != \"\" {\n\t\t\t\treturn \"oauth\", email\n\t\t\t}\n\t\t}\n\t}\n\t// Fall back to API key (API-key auth)\n\tif a.Attributes != nil {\n\t\tif v := a.Attributes[\"api_key\"]; v != \"\" {\n\t\t\treturn \"api_key\", v\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\n// ExpirationTime attempts to extract the credential expiration timestamp from metadata.\n// It inspects common keys such as \"expired\", \"expire\", \"expires_at\", and also\n// nested \"token\" objects to remain compatible with legacy auth file formats.\nfunc (a *Auth) ExpirationTime() (time.Time, bool) {\n\tif a == nil {\n\t\treturn time.Time{}, false\n\t}\n\tif ts, ok := expirationFromMap(a.Metadata); ok {\n\t\treturn ts, true\n\t}\n\treturn time.Time{}, false\n}\n\nvar (\n\trefreshLeadMu        sync.RWMutex\n\trefreshLeadFactories = make(map[string]func() *time.Duration)\n)\n\nfunc RegisterRefreshLeadProvider(provider string, factory func() *time.Duration) {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif provider == \"\" || factory == nil {\n\t\treturn\n\t}\n\trefreshLeadMu.Lock()\n\trefreshLeadFactories[provider] = factory\n\trefreshLeadMu.Unlock()\n}\n\nvar expireKeys = [...]string{\"expired\", \"expire\", \"expires_at\", \"expiresAt\", \"expiry\", \"expires\"}\n\nfunc expirationFromMap(meta map[string]any) (time.Time, bool) {\n\tif meta == nil {\n\t\treturn time.Time{}, false\n\t}\n\tfor _, key := range expireKeys {\n\t\tif v, ok := meta[key]; ok {\n\t\t\tif ts, ok1 := parseTimeValue(v); ok1 {\n\t\t\t\treturn ts, true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, nestedKey := range []string{\"token\", \"Token\"} {\n\t\tif nested, ok := meta[nestedKey]; ok {\n\t\t\tswitch val := nested.(type) {\n\t\t\tcase map[string]any:\n\t\t\t\tif ts, ok1 := expirationFromMap(val); ok1 {\n\t\t\t\t\treturn ts, true\n\t\t\t\t}\n\t\t\tcase map[string]string:\n\t\t\t\ttemp := make(map[string]any, len(val))\n\t\t\t\tfor k, v := range val {\n\t\t\t\t\ttemp[k] = v\n\t\t\t\t}\n\t\t\t\tif ts, ok1 := expirationFromMap(temp); ok1 {\n\t\t\t\t\treturn ts, true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc ProviderRefreshLead(provider string, runtime any) *time.Duration {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tif runtime != nil {\n\t\tif eval, ok := runtime.(interface{ RefreshLead() *time.Duration }); ok {\n\t\t\tif lead := eval.RefreshLead(); lead != nil && *lead > 0 {\n\t\t\t\treturn lead\n\t\t\t}\n\t\t}\n\t}\n\trefreshLeadMu.RLock()\n\tfactory := refreshLeadFactories[provider]\n\trefreshLeadMu.RUnlock()\n\tif factory == nil {\n\t\treturn nil\n\t}\n\tif lead := factory(); lead != nil && *lead > 0 {\n\t\treturn lead\n\t}\n\treturn nil\n}\n\nfunc parseTimeValue(v any) (time.Time, bool) {\n\tswitch value := v.(type) {\n\tcase string:\n\t\ts := strings.TrimSpace(value)\n\t\tif s == \"\" {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\tlayouts := []string{\n\t\t\ttime.RFC3339,\n\t\t\ttime.RFC3339Nano,\n\t\t\t\"2006-01-02 15:04:05\",\n\t\t\t\"2006-01-02 15:04\",\n\t\t\t\"2006-01-02T15:04:05Z07:00\",\n\t\t}\n\t\tfor _, layout := range layouts {\n\t\t\tif ts, err := time.Parse(layout, s); err == nil {\n\t\t\t\treturn ts, true\n\t\t\t}\n\t\t}\n\t\tif unix, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normaliseUnix(unix), true\n\t\t}\n\tcase float64:\n\t\treturn normaliseUnix(int64(value)), true\n\tcase int64:\n\t\treturn normaliseUnix(value), true\n\tcase json.Number:\n\t\tif i, err := value.Int64(); err == nil {\n\t\t\treturn normaliseUnix(i), true\n\t\t}\n\t\tif f, err := value.Float64(); err == nil {\n\t\t\treturn normaliseUnix(int64(f)), true\n\t\t}\n\t}\n\treturn time.Time{}, false\n}\n\nfunc normaliseUnix(raw int64) time.Time {\n\tif raw <= 0 {\n\t\treturn time.Time{}\n\t}\n\t// Heuristic: treat values with millisecond precision (>1e12) accordingly.\n\tif raw > 1_000_000_000_000 {\n\t\treturn time.UnixMilli(raw)\n\t}\n\treturn time.Unix(raw, 0)\n}\n"
  },
  {
    "path": "sdk/cliproxy/auth/types_test.go",
    "content": "package auth\n\nimport \"testing\"\n\nfunc TestToolPrefixDisabled(t *testing.T) {\n\tvar a *Auth\n\tif a.ToolPrefixDisabled() {\n\t\tt.Error(\"nil auth should return false\")\n\t}\n\n\ta = &Auth{}\n\tif a.ToolPrefixDisabled() {\n\t\tt.Error(\"empty auth should return false\")\n\t}\n\n\ta = &Auth{Metadata: map[string]any{\"tool_prefix_disabled\": true}}\n\tif !a.ToolPrefixDisabled() {\n\t\tt.Error(\"should return true when set to true\")\n\t}\n\n\ta = &Auth{Metadata: map[string]any{\"tool_prefix_disabled\": \"true\"}}\n\tif !a.ToolPrefixDisabled() {\n\t\tt.Error(\"should return true when set to string 'true'\")\n\t}\n\n\ta = &Auth{Metadata: map[string]any{\"tool-prefix-disabled\": true}}\n\tif !a.ToolPrefixDisabled() {\n\t\tt.Error(\"should return true with kebab-case key\")\n\t}\n\n\ta = &Auth{Metadata: map[string]any{\"tool_prefix_disabled\": false}}\n\tif a.ToolPrefixDisabled() {\n\t\tt.Error(\"should return false when set to false\")\n\t}\n}\n\nfunc TestEnsureIndexUsesCredentialIdentity(t *testing.T) {\n\tt.Parallel()\n\n\tgeminiAuth := &Auth{\n\t\tProvider: \"gemini\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": \"shared-key\",\n\t\t\t\"source\":  \"config:gemini[abc123]\",\n\t\t},\n\t}\n\tcompatAuth := &Auth{\n\t\tProvider: \"bohe\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\":      \"shared-key\",\n\t\t\t\"compat_name\":  \"bohe\",\n\t\t\t\"provider_key\": \"bohe\",\n\t\t\t\"source\":       \"config:bohe[def456]\",\n\t\t},\n\t}\n\tgeminiAltBase := &Auth{\n\t\tProvider: \"gemini\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\":  \"shared-key\",\n\t\t\t\"base_url\": \"https://alt.example.com\",\n\t\t\t\"source\":   \"config:gemini[ghi789]\",\n\t\t},\n\t}\n\tgeminiDuplicate := &Auth{\n\t\tProvider: \"gemini\",\n\t\tAttributes: map[string]string{\n\t\t\t\"api_key\": \"shared-key\",\n\t\t\t\"source\":  \"config:gemini[abc123-1]\",\n\t\t},\n\t}\n\n\tgeminiIndex := geminiAuth.EnsureIndex()\n\tcompatIndex := compatAuth.EnsureIndex()\n\taltBaseIndex := geminiAltBase.EnsureIndex()\n\tduplicateIndex := geminiDuplicate.EnsureIndex()\n\n\tif geminiIndex == \"\" {\n\t\tt.Fatal(\"gemini index should not be empty\")\n\t}\n\tif compatIndex == \"\" {\n\t\tt.Fatal(\"compat index should not be empty\")\n\t}\n\tif altBaseIndex == \"\" {\n\t\tt.Fatal(\"alt base index should not be empty\")\n\t}\n\tif duplicateIndex == \"\" {\n\t\tt.Fatal(\"duplicate index should not be empty\")\n\t}\n\tif geminiIndex == compatIndex {\n\t\tt.Fatalf(\"shared api key produced duplicate auth_index %q\", geminiIndex)\n\t}\n\tif geminiIndex == altBaseIndex {\n\t\tt.Fatalf(\"same provider/key with different base_url produced duplicate auth_index %q\", geminiIndex)\n\t}\n\tif geminiIndex == duplicateIndex {\n\t\tt.Fatalf(\"duplicate config entries should be separated by source-derived seed, got %q\", geminiIndex)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/builder.go",
    "content": "// Package cliproxy provides the core service implementation for the CLI Proxy API.\n// It includes service lifecycle management, authentication handling, file watching,\n// and integration with various AI service providers through a unified interface.\npackage cliproxy\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\tconfigaccess \"github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\n// Builder constructs a Service instance with customizable providers.\n// It provides a fluent interface for configuring all aspects of the service\n// including authentication, file watching, HTTP server options, and lifecycle hooks.\ntype Builder struct {\n\t// cfg holds the application configuration.\n\tcfg *config.Config\n\n\t// configPath is the path to the configuration file.\n\tconfigPath string\n\n\t// tokenProvider handles loading token-based clients.\n\ttokenProvider TokenClientProvider\n\n\t// apiKeyProvider handles loading API key-based clients.\n\tapiKeyProvider APIKeyClientProvider\n\n\t// watcherFactory creates file watcher instances.\n\twatcherFactory WatcherFactory\n\n\t// hooks provides lifecycle callbacks.\n\thooks Hooks\n\n\t// authManager handles legacy authentication operations.\n\tauthManager *sdkAuth.Manager\n\n\t// accessManager handles request authentication providers.\n\taccessManager *sdkaccess.Manager\n\n\t// coreManager handles core authentication and execution.\n\tcoreManager *coreauth.Manager\n\n\t// serverOptions contains additional server configuration options.\n\tserverOptions []api.ServerOption\n}\n\n// Hooks allows callers to plug into service lifecycle stages.\n// These callbacks provide opportunities to perform custom initialization\n// and cleanup operations during service startup and shutdown.\ntype Hooks struct {\n\t// OnBeforeStart is called before the service starts, allowing configuration\n\t// modifications or additional setup.\n\tOnBeforeStart func(*config.Config)\n\n\t// OnAfterStart is called after the service has started successfully,\n\t// providing access to the service instance for additional operations.\n\tOnAfterStart func(*Service)\n}\n\n// NewBuilder creates a Builder with default dependencies left unset.\n// Use the fluent interface methods to configure the service before calling Build().\n//\n// Returns:\n//   - *Builder: A new builder instance ready for configuration\nfunc NewBuilder() *Builder {\n\treturn &Builder{}\n}\n\n// WithConfig sets the configuration instance used by the service.\n//\n// Parameters:\n//   - cfg: The application configuration\n//\n// Returns:\n//   - *Builder: The builder instance for method chaining\nfunc (b *Builder) WithConfig(cfg *config.Config) *Builder {\n\tb.cfg = cfg\n\treturn b\n}\n\n// WithConfigPath sets the absolute configuration file path used for reload watching.\n//\n// Parameters:\n//   - path: The absolute path to the configuration file\n//\n// Returns:\n//   - *Builder: The builder instance for method chaining\nfunc (b *Builder) WithConfigPath(path string) *Builder {\n\tb.configPath = path\n\treturn b\n}\n\n// WithTokenClientProvider overrides the provider responsible for token-backed clients.\nfunc (b *Builder) WithTokenClientProvider(provider TokenClientProvider) *Builder {\n\tb.tokenProvider = provider\n\treturn b\n}\n\n// WithAPIKeyClientProvider overrides the provider responsible for API key-backed clients.\nfunc (b *Builder) WithAPIKeyClientProvider(provider APIKeyClientProvider) *Builder {\n\tb.apiKeyProvider = provider\n\treturn b\n}\n\n// WithWatcherFactory allows customizing the watcher factory that handles reloads.\nfunc (b *Builder) WithWatcherFactory(factory WatcherFactory) *Builder {\n\tb.watcherFactory = factory\n\treturn b\n}\n\n// WithHooks registers lifecycle hooks executed around service startup.\nfunc (b *Builder) WithHooks(h Hooks) *Builder {\n\tb.hooks = h\n\treturn b\n}\n\n// WithAuthManager overrides the authentication manager used for token lifecycle operations.\nfunc (b *Builder) WithAuthManager(mgr *sdkAuth.Manager) *Builder {\n\tb.authManager = mgr\n\treturn b\n}\n\n// WithRequestAccessManager overrides the request authentication manager.\nfunc (b *Builder) WithRequestAccessManager(mgr *sdkaccess.Manager) *Builder {\n\tb.accessManager = mgr\n\treturn b\n}\n\n// WithCoreAuthManager overrides the runtime auth manager responsible for request execution.\nfunc (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder {\n\tb.coreManager = mgr\n\treturn b\n}\n\n// WithServerOptions appends server configuration options used during construction.\nfunc (b *Builder) WithServerOptions(opts ...api.ServerOption) *Builder {\n\tb.serverOptions = append(b.serverOptions, opts...)\n\treturn b\n}\n\n// WithLocalManagementPassword configures a password that is only accepted from localhost management requests.\nfunc (b *Builder) WithLocalManagementPassword(password string) *Builder {\n\tif password == \"\" {\n\t\treturn b\n\t}\n\tb.serverOptions = append(b.serverOptions, api.WithLocalManagementPassword(password))\n\treturn b\n}\n\n// WithPostAuthHook registers a hook to be called after an Auth record is created\n// but before it is persisted to storage.\nfunc (b *Builder) WithPostAuthHook(hook coreauth.PostAuthHook) *Builder {\n\tif hook == nil {\n\t\treturn b\n\t}\n\tb.serverOptions = append(b.serverOptions, api.WithPostAuthHook(hook))\n\treturn b\n}\n\n// Build validates inputs, applies defaults, and returns a ready-to-run service.\nfunc (b *Builder) Build() (*Service, error) {\n\tif b.cfg == nil {\n\t\treturn nil, fmt.Errorf(\"cliproxy: configuration is required\")\n\t}\n\tif b.configPath == \"\" {\n\t\treturn nil, fmt.Errorf(\"cliproxy: configuration path is required\")\n\t}\n\n\ttokenProvider := b.tokenProvider\n\tif tokenProvider == nil {\n\t\ttokenProvider = NewFileTokenClientProvider()\n\t}\n\n\tapiKeyProvider := b.apiKeyProvider\n\tif apiKeyProvider == nil {\n\t\tapiKeyProvider = NewAPIKeyClientProvider()\n\t}\n\n\twatcherFactory := b.watcherFactory\n\tif watcherFactory == nil {\n\t\twatcherFactory = defaultWatcherFactory\n\t}\n\n\tauthManager := b.authManager\n\tif authManager == nil {\n\t\tauthManager = newDefaultAuthManager()\n\t}\n\n\taccessManager := b.accessManager\n\tif accessManager == nil {\n\t\taccessManager = sdkaccess.NewManager()\n\t}\n\n\tconfigaccess.Register(&b.cfg.SDKConfig)\n\taccessManager.SetProviders(sdkaccess.RegisteredProviders())\n\n\tcoreManager := b.coreManager\n\tif coreManager == nil {\n\t\ttokenStore := sdkAuth.GetTokenStore()\n\t\tif dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok && b.cfg != nil {\n\t\t\tdirSetter.SetBaseDir(b.cfg.AuthDir)\n\t\t}\n\n\t\tstrategy := \"\"\n\t\tif b.cfg != nil {\n\t\t\tstrategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy))\n\t\t}\n\t\tvar selector coreauth.Selector\n\t\tswitch strategy {\n\t\tcase \"fill-first\", \"fillfirst\", \"ff\":\n\t\t\tselector = &coreauth.FillFirstSelector{}\n\t\tdefault:\n\t\t\tselector = &coreauth.RoundRobinSelector{}\n\t\t}\n\n\t\tcoreManager = coreauth.NewManager(tokenStore, selector, nil)\n\t}\n\t// Attach a default RoundTripper provider so providers can opt-in per-auth transports.\n\tcoreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())\n\tcoreManager.SetConfig(b.cfg)\n\tcoreManager.SetOAuthModelAlias(b.cfg.OAuthModelAlias)\n\n\tservice := &Service{\n\t\tcfg:            b.cfg,\n\t\tconfigPath:     b.configPath,\n\t\ttokenProvider:  tokenProvider,\n\t\tapiKeyProvider: apiKeyProvider,\n\t\twatcherFactory: watcherFactory,\n\t\thooks:          b.hooks,\n\t\tauthManager:    authManager,\n\t\taccessManager:  accessManager,\n\t\tcoreManager:    coreManager,\n\t\tserverOptions:  append([]api.ServerOption(nil), b.serverOptions...),\n\t}\n\treturn service, nil\n}\n"
  },
  {
    "path": "sdk/cliproxy/executor/context.go",
    "content": "package executor\n\nimport \"context\"\n\ntype downstreamWebsocketContextKey struct{}\n\n// WithDownstreamWebsocket marks the current request as coming from a downstream websocket connection.\nfunc WithDownstreamWebsocket(ctx context.Context) context.Context {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\treturn context.WithValue(ctx, downstreamWebsocketContextKey{}, true)\n}\n\n// DownstreamWebsocket reports whether the current request originates from a downstream websocket connection.\nfunc DownstreamWebsocket(ctx context.Context) bool {\n\tif ctx == nil {\n\t\treturn false\n\t}\n\traw := ctx.Value(downstreamWebsocketContextKey{})\n\tenabled, ok := raw.(bool)\n\treturn ok && enabled\n}\n"
  },
  {
    "path": "sdk/cliproxy/executor/types.go",
    "content": "package executor\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n)\n\n// RequestedModelMetadataKey stores the client-requested model name in Options.Metadata.\nconst RequestedModelMetadataKey = \"requested_model\"\n\nconst (\n\t// PinnedAuthMetadataKey locks execution to a specific auth ID.\n\tPinnedAuthMetadataKey = \"pinned_auth_id\"\n\t// SelectedAuthMetadataKey stores the auth ID selected by the scheduler.\n\tSelectedAuthMetadataKey = \"selected_auth_id\"\n\t// SelectedAuthCallbackMetadataKey carries an optional callback invoked with the selected auth ID.\n\tSelectedAuthCallbackMetadataKey = \"selected_auth_callback\"\n\t// ExecutionSessionMetadataKey identifies a long-lived downstream execution session.\n\tExecutionSessionMetadataKey = \"execution_session_id\"\n)\n\n// Request encapsulates the translated payload that will be sent to a provider executor.\ntype Request struct {\n\t// Model is the upstream model identifier after translation.\n\tModel string\n\t// Payload is the provider specific JSON payload.\n\tPayload []byte\n\t// Format represents the provider payload schema.\n\tFormat sdktranslator.Format\n\t// Metadata carries optional provider specific execution hints.\n\tMetadata map[string]any\n}\n\n// Options controls execution behavior for both streaming and non-streaming calls.\ntype Options struct {\n\t// Stream toggles streaming mode.\n\tStream bool\n\t// Alt carries optional alternate format hint (e.g. SSE JSON key).\n\tAlt string\n\t// Headers are forwarded to the provider request builder.\n\tHeaders http.Header\n\t// Query contains optional query string parameters.\n\tQuery url.Values\n\t// OriginalRequest preserves the inbound request bytes prior to translation.\n\tOriginalRequest []byte\n\t// SourceFormat identifies the inbound schema.\n\tSourceFormat sdktranslator.Format\n\t// Metadata carries extra execution hints shared across selection and executors.\n\tMetadata map[string]any\n}\n\n// Response wraps either a full provider response or metadata for streaming flows.\ntype Response struct {\n\t// Payload is the provider response in the executor format.\n\tPayload []byte\n\t// Metadata exposes optional structured data for translators.\n\tMetadata map[string]any\n\t// Headers carries upstream HTTP response headers for passthrough to clients.\n\tHeaders http.Header\n}\n\n// StreamChunk represents a single streaming payload unit emitted by provider executors.\ntype StreamChunk struct {\n\t// Payload is the raw provider chunk payload.\n\tPayload []byte\n\t// Err reports any terminal error encountered while producing chunks.\n\tErr error\n}\n\n// StreamResult wraps the streaming response, providing both the chunk channel\n// and the upstream HTTP response headers captured before streaming begins.\ntype StreamResult struct {\n\t// Headers carries upstream HTTP response headers from the initial connection.\n\tHeaders http.Header\n\t// Chunks is the channel of streaming payload units.\n\tChunks <-chan StreamChunk\n}\n\n// StatusError represents an error that carries an HTTP-like status code.\n// Provider executors should implement this when possible to enable\n// better auth state updates on failures (e.g., 401/402/429).\ntype StatusError interface {\n\terror\n\tStatusCode() int\n}\n"
  },
  {
    "path": "sdk/cliproxy/model_registry.go",
    "content": "package cliproxy\n\nimport \"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\n// ModelInfo re-exports the registry model info structure.\ntype ModelInfo = registry.ModelInfo\n\n// ModelRegistryHook re-exports the registry hook interface for external integrations.\ntype ModelRegistryHook = registry.ModelRegistryHook\n\n// ModelRegistry describes registry operations consumed by external callers.\ntype ModelRegistry interface {\n\tRegisterClient(clientID, clientProvider string, models []*ModelInfo)\n\tUnregisterClient(clientID string)\n\tSetModelQuotaExceeded(clientID, modelID string)\n\tClearModelQuotaExceeded(clientID, modelID string)\n\tClientSupportsModel(clientID, modelID string) bool\n\tGetAvailableModels(handlerType string) []map[string]any\n\tGetAvailableModelsByProvider(provider string) []*ModelInfo\n}\n\n// GlobalModelRegistry returns the shared registry instance.\nfunc GlobalModelRegistry() ModelRegistry {\n\treturn registry.GetGlobalRegistry()\n}\n\n// SetGlobalModelRegistryHook registers an optional hook on the shared global registry instance.\nfunc SetGlobalModelRegistryHook(hook ModelRegistryHook) {\n\tregistry.GetGlobalRegistry().SetHook(hook)\n}\n"
  },
  {
    "path": "sdk/cliproxy/pipeline/context.go",
    "content": "package pipeline\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\tcliproxyauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\tcliproxyexecutor \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n)\n\n// Context encapsulates execution state shared across middleware, translators, and executors.\ntype Context struct {\n\t// Request encapsulates the provider facing request payload.\n\tRequest cliproxyexecutor.Request\n\t// Options carries execution flags (streaming, headers, etc.).\n\tOptions cliproxyexecutor.Options\n\t// Auth references the credential selected for execution.\n\tAuth *cliproxyauth.Auth\n\t// Translator represents the pipeline responsible for schema adaptation.\n\tTranslator *sdktranslator.Pipeline\n\t// HTTPClient allows middleware to customise the outbound transport per request.\n\tHTTPClient *http.Client\n}\n\n// Hook captures middleware callbacks around execution.\ntype Hook interface {\n\tBeforeExecute(ctx context.Context, execCtx *Context)\n\tAfterExecute(ctx context.Context, execCtx *Context, resp cliproxyexecutor.Response, err error)\n\tOnStreamChunk(ctx context.Context, execCtx *Context, chunk cliproxyexecutor.StreamChunk)\n}\n\n// HookFunc aggregates optional hook implementations.\ntype HookFunc struct {\n\tBefore func(context.Context, *Context)\n\tAfter  func(context.Context, *Context, cliproxyexecutor.Response, error)\n\tStream func(context.Context, *Context, cliproxyexecutor.StreamChunk)\n}\n\n// BeforeExecute implements Hook.\nfunc (h HookFunc) BeforeExecute(ctx context.Context, execCtx *Context) {\n\tif h.Before != nil {\n\t\th.Before(ctx, execCtx)\n\t}\n}\n\n// AfterExecute implements Hook.\nfunc (h HookFunc) AfterExecute(ctx context.Context, execCtx *Context, resp cliproxyexecutor.Response, err error) {\n\tif h.After != nil {\n\t\th.After(ctx, execCtx, resp, err)\n\t}\n}\n\n// OnStreamChunk implements Hook.\nfunc (h HookFunc) OnStreamChunk(ctx context.Context, execCtx *Context, chunk cliproxyexecutor.StreamChunk) {\n\tif h.Stream != nil {\n\t\th.Stream(ctx, execCtx, chunk)\n\t}\n}\n\n// RoundTripperProvider allows injection of custom HTTP transports per auth entry.\ntype RoundTripperProvider interface {\n\tRoundTripperFor(auth *cliproxyauth.Auth) http.RoundTripper\n}\n"
  },
  {
    "path": "sdk/cliproxy/pprof_server.go",
    "content": "package cliproxy\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/pprof\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype pprofServer struct {\n\tmu      sync.Mutex\n\tserver  *http.Server\n\taddr    string\n\tenabled bool\n}\n\nfunc newPprofServer() *pprofServer {\n\treturn &pprofServer{}\n}\n\nfunc (s *Service) applyPprofConfig(cfg *config.Config) {\n\tif s == nil || cfg == nil {\n\t\treturn\n\t}\n\tif s.pprofServer == nil {\n\t\ts.pprofServer = newPprofServer()\n\t}\n\ts.pprofServer.Apply(cfg)\n}\n\nfunc (s *Service) shutdownPprof(ctx context.Context) error {\n\tif s == nil || s.pprofServer == nil {\n\t\treturn nil\n\t}\n\treturn s.pprofServer.Shutdown(ctx)\n}\n\nfunc (p *pprofServer) Apply(cfg *config.Config) {\n\tif p == nil || cfg == nil {\n\t\treturn\n\t}\n\taddr := strings.TrimSpace(cfg.Pprof.Addr)\n\tif addr == \"\" {\n\t\taddr = config.DefaultPprofAddr\n\t}\n\tenabled := cfg.Pprof.Enable\n\n\tp.mu.Lock()\n\tcurrentServer := p.server\n\tcurrentAddr := p.addr\n\tp.addr = addr\n\tp.enabled = enabled\n\tif !enabled {\n\t\tp.server = nil\n\t\tp.mu.Unlock()\n\t\tif currentServer != nil {\n\t\t\tp.stopServer(currentServer, currentAddr, \"disabled\")\n\t\t}\n\t\treturn\n\t}\n\tif currentServer != nil && currentAddr == addr {\n\t\tp.mu.Unlock()\n\t\treturn\n\t}\n\tp.server = nil\n\tp.mu.Unlock()\n\n\tif currentServer != nil {\n\t\tp.stopServer(currentServer, currentAddr, \"restarted\")\n\t}\n\n\tp.startServer(addr)\n}\n\nfunc (p *pprofServer) Shutdown(ctx context.Context) error {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tp.mu.Lock()\n\tcurrentServer := p.server\n\tcurrentAddr := p.addr\n\tp.server = nil\n\tp.enabled = false\n\tp.mu.Unlock()\n\n\tif currentServer == nil {\n\t\treturn nil\n\t}\n\treturn p.stopServerWithContext(ctx, currentServer, currentAddr, \"shutdown\")\n}\n\nfunc (p *pprofServer) startServer(addr string) {\n\tmux := newPprofMux()\n\tserver := &http.Server{\n\t\tAddr:              addr,\n\t\tHandler:           mux,\n\t\tReadHeaderTimeout: 5 * time.Second,\n\t}\n\n\tp.mu.Lock()\n\tif !p.enabled || p.addr != addr || p.server != nil {\n\t\tp.mu.Unlock()\n\t\treturn\n\t}\n\tp.server = server\n\tp.mu.Unlock()\n\n\tlog.Infof(\"pprof server starting on %s\", addr)\n\tgo func() {\n\t\tif errServe := server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {\n\t\t\tlog.Errorf(\"pprof server failed on %s: %v\", addr, errServe)\n\t\t\tp.mu.Lock()\n\t\t\tif p.server == server {\n\t\t\t\tp.server = nil\n\t\t\t}\n\t\t\tp.mu.Unlock()\n\t\t}\n\t}()\n}\n\nfunc (p *pprofServer) stopServer(server *http.Server, addr string, reason string) {\n\t_ = p.stopServerWithContext(context.Background(), server, addr, reason)\n}\n\nfunc (p *pprofServer) stopServerWithContext(ctx context.Context, server *http.Server, addr string, reason string) error {\n\tif server == nil {\n\t\treturn nil\n\t}\n\tstopCtx := ctx\n\tif stopCtx == nil {\n\t\tstopCtx = context.Background()\n\t}\n\tstopCtx, cancel := context.WithTimeout(stopCtx, 5*time.Second)\n\tdefer cancel()\n\tif errStop := server.Shutdown(stopCtx); errStop != nil {\n\t\tlog.Errorf(\"pprof server stop failed on %s: %v\", addr, errStop)\n\t\treturn errStop\n\t}\n\tlog.Infof(\"pprof server stopped on %s (%s)\", addr, reason)\n\treturn nil\n}\n\nfunc newPprofMux() *http.ServeMux {\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/debug/pprof/\", pprof.Index)\n\tmux.HandleFunc(\"/debug/pprof/cmdline\", pprof.Cmdline)\n\tmux.HandleFunc(\"/debug/pprof/profile\", pprof.Profile)\n\tmux.HandleFunc(\"/debug/pprof/symbol\", pprof.Symbol)\n\tmux.HandleFunc(\"/debug/pprof/trace\", pprof.Trace)\n\tmux.Handle(\"/debug/pprof/allocs\", pprof.Handler(\"allocs\"))\n\tmux.Handle(\"/debug/pprof/block\", pprof.Handler(\"block\"))\n\tmux.Handle(\"/debug/pprof/goroutine\", pprof.Handler(\"goroutine\"))\n\tmux.Handle(\"/debug/pprof/heap\", pprof.Handler(\"heap\"))\n\tmux.Handle(\"/debug/pprof/mutex\", pprof.Handler(\"mutex\"))\n\tmux.Handle(\"/debug/pprof/threadcreate\", pprof.Handler(\"threadcreate\"))\n\treturn mux\n}\n"
  },
  {
    "path": "sdk/cliproxy/providers.go",
    "content": "package cliproxy\n\nimport (\n\t\"context\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\n// NewFileTokenClientProvider returns the default token-backed client loader.\nfunc NewFileTokenClientProvider() TokenClientProvider {\n\treturn &fileTokenClientProvider{}\n}\n\ntype fileTokenClientProvider struct{}\n\nfunc (p *fileTokenClientProvider) Load(ctx context.Context, cfg *config.Config) (*TokenClientResult, error) {\n\t// Stateless executors handle tokens\n\t_ = ctx\n\t_ = cfg\n\treturn &TokenClientResult{SuccessfulAuthed: 0}, nil\n}\n\n// NewAPIKeyClientProvider returns the default API key client loader that reuses existing logic.\nfunc NewAPIKeyClientProvider() APIKeyClientProvider {\n\treturn &apiKeyClientProvider{}\n}\n\ntype apiKeyClientProvider struct{}\n\nfunc (p *apiKeyClientProvider) Load(ctx context.Context, cfg *config.Config) (*APIKeyClientResult, error) {\n\tgeminiCount, vertexCompatCount, claudeCount, codexCount, openAICompat := watcher.BuildAPIKeyClients(cfg)\n\tif ctx != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, ctx.Err()\n\t\tdefault:\n\t\t}\n\t}\n\treturn &APIKeyClientResult{\n\t\tGeminiKeyCount:       geminiCount,\n\t\tVertexCompatKeyCount: vertexCompatCount,\n\t\tClaudeKeyCount:       claudeCount,\n\t\tCodexKeyCount:        codexCount,\n\t\tOpenAICompatCount:    openAICompat,\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/cliproxy/rtprovider.go",
    "content": "package cliproxy\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// defaultRoundTripperProvider returns a per-auth HTTP RoundTripper based on\n// the Auth.ProxyURL value. It caches transports per proxy URL string.\ntype defaultRoundTripperProvider struct {\n\tmu    sync.RWMutex\n\tcache map[string]http.RoundTripper\n}\n\nfunc newDefaultRoundTripperProvider() *defaultRoundTripperProvider {\n\treturn &defaultRoundTripperProvider{cache: make(map[string]http.RoundTripper)}\n}\n\n// RoundTripperFor implements coreauth.RoundTripperProvider.\nfunc (p *defaultRoundTripperProvider) RoundTripperFor(auth *coreauth.Auth) http.RoundTripper {\n\tif auth == nil {\n\t\treturn nil\n\t}\n\tproxyStr := strings.TrimSpace(auth.ProxyURL)\n\tif proxyStr == \"\" {\n\t\treturn nil\n\t}\n\tp.mu.RLock()\n\trt := p.cache[proxyStr]\n\tp.mu.RUnlock()\n\tif rt != nil {\n\t\treturn rt\n\t}\n\ttransport, _, errBuild := proxyutil.BuildHTTPTransport(proxyStr)\n\tif errBuild != nil {\n\t\tlog.Errorf(\"%v\", errBuild)\n\t\treturn nil\n\t}\n\tif transport == nil {\n\t\treturn nil\n\t}\n\tp.mu.Lock()\n\tp.cache[proxyStr] = transport\n\tp.mu.Unlock()\n\treturn transport\n}\n"
  },
  {
    "path": "sdk/cliproxy/rtprovider_test.go",
    "content": "package cliproxy\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n)\n\nfunc TestRoundTripperForDirectBypassesProxy(t *testing.T) {\n\tt.Parallel()\n\n\tprovider := newDefaultRoundTripperProvider()\n\trt := provider.RoundTripperFor(&coreauth.Auth{ProxyURL: \"direct\"})\n\ttransport, ok := rt.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"transport type = %T, want *http.Transport\", rt)\n\t}\n\tif transport.Proxy != nil {\n\t\tt.Fatal(\"expected direct transport to disable proxy function\")\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/service.go",
    "content": "// Package cliproxy provides the core service implementation for the CLI Proxy API.\n// It includes service lifecycle management, authentication handling, file watching,\n// and integration with various AI service providers through a unified interface.\npackage cliproxy\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/usage\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay\"\n\tsdkaccess \"github.com/router-for-me/CLIProxyAPI/v6/sdk/access\"\n\tsdkAuth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/auth\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// Service wraps the proxy server lifecycle so external programs can embed the CLI proxy.\n// It manages the complete lifecycle including authentication, file watching, HTTP server,\n// and integration with various AI service providers.\ntype Service struct {\n\t// cfg holds the current application configuration.\n\tcfg *config.Config\n\n\t// cfgMu protects concurrent access to the configuration.\n\tcfgMu sync.RWMutex\n\n\t// configPath is the path to the configuration file.\n\tconfigPath string\n\n\t// tokenProvider handles loading token-based clients.\n\ttokenProvider TokenClientProvider\n\n\t// apiKeyProvider handles loading API key-based clients.\n\tapiKeyProvider APIKeyClientProvider\n\n\t// watcherFactory creates file watcher instances.\n\twatcherFactory WatcherFactory\n\n\t// hooks provides lifecycle callbacks.\n\thooks Hooks\n\n\t// serverOptions contains additional server configuration options.\n\tserverOptions []api.ServerOption\n\n\t// server is the HTTP API server instance.\n\tserver *api.Server\n\n\t// pprofServer manages the optional pprof HTTP debug server.\n\tpprofServer *pprofServer\n\n\t// serverErr channel for server startup/shutdown errors.\n\tserverErr chan error\n\n\t// watcher handles file system monitoring.\n\twatcher *WatcherWrapper\n\n\t// watcherCancel cancels the watcher context.\n\twatcherCancel context.CancelFunc\n\n\t// authUpdates channel for authentication updates.\n\tauthUpdates chan watcher.AuthUpdate\n\n\t// authQueueStop cancels the auth update queue processing.\n\tauthQueueStop context.CancelFunc\n\n\t// authManager handles legacy authentication operations.\n\tauthManager *sdkAuth.Manager\n\n\t// accessManager handles request authentication providers.\n\taccessManager *sdkaccess.Manager\n\n\t// coreManager handles core authentication and execution.\n\tcoreManager *coreauth.Manager\n\n\t// shutdownOnce ensures shutdown is called only once.\n\tshutdownOnce sync.Once\n\n\t// wsGateway manages websocket Gemini providers.\n\twsGateway *wsrelay.Manager\n}\n\n// RegisterUsagePlugin registers a usage plugin on the global usage manager.\n// This allows external code to monitor API usage and token consumption.\n//\n// Parameters:\n//   - plugin: The usage plugin to register\nfunc (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {\n\tusage.RegisterPlugin(plugin)\n}\n\n// newDefaultAuthManager creates a default authentication manager with all supported providers.\nfunc newDefaultAuthManager() *sdkAuth.Manager {\n\treturn sdkAuth.NewManager(\n\t\tsdkAuth.GetTokenStore(),\n\t\tsdkAuth.NewGeminiAuthenticator(),\n\t\tsdkAuth.NewCodexAuthenticator(),\n\t\tsdkAuth.NewClaudeAuthenticator(),\n\t\tsdkAuth.NewQwenAuthenticator(),\n\t)\n}\n\nfunc (s *Service) ensureAuthUpdateQueue(ctx context.Context) {\n\tif s == nil {\n\t\treturn\n\t}\n\tif s.authUpdates == nil {\n\t\ts.authUpdates = make(chan watcher.AuthUpdate, 256)\n\t}\n\tif s.authQueueStop != nil {\n\t\treturn\n\t}\n\tqueueCtx, cancel := context.WithCancel(ctx)\n\ts.authQueueStop = cancel\n\tgo s.consumeAuthUpdates(queueCtx)\n}\n\nfunc (s *Service) consumeAuthUpdates(ctx context.Context) {\n\tctx = coreauth.WithSkipPersist(ctx)\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase update, ok := <-s.authUpdates:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ts.handleAuthUpdate(ctx, update)\n\t\tlabelDrain:\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase nextUpdate := <-s.authUpdates:\n\t\t\t\t\ts.handleAuthUpdate(ctx, nextUpdate)\n\t\t\t\tdefault:\n\t\t\t\t\tbreak labelDrain\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {\n\tif s == nil {\n\t\treturn\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif s.watcher != nil && s.watcher.DispatchRuntimeAuthUpdate(update) {\n\t\treturn\n\t}\n\tif s.authUpdates != nil {\n\t\tselect {\n\t\tcase s.authUpdates <- update:\n\t\t\treturn\n\t\tdefault:\n\t\t\tlog.Debugf(\"auth update queue saturated, applying inline action=%v id=%s\", update.Action, update.ID)\n\t\t}\n\t}\n\ts.handleAuthUpdate(ctx, update)\n}\n\nfunc (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {\n\tif s == nil {\n\t\treturn\n\t}\n\ts.cfgMu.RLock()\n\tcfg := s.cfg\n\ts.cfgMu.RUnlock()\n\tif cfg == nil || s.coreManager == nil {\n\t\treturn\n\t}\n\tswitch update.Action {\n\tcase watcher.AuthUpdateActionAdd, watcher.AuthUpdateActionModify:\n\t\tif update.Auth == nil || update.Auth.ID == \"\" {\n\t\t\treturn\n\t\t}\n\t\ts.applyCoreAuthAddOrUpdate(ctx, update.Auth)\n\tcase watcher.AuthUpdateActionDelete:\n\t\tid := update.ID\n\t\tif id == \"\" && update.Auth != nil {\n\t\t\tid = update.Auth.ID\n\t\t}\n\t\tif id == \"\" {\n\t\t\treturn\n\t\t}\n\t\ts.applyCoreAuthRemoval(ctx, id)\n\tdefault:\n\t\tlog.Debugf(\"received unknown auth update action: %v\", update.Action)\n\t}\n}\n\nfunc (s *Service) ensureWebsocketGateway() {\n\tif s == nil {\n\t\treturn\n\t}\n\tif s.wsGateway != nil {\n\t\treturn\n\t}\n\topts := wsrelay.Options{\n\t\tPath:           \"/v1/ws\",\n\t\tOnConnected:    s.wsOnConnected,\n\t\tOnDisconnected: s.wsOnDisconnected,\n\t\tLogDebugf:      log.Debugf,\n\t\tLogInfof:       log.Infof,\n\t\tLogWarnf:       log.Warnf,\n\t}\n\ts.wsGateway = wsrelay.NewManager(opts)\n}\n\nfunc (s *Service) wsOnConnected(channelID string) {\n\tif s == nil || channelID == \"\" {\n\t\treturn\n\t}\n\tif !strings.HasPrefix(strings.ToLower(channelID), \"aistudio-\") {\n\t\treturn\n\t}\n\tif s.coreManager != nil {\n\t\tif existing, ok := s.coreManager.GetByID(channelID); ok && existing != nil {\n\t\t\tif !existing.Disabled && existing.Status == coreauth.StatusActive {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\tnow := time.Now().UTC()\n\tauth := &coreauth.Auth{\n\t\tID:         channelID,  // keep channel identifier as ID\n\t\tProvider:   \"aistudio\", // logical provider for switch routing\n\t\tLabel:      channelID,  // display original channel id\n\t\tStatus:     coreauth.StatusActive,\n\t\tCreatedAt:  now,\n\t\tUpdatedAt:  now,\n\t\tAttributes: map[string]string{\"runtime_only\": \"true\"},\n\t\tMetadata:   map[string]any{\"email\": channelID}, // metadata drives logging and usage tracking\n\t}\n\tlog.Infof(\"websocket provider connected: %s\", channelID)\n\ts.emitAuthUpdate(context.Background(), watcher.AuthUpdate{\n\t\tAction: watcher.AuthUpdateActionAdd,\n\t\tID:     auth.ID,\n\t\tAuth:   auth,\n\t})\n}\n\nfunc (s *Service) wsOnDisconnected(channelID string, reason error) {\n\tif s == nil || channelID == \"\" {\n\t\treturn\n\t}\n\tif reason != nil {\n\t\tif strings.Contains(reason.Error(), \"replaced by new connection\") {\n\t\t\tlog.Infof(\"websocket provider replaced: %s\", channelID)\n\t\t\treturn\n\t\t}\n\t\tlog.Warnf(\"websocket provider disconnected: %s (%v)\", channelID, reason)\n\t} else {\n\t\tlog.Infof(\"websocket provider disconnected: %s\", channelID)\n\t}\n\tctx := context.Background()\n\ts.emitAuthUpdate(ctx, watcher.AuthUpdate{\n\t\tAction: watcher.AuthUpdateActionDelete,\n\t\tID:     channelID,\n\t})\n}\n\nfunc (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {\n\tif s == nil || s.coreManager == nil || auth == nil || auth.ID == \"\" {\n\t\treturn\n\t}\n\tauth = auth.Clone()\n\ts.ensureExecutorsForAuth(auth)\n\n\t// IMPORTANT: Update coreManager FIRST, before model registration.\n\t// This ensures that configuration changes (proxy_url, prefix, etc.) take effect\n\t// immediately for API calls, rather than waiting for model registration to complete.\n\top := \"register\"\n\tvar err error\n\tif existing, ok := s.coreManager.GetByID(auth.ID); ok {\n\t\tauth.CreatedAt = existing.CreatedAt\n\t\tauth.LastRefreshedAt = existing.LastRefreshedAt\n\t\tauth.NextRefreshAfter = existing.NextRefreshAfter\n\t\tif len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {\n\t\t\tauth.ModelStates = existing.ModelStates\n\t\t}\n\t\top = \"update\"\n\t\t_, err = s.coreManager.Update(ctx, auth)\n\t} else {\n\t\t_, err = s.coreManager.Register(ctx, auth)\n\t}\n\tif err != nil {\n\t\tlog.Errorf(\"failed to %s auth %s: %v\", op, auth.ID, err)\n\t\tcurrent, ok := s.coreManager.GetByID(auth.ID)\n\t\tif !ok || current.Disabled {\n\t\t\tGlobalModelRegistry().UnregisterClient(auth.ID)\n\t\t\treturn\n\t\t}\n\t\tauth = current\n\t}\n\n\t// Register models after auth is updated in coreManager.\n\t// This operation may block on network calls, but the auth configuration\n\t// is already effective at this point.\n\ts.registerModelsForAuth(auth)\n\n\t// Refresh the scheduler entry so that the auth's supportedModelSet is rebuilt\n\t// from the now-populated global model registry. Without this, newly added auths\n\t// have an empty supportedModelSet (because Register/Update upserts into the\n\t// scheduler before registerModelsForAuth runs) and are invisible to the scheduler.\n\ts.coreManager.RefreshSchedulerEntry(auth.ID)\n}\n\nfunc (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {\n\tif s == nil || id == \"\" {\n\t\treturn\n\t}\n\tif s.coreManager == nil {\n\t\treturn\n\t}\n\tGlobalModelRegistry().UnregisterClient(id)\n\tif existing, ok := s.coreManager.GetByID(id); ok && existing != nil {\n\t\texisting.Disabled = true\n\t\texisting.Status = coreauth.StatusDisabled\n\t\tif _, err := s.coreManager.Update(ctx, existing); err != nil {\n\t\t\tlog.Errorf(\"failed to disable auth %s: %v\", id, err)\n\t\t}\n\t\tif strings.EqualFold(strings.TrimSpace(existing.Provider), \"codex\") {\n\t\t\ts.ensureExecutorsForAuth(existing)\n\t\t}\n\t}\n}\n\nfunc (s *Service) applyRetryConfig(cfg *config.Config) {\n\tif s == nil || s.coreManager == nil || cfg == nil {\n\t\treturn\n\t}\n\tmaxInterval := time.Duration(cfg.MaxRetryInterval) * time.Second\n\ts.coreManager.SetRetryConfig(cfg.RequestRetry, maxInterval, cfg.MaxRetryCredentials)\n}\n\nfunc openAICompatInfoFromAuth(a *coreauth.Auth) (providerKey string, compatName string, ok bool) {\n\tif a == nil {\n\t\treturn \"\", \"\", false\n\t}\n\tif len(a.Attributes) > 0 {\n\t\tproviderKey = strings.TrimSpace(a.Attributes[\"provider_key\"])\n\t\tcompatName = strings.TrimSpace(a.Attributes[\"compat_name\"])\n\t\tif compatName != \"\" {\n\t\t\tif providerKey == \"\" {\n\t\t\t\tproviderKey = compatName\n\t\t\t}\n\t\t\treturn strings.ToLower(providerKey), compatName, true\n\t\t}\n\t}\n\tif strings.EqualFold(strings.TrimSpace(a.Provider), \"openai-compatibility\") {\n\t\treturn \"openai-compatibility\", strings.TrimSpace(a.Label), true\n\t}\n\treturn \"\", \"\", false\n}\n\nfunc (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {\n\ts.ensureExecutorsForAuthWithMode(a, false)\n}\n\nfunc (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace bool) {\n\tif s == nil || s.coreManager == nil || a == nil {\n\t\treturn\n\t}\n\tif strings.EqualFold(strings.TrimSpace(a.Provider), \"codex\") {\n\t\tif !forceReplace {\n\t\t\texistingExecutor, hasExecutor := s.coreManager.Executor(\"codex\")\n\t\t\tif hasExecutor {\n\t\t\t\t_, isCodexAutoExecutor := existingExecutor.(*executor.CodexAutoExecutor)\n\t\t\t\tif isCodexAutoExecutor {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ts.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg))\n\t\treturn\n\t}\n\t// Skip disabled auth entries when (re)binding executors.\n\t// Disabled auths can linger during config reloads (e.g., removed OpenAI-compat entries)\n\t// and must not override active provider executors (such as iFlow OAuth accounts).\n\tif a.Disabled {\n\t\treturn\n\t}\n\tif compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat {\n\t\tif compatProviderKey == \"\" {\n\t\t\tcompatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider))\n\t\t}\n\t\tif compatProviderKey == \"\" {\n\t\t\tcompatProviderKey = \"openai-compatibility\"\n\t\t}\n\t\ts.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(compatProviderKey, s.cfg))\n\t\treturn\n\t}\n\tswitch strings.ToLower(a.Provider) {\n\tcase \"gemini\":\n\t\ts.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg))\n\tcase \"vertex\":\n\t\ts.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg))\n\tcase \"gemini-cli\":\n\t\ts.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))\n\tcase \"aistudio\":\n\t\tif s.wsGateway != nil {\n\t\t\ts.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, a.ID, s.wsGateway))\n\t\t}\n\t\treturn\n\tcase \"antigravity\":\n\t\ts.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))\n\tcase \"claude\":\n\t\ts.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))\n\tcase \"qwen\":\n\t\ts.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))\n\tcase \"iflow\":\n\t\ts.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))\n\tcase \"kimi\":\n\t\ts.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))\n\tdefault:\n\t\tproviderKey := strings.ToLower(strings.TrimSpace(a.Provider))\n\t\tif providerKey == \"\" {\n\t\t\tproviderKey = \"openai-compatibility\"\n\t\t}\n\t\ts.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(providerKey, s.cfg))\n\t}\n}\n\nfunc (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey string, models []*ModelInfo) {\n\tif a == nil || a.ID == \"\" {\n\t\treturn\n\t}\n\tif len(models) == 0 {\n\t\tGlobalModelRegistry().UnregisterClient(a.ID)\n\t\treturn\n\t}\n\tGlobalModelRegistry().RegisterClient(a.ID, providerKey, models)\n}\n\n// rebindExecutors refreshes provider executors so they observe the latest configuration.\nfunc (s *Service) rebindExecutors() {\n\tif s == nil || s.coreManager == nil {\n\t\treturn\n\t}\n\tauths := s.coreManager.List()\n\treboundCodex := false\n\tfor _, auth := range auths {\n\t\tif auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), \"codex\") {\n\t\t\tif reboundCodex {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treboundCodex = true\n\t\t}\n\t\ts.ensureExecutorsForAuthWithMode(auth, true)\n\t}\n}\n\n// Run starts the service and blocks until the context is cancelled or the server stops.\n// It initializes all components including authentication, file watching, HTTP server,\n// and starts processing requests. The method blocks until the context is cancelled.\n//\n// Parameters:\n//   - ctx: The context for controlling the service lifecycle\n//\n// Returns:\n//   - error: An error if the service fails to start or run\nfunc (s *Service) Run(ctx context.Context) error {\n\tif s == nil {\n\t\treturn fmt.Errorf(\"cliproxy: service is nil\")\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\n\tusage.StartDefault(ctx)\n\n\tshutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer shutdownCancel()\n\tdefer func() {\n\t\tif err := s.Shutdown(shutdownCtx); err != nil {\n\t\t\tlog.Errorf(\"service shutdown returned error: %v\", err)\n\t\t}\n\t}()\n\n\tif err := s.ensureAuthDir(); err != nil {\n\t\treturn err\n\t}\n\n\ts.applyRetryConfig(s.cfg)\n\n\tif s.coreManager != nil {\n\t\tif errLoad := s.coreManager.Load(ctx); errLoad != nil {\n\t\t\tlog.Warnf(\"failed to load auth store: %v\", errLoad)\n\t\t}\n\t}\n\n\ttokenResult, err := s.tokenProvider.Load(ctx, s.cfg)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\treturn err\n\t}\n\tif tokenResult == nil {\n\t\ttokenResult = &TokenClientResult{}\n\t}\n\n\tapiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\treturn err\n\t}\n\tif apiKeyResult == nil {\n\t\tapiKeyResult = &APIKeyClientResult{}\n\t}\n\n\t// legacy clients removed; no caches to refresh\n\n\t// handlers no longer depend on legacy clients; pass nil slice initially\n\ts.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)\n\n\tif s.authManager == nil {\n\t\ts.authManager = newDefaultAuthManager()\n\t}\n\n\ts.ensureWebsocketGateway()\n\tif s.server != nil && s.wsGateway != nil {\n\t\ts.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())\n\t\ts.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) {\n\t\t\tif oldEnabled == newEnabled {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !oldEnabled && newEnabled {\n\t\t\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\t\tdefer cancel()\n\t\t\t\tif errStop := s.wsGateway.Stop(ctx); errStop != nil {\n\t\t\t\t\tlog.Warnf(\"failed to reset websocket connections after ws-auth change %t -> %t: %v\", oldEnabled, newEnabled, errStop)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlog.Debugf(\"ws-auth enabled; existing websocket sessions terminated to enforce authentication\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog.Debugf(\"ws-auth disabled; existing websocket sessions remain connected\")\n\t\t})\n\t}\n\n\tif s.hooks.OnBeforeStart != nil {\n\t\ts.hooks.OnBeforeStart(s.cfg)\n\t}\n\n\t// Register callback for startup and periodic model catalog refresh.\n\t// When remote model definitions change, re-register models for affected providers.\n\t// This intentionally rebuilds per-auth model availability from the latest catalog\n\t// snapshot instead of preserving prior registry suppression state.\n\tregistry.SetModelRefreshCallback(func(changedProviders []string) {\n\t\tif s == nil || s.coreManager == nil || len(changedProviders) == 0 {\n\t\t\treturn\n\t\t}\n\n\t\tproviderSet := make(map[string]bool, len(changedProviders))\n\t\tfor _, p := range changedProviders {\n\t\t\tproviderSet[strings.ToLower(strings.TrimSpace(p))] = true\n\t\t}\n\n\t\tauths := s.coreManager.List()\n\t\trefreshed := 0\n\t\tfor _, item := range auths {\n\t\t\tif item == nil || item.ID == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tauth, ok := s.coreManager.GetByID(item.ID)\n\t\t\tif !ok || auth == nil || auth.Disabled {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tprovider := strings.ToLower(strings.TrimSpace(auth.Provider))\n\t\t\tif !providerSet[provider] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s.refreshModelRegistrationForAuth(auth) {\n\t\t\t\trefreshed++\n\t\t\t}\n\t\t}\n\n\t\tif refreshed > 0 {\n\t\t\tlog.Infof(\"re-registered models for %d auth(s) due to model catalog changes: %v\", refreshed, changedProviders)\n\t\t}\n\t})\n\n\ts.serverErr = make(chan error, 1)\n\tgo func() {\n\t\tif errStart := s.server.Start(); errStart != nil {\n\t\t\ts.serverErr <- errStart\n\t\t} else {\n\t\t\ts.serverErr <- nil\n\t\t}\n\t}()\n\n\ttime.Sleep(100 * time.Millisecond)\n\tfmt.Printf(\"API server started successfully on: %s:%d\\n\", s.cfg.Host, s.cfg.Port)\n\n\ts.applyPprofConfig(s.cfg)\n\n\tif s.hooks.OnAfterStart != nil {\n\t\ts.hooks.OnAfterStart(s)\n\t}\n\n\tvar watcherWrapper *WatcherWrapper\n\treloadCallback := func(newCfg *config.Config) {\n\t\tpreviousStrategy := \"\"\n\t\ts.cfgMu.RLock()\n\t\tif s.cfg != nil {\n\t\t\tpreviousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))\n\t\t}\n\t\ts.cfgMu.RUnlock()\n\n\t\tif newCfg == nil {\n\t\t\ts.cfgMu.RLock()\n\t\t\tnewCfg = s.cfg\n\t\t\ts.cfgMu.RUnlock()\n\t\t}\n\t\tif newCfg == nil {\n\t\t\treturn\n\t\t}\n\n\t\tnextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))\n\t\tnormalizeStrategy := func(strategy string) string {\n\t\t\tswitch strategy {\n\t\t\tcase \"fill-first\", \"fillfirst\", \"ff\":\n\t\t\t\treturn \"fill-first\"\n\t\t\tdefault:\n\t\t\t\treturn \"round-robin\"\n\t\t\t}\n\t\t}\n\t\tpreviousStrategy = normalizeStrategy(previousStrategy)\n\t\tnextStrategy = normalizeStrategy(nextStrategy)\n\t\tif s.coreManager != nil && previousStrategy != nextStrategy {\n\t\t\tvar selector coreauth.Selector\n\t\t\tswitch nextStrategy {\n\t\t\tcase \"fill-first\":\n\t\t\t\tselector = &coreauth.FillFirstSelector{}\n\t\t\tdefault:\n\t\t\t\tselector = &coreauth.RoundRobinSelector{}\n\t\t\t}\n\t\t\ts.coreManager.SetSelector(selector)\n\t\t}\n\n\t\ts.applyRetryConfig(newCfg)\n\t\ts.applyPprofConfig(newCfg)\n\t\tif s.server != nil {\n\t\t\ts.server.UpdateClients(newCfg)\n\t\t}\n\t\ts.cfgMu.Lock()\n\t\ts.cfg = newCfg\n\t\ts.cfgMu.Unlock()\n\t\tif s.coreManager != nil {\n\t\t\ts.coreManager.SetConfig(newCfg)\n\t\t\ts.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)\n\t\t}\n\t\ts.rebindExecutors()\n\t}\n\n\twatcherWrapper, err = s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cliproxy: failed to create watcher: %w\", err)\n\t}\n\ts.watcher = watcherWrapper\n\ts.ensureAuthUpdateQueue(ctx)\n\tif s.authUpdates != nil {\n\t\twatcherWrapper.SetAuthUpdateQueue(s.authUpdates)\n\t}\n\twatcherWrapper.SetConfig(s.cfg)\n\n\twatcherCtx, watcherCancel := context.WithCancel(context.Background())\n\ts.watcherCancel = watcherCancel\n\tif err = watcherWrapper.Start(watcherCtx); err != nil {\n\t\treturn fmt.Errorf(\"cliproxy: failed to start watcher: %w\", err)\n\t}\n\tlog.Info(\"file watcher started for config and auth directory changes\")\n\n\t// Prefer core auth manager auto refresh if available.\n\tif s.coreManager != nil {\n\t\tinterval := 15 * time.Minute\n\t\ts.coreManager.StartAutoRefresh(context.Background(), interval)\n\t\tlog.Infof(\"core auth auto-refresh started (interval=%s)\", interval)\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tlog.Debug(\"service context cancelled, shutting down...\")\n\t\treturn ctx.Err()\n\tcase err = <-s.serverErr:\n\t\treturn err\n\t}\n}\n\n// Shutdown gracefully stops background workers and the HTTP server.\n// It ensures all resources are properly cleaned up and connections are closed.\n// The shutdown is idempotent and can be called multiple times safely.\n//\n// Parameters:\n//   - ctx: The context for controlling the shutdown timeout\n//\n// Returns:\n//   - error: An error if shutdown fails\nfunc (s *Service) Shutdown(ctx context.Context) error {\n\tif s == nil {\n\t\treturn nil\n\t}\n\tvar shutdownErr error\n\ts.shutdownOnce.Do(func() {\n\t\tif ctx == nil {\n\t\t\tctx = context.Background()\n\t\t}\n\n\t\t// legacy refresh loop removed; only stopping core auth manager below\n\n\t\tif s.watcherCancel != nil {\n\t\t\ts.watcherCancel()\n\t\t}\n\t\tif s.coreManager != nil {\n\t\t\ts.coreManager.StopAutoRefresh()\n\t\t}\n\t\tif s.watcher != nil {\n\t\t\tif err := s.watcher.Stop(); err != nil {\n\t\t\t\tlog.Errorf(\"failed to stop file watcher: %v\", err)\n\t\t\t\tshutdownErr = err\n\t\t\t}\n\t\t}\n\t\tif s.wsGateway != nil {\n\t\t\tif err := s.wsGateway.Stop(ctx); err != nil {\n\t\t\t\tlog.Errorf(\"failed to stop websocket gateway: %v\", err)\n\t\t\t\tif shutdownErr == nil {\n\t\t\t\t\tshutdownErr = err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif s.authQueueStop != nil {\n\t\t\ts.authQueueStop()\n\t\t\ts.authQueueStop = nil\n\t\t}\n\n\t\tif errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil {\n\t\t\tlog.Errorf(\"failed to stop pprof server: %v\", errShutdownPprof)\n\t\t\tif shutdownErr == nil {\n\t\t\t\tshutdownErr = errShutdownPprof\n\t\t\t}\n\t\t}\n\n\t\t// no legacy clients to persist\n\n\t\tif s.server != nil {\n\t\t\tshutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)\n\t\t\tdefer cancel()\n\t\t\tif err := s.server.Stop(shutdownCtx); err != nil {\n\t\t\t\tlog.Errorf(\"error stopping API server: %v\", err)\n\t\t\t\tif shutdownErr == nil {\n\t\t\t\t\tshutdownErr = err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tusage.StopDefault()\n\t})\n\treturn shutdownErr\n}\n\nfunc (s *Service) ensureAuthDir() error {\n\tinfo, err := os.Stat(s.cfg.AuthDir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tif mkErr := os.MkdirAll(s.cfg.AuthDir, 0o755); mkErr != nil {\n\t\t\t\treturn fmt.Errorf(\"cliproxy: failed to create auth directory %s: %w\", s.cfg.AuthDir, mkErr)\n\t\t\t}\n\t\t\tlog.Infof(\"created missing auth directory: %s\", s.cfg.AuthDir)\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"cliproxy: error checking auth directory %s: %w\", s.cfg.AuthDir, err)\n\t}\n\tif !info.IsDir() {\n\t\treturn fmt.Errorf(\"cliproxy: auth path exists but is not a directory: %s\", s.cfg.AuthDir)\n\t}\n\treturn nil\n}\n\n// registerModelsForAuth (re)binds provider models in the global registry using the core auth ID as client identifier.\nfunc (s *Service) registerModelsForAuth(a *coreauth.Auth) {\n\tif a == nil || a.ID == \"\" {\n\t\treturn\n\t}\n\tif a.Disabled {\n\t\tGlobalModelRegistry().UnregisterClient(a.ID)\n\t\treturn\n\t}\n\tauthKind := strings.ToLower(strings.TrimSpace(a.Attributes[\"auth_kind\"]))\n\tif authKind == \"\" {\n\t\tif kind, _ := a.AccountInfo(); strings.EqualFold(kind, \"api_key\") {\n\t\t\tauthKind = \"apikey\"\n\t\t}\n\t}\n\tif a.Attributes != nil {\n\t\tif v := strings.TrimSpace(a.Attributes[\"gemini_virtual_primary\"]); strings.EqualFold(v, \"true\") {\n\t\t\tGlobalModelRegistry().UnregisterClient(a.ID)\n\t\t\treturn\n\t\t}\n\t}\n\t// Unregister legacy client ID (if present) to avoid double counting\n\tif a.Runtime != nil {\n\t\tif idGetter, ok := a.Runtime.(interface{ GetClientID() string }); ok {\n\t\t\tif rid := idGetter.GetClientID(); rid != \"\" && rid != a.ID {\n\t\t\t\tGlobalModelRegistry().UnregisterClient(rid)\n\t\t\t}\n\t\t}\n\t}\n\tprovider := strings.ToLower(strings.TrimSpace(a.Provider))\n\tcompatProviderKey, compatDisplayName, compatDetected := openAICompatInfoFromAuth(a)\n\tif compatDetected {\n\t\tprovider = \"openai-compatibility\"\n\t}\n\texcluded := s.oauthExcludedModels(provider, authKind)\n\t// The synthesizer pre-merges per-account and global exclusions into the \"excluded_models\" attribute.\n\t// If this attribute is present, it represents the complete list of exclusions and overrides the global config.\n\tif a.Attributes != nil {\n\t\tif val, ok := a.Attributes[\"excluded_models\"]; ok && strings.TrimSpace(val) != \"\" {\n\t\t\texcluded = strings.Split(val, \",\")\n\t\t}\n\t}\n\tvar models []*ModelInfo\n\tswitch provider {\n\tcase \"gemini\":\n\t\tmodels = registry.GetGeminiModels()\n\t\tif entry := s.resolveConfigGeminiKey(a); entry != nil {\n\t\t\tif len(entry.Models) > 0 {\n\t\t\t\tmodels = buildGeminiConfigModels(entry)\n\t\t\t}\n\t\t\tif authKind == \"apikey\" {\n\t\t\t\texcluded = entry.ExcludedModels\n\t\t\t}\n\t\t}\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"vertex\":\n\t\t// Vertex AI Gemini supports the same model identifiers as Gemini.\n\t\tmodels = registry.GetGeminiVertexModels()\n\t\tif entry := s.resolveConfigVertexCompatKey(a); entry != nil {\n\t\t\tif len(entry.Models) > 0 {\n\t\t\t\tmodels = buildVertexCompatConfigModels(entry)\n\t\t\t}\n\t\t\tif authKind == \"apikey\" {\n\t\t\t\texcluded = entry.ExcludedModels\n\t\t\t}\n\t\t}\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"gemini-cli\":\n\t\tmodels = registry.GetGeminiCLIModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"aistudio\":\n\t\tmodels = registry.GetAIStudioModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"antigravity\":\n\t\tmodels = registry.GetAntigravityModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"claude\":\n\t\tmodels = registry.GetClaudeModels()\n\t\tif entry := s.resolveConfigClaudeKey(a); entry != nil {\n\t\t\tif len(entry.Models) > 0 {\n\t\t\t\tmodels = buildClaudeConfigModels(entry)\n\t\t\t}\n\t\t\tif authKind == \"apikey\" {\n\t\t\t\texcluded = entry.ExcludedModels\n\t\t\t}\n\t\t}\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"codex\":\n\t\tcodexPlanType := \"\"\n\t\tif a.Attributes != nil {\n\t\t\tcodexPlanType = strings.TrimSpace(a.Attributes[\"plan_type\"])\n\t\t}\n\t\tswitch strings.ToLower(codexPlanType) {\n\t\tcase \"pro\":\n\t\t\tmodels = registry.GetCodexProModels()\n\t\tcase \"plus\":\n\t\t\tmodels = registry.GetCodexPlusModels()\n\t\tcase \"team\", \"business\", \"go\":\n\t\t\tmodels = registry.GetCodexTeamModels()\n\t\tcase \"free\":\n\t\t\tmodels = registry.GetCodexFreeModels()\n\t\tdefault:\n\t\t\tmodels = registry.GetCodexProModels()\n\t\t}\n\t\tif entry := s.resolveConfigCodexKey(a); entry != nil {\n\t\t\tif len(entry.Models) > 0 {\n\t\t\t\tmodels = buildCodexConfigModels(entry)\n\t\t\t}\n\t\t\tif authKind == \"apikey\" {\n\t\t\t\texcluded = entry.ExcludedModels\n\t\t\t}\n\t\t}\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"qwen\":\n\t\tmodels = registry.GetQwenModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"iflow\":\n\t\tmodels = registry.GetIFlowModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tcase \"kimi\":\n\t\tmodels = registry.GetKimiModels()\n\t\tmodels = applyExcludedModels(models, excluded)\n\tdefault:\n\t\t// Handle OpenAI-compatibility providers by name using config\n\t\tif s.cfg != nil {\n\t\t\tproviderKey := provider\n\t\t\tcompatName := strings.TrimSpace(a.Provider)\n\t\t\tisCompatAuth := false\n\t\t\tif compatDetected {\n\t\t\t\tif compatProviderKey != \"\" {\n\t\t\t\t\tproviderKey = compatProviderKey\n\t\t\t\t}\n\t\t\t\tif compatDisplayName != \"\" {\n\t\t\t\t\tcompatName = compatDisplayName\n\t\t\t\t}\n\t\t\t\tisCompatAuth = true\n\t\t\t}\n\t\t\tif strings.EqualFold(providerKey, \"openai-compatibility\") {\n\t\t\t\tisCompatAuth = true\n\t\t\t\tif a.Attributes != nil {\n\t\t\t\t\tif v := strings.TrimSpace(a.Attributes[\"compat_name\"]); v != \"\" {\n\t\t\t\t\t\tcompatName = v\n\t\t\t\t\t}\n\t\t\t\t\tif v := strings.TrimSpace(a.Attributes[\"provider_key\"]); v != \"\" {\n\t\t\t\t\t\tproviderKey = strings.ToLower(v)\n\t\t\t\t\t\tisCompatAuth = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif providerKey == \"openai-compatibility\" && compatName != \"\" {\n\t\t\t\t\tproviderKey = strings.ToLower(compatName)\n\t\t\t\t}\n\t\t\t} else if a.Attributes != nil {\n\t\t\t\tif v := strings.TrimSpace(a.Attributes[\"compat_name\"]); v != \"\" {\n\t\t\t\t\tcompatName = v\n\t\t\t\t\tisCompatAuth = true\n\t\t\t\t}\n\t\t\t\tif v := strings.TrimSpace(a.Attributes[\"provider_key\"]); v != \"\" {\n\t\t\t\t\tproviderKey = strings.ToLower(v)\n\t\t\t\t\tisCompatAuth = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor i := range s.cfg.OpenAICompatibility {\n\t\t\t\tcompat := &s.cfg.OpenAICompatibility[i]\n\t\t\t\tif strings.EqualFold(compat.Name, compatName) {\n\t\t\t\t\tisCompatAuth = true\n\t\t\t\t\t// Convert compatibility models to registry models\n\t\t\t\t\tms := make([]*ModelInfo, 0, len(compat.Models))\n\t\t\t\t\tfor j := range compat.Models {\n\t\t\t\t\t\tm := compat.Models[j]\n\t\t\t\t\t\t// Use alias as model ID, fallback to name if alias is empty\n\t\t\t\t\t\tmodelID := m.Alias\n\t\t\t\t\t\tif modelID == \"\" {\n\t\t\t\t\t\t\tmodelID = m.Name\n\t\t\t\t\t\t}\n\t\t\t\t\t\tms = append(ms, &ModelInfo{\n\t\t\t\t\t\t\tID:          modelID,\n\t\t\t\t\t\t\tObject:      \"model\",\n\t\t\t\t\t\t\tCreated:     time.Now().Unix(),\n\t\t\t\t\t\t\tOwnedBy:     compat.Name,\n\t\t\t\t\t\t\tType:        \"openai-compatibility\",\n\t\t\t\t\t\t\tDisplayName: modelID,\n\t\t\t\t\t\t\tUserDefined: true,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\t// Register and return\n\t\t\t\t\tif len(ms) > 0 {\n\t\t\t\t\t\tif providerKey == \"\" {\n\t\t\t\t\t\t\tproviderKey = \"openai-compatibility\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\ts.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Ensure stale registrations are cleared when model list becomes empty.\n\t\t\t\t\t\tGlobalModelRegistry().UnregisterClient(a.ID)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tif isCompatAuth {\n\t\t\t\t// No matching provider found or models removed entirely; drop any prior registration.\n\t\t\t\tGlobalModelRegistry().UnregisterClient(a.ID)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\tmodels = applyOAuthModelAlias(s.cfg, provider, authKind, models)\n\tif len(models) > 0 {\n\t\tkey := provider\n\t\tif key == \"\" {\n\t\t\tkey = strings.ToLower(strings.TrimSpace(a.Provider))\n\t\t}\n\t\ts.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))\n\t\treturn\n\t}\n\n\tGlobalModelRegistry().UnregisterClient(a.ID)\n}\n\n// refreshModelRegistrationForAuth re-applies the latest model registration for\n// one auth and reconciles any concurrent auth changes that race with the\n// refresh. Callers are expected to pre-filter provider membership.\n//\n// Re-registration is deliberate: registry cooldown/suspension state is treated\n// as part of the previous registration snapshot and is cleared when the auth is\n// rebound to the refreshed model catalog.\nfunc (s *Service) refreshModelRegistrationForAuth(current *coreauth.Auth) bool {\n\tif s == nil || s.coreManager == nil || current == nil || current.ID == \"\" {\n\t\treturn false\n\t}\n\n\tif !current.Disabled {\n\t\ts.ensureExecutorsForAuth(current)\n\t}\n\ts.registerModelsForAuth(current)\n\n\tlatest, ok := s.latestAuthForModelRegistration(current.ID)\n\tif !ok || latest.Disabled {\n\t\tGlobalModelRegistry().UnregisterClient(current.ID)\n\t\ts.coreManager.RefreshSchedulerEntry(current.ID)\n\t\treturn false\n\t}\n\n\t// Re-apply the latest auth snapshot so concurrent auth updates cannot leave\n\t// stale model registrations behind. This may duplicate registration work when\n\t// no auth fields changed, but keeps the refresh path simple and correct.\n\ts.ensureExecutorsForAuth(latest)\n\ts.registerModelsForAuth(latest)\n\ts.coreManager.RefreshSchedulerEntry(current.ID)\n\treturn true\n}\n\n// latestAuthForModelRegistration returns the latest auth snapshot regardless of\n// provider membership. Callers use this after a registration attempt to restore\n// whichever state currently owns the client ID in the global registry.\nfunc (s *Service) latestAuthForModelRegistration(authID string) (*coreauth.Auth, bool) {\n\tif s == nil || s.coreManager == nil || authID == \"\" {\n\t\treturn nil, false\n\t}\n\tauth, ok := s.coreManager.GetByID(authID)\n\tif !ok || auth == nil || auth.ID == \"\" {\n\t\treturn nil, false\n\t}\n\treturn auth, true\n}\n\nfunc (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey {\n\tif auth == nil || s.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range s.cfg.ClaudeKey {\n\t\tentry := &s.cfg.ClaudeKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && attrBase != \"\" {\n\t\t\tif strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range s.cfg.ClaudeKey {\n\t\t\tentry := &s.cfg.ClaudeKey[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey {\n\tif auth == nil || s.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range s.cfg.GeminiKey {\n\t\tentry := &s.cfg.GeminiKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.VertexCompatKey {\n\tif auth == nil || s.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range s.cfg.VertexCompatAPIKey {\n\t\tentry := &s.cfg.VertexCompatAPIKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\tif attrKey != \"\" {\n\t\tfor i := range s.cfg.VertexCompatAPIKey {\n\t\t\tentry := &s.cfg.VertexCompatAPIKey[i]\n\t\t\tif strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey {\n\tif auth == nil || s.cfg == nil {\n\t\treturn nil\n\t}\n\tvar attrKey, attrBase string\n\tif auth.Attributes != nil {\n\t\tattrKey = strings.TrimSpace(auth.Attributes[\"api_key\"])\n\t\tattrBase = strings.TrimSpace(auth.Attributes[\"base_url\"])\n\t}\n\tfor i := range s.cfg.CodexKey {\n\t\tentry := &s.cfg.CodexKey[i]\n\t\tcfgKey := strings.TrimSpace(entry.APIKey)\n\t\tcfgBase := strings.TrimSpace(entry.BaseURL)\n\t\tif attrKey != \"\" && strings.EqualFold(cfgKey, attrKey) {\n\t\t\tif cfgBase == \"\" || strings.EqualFold(cfgBase, attrBase) {\n\t\t\t\treturn entry\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif attrKey == \"\" && attrBase != \"\" && strings.EqualFold(cfgBase, attrBase) {\n\t\t\treturn entry\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Service) oauthExcludedModels(provider, authKind string) []string {\n\tcfg := s.cfg\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\tauthKindKey := strings.ToLower(strings.TrimSpace(authKind))\n\tproviderKey := strings.ToLower(strings.TrimSpace(provider))\n\tif authKindKey == \"apikey\" {\n\t\treturn nil\n\t}\n\treturn cfg.OAuthExcludedModels[providerKey]\n}\n\nfunc applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {\n\tif len(models) == 0 || len(excluded) == 0 {\n\t\treturn models\n\t}\n\n\tpatterns := make([]string, 0, len(excluded))\n\tfor _, item := range excluded {\n\t\tif trimmed := strings.TrimSpace(item); trimmed != \"\" {\n\t\t\tpatterns = append(patterns, strings.ToLower(trimmed))\n\t\t}\n\t}\n\tif len(patterns) == 0 {\n\t\treturn models\n\t}\n\n\tfiltered := make([]*ModelInfo, 0, len(models))\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tmodelID := strings.ToLower(strings.TrimSpace(model.ID))\n\t\tblocked := false\n\t\tfor _, pattern := range patterns {\n\t\t\tif matchWildcard(pattern, modelID) {\n\t\t\t\tblocked = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !blocked {\n\t\t\tfiltered = append(filtered, model)\n\t\t}\n\t}\n\treturn filtered\n}\n\nfunc applyModelPrefixes(models []*ModelInfo, prefix string, forceModelPrefix bool) []*ModelInfo {\n\ttrimmedPrefix := strings.TrimSpace(prefix)\n\tif trimmedPrefix == \"\" || len(models) == 0 {\n\t\treturn models\n\t}\n\n\tout := make([]*ModelInfo, 0, len(models)*2)\n\tseen := make(map[string]struct{}, len(models)*2)\n\n\taddModel := func(model *ModelInfo) {\n\t\tif model == nil {\n\t\t\treturn\n\t\t}\n\t\tid := strings.TrimSpace(model.ID)\n\t\tif id == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[id]; exists {\n\t\t\treturn\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tout = append(out, model)\n\t}\n\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tbaseID := strings.TrimSpace(model.ID)\n\t\tif baseID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !forceModelPrefix || trimmedPrefix == baseID {\n\t\t\taddModel(model)\n\t\t}\n\t\tclone := *model\n\t\tclone.ID = trimmedPrefix + \"/\" + baseID\n\t\taddModel(&clone)\n\t}\n\treturn out\n}\n\n// matchWildcard performs case-insensitive wildcard matching where '*' matches any substring.\nfunc matchWildcard(pattern, value string) bool {\n\tif pattern == \"\" {\n\t\treturn false\n\t}\n\n\t// Fast path for exact match (no wildcard present).\n\tif !strings.Contains(pattern, \"*\") {\n\t\treturn pattern == value\n\t}\n\n\tparts := strings.Split(pattern, \"*\")\n\t// Handle prefix.\n\tif prefix := parts[0]; prefix != \"\" {\n\t\tif !strings.HasPrefix(value, prefix) {\n\t\t\treturn false\n\t\t}\n\t\tvalue = value[len(prefix):]\n\t}\n\n\t// Handle suffix.\n\tif suffix := parts[len(parts)-1]; suffix != \"\" {\n\t\tif !strings.HasSuffix(value, suffix) {\n\t\t\treturn false\n\t\t}\n\t\tvalue = value[:len(value)-len(suffix)]\n\t}\n\n\t// Handle middle segments in order.\n\tfor i := 1; i < len(parts)-1; i++ {\n\t\tsegment := parts[i]\n\t\tif segment == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tidx := strings.Index(value, segment)\n\t\tif idx < 0 {\n\t\t\treturn false\n\t\t}\n\t\tvalue = value[idx+len(segment):]\n\t}\n\n\treturn true\n}\n\ntype modelEntry interface {\n\tGetName() string\n\tGetAlias() string\n}\n\nfunc buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*ModelInfo {\n\tif len(models) == 0 {\n\t\treturn nil\n\t}\n\tnow := time.Now().Unix()\n\tout := make([]*ModelInfo, 0, len(models))\n\tseen := make(map[string]struct{}, len(models))\n\tfor i := range models {\n\t\tmodel := models[i]\n\t\tname := strings.TrimSpace(model.GetName())\n\t\talias := strings.TrimSpace(model.GetAlias())\n\t\tif alias == \"\" {\n\t\t\talias = name\n\t\t}\n\t\tif alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.ToLower(alias)\n\t\tif _, exists := seen[key]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tdisplay := name\n\t\tif display == \"\" {\n\t\t\tdisplay = alias\n\t\t}\n\t\tinfo := &ModelInfo{\n\t\t\tID:          alias,\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     now,\n\t\t\tOwnedBy:     ownedBy,\n\t\t\tType:        modelType,\n\t\t\tDisplayName: display,\n\t\t\tUserDefined: true,\n\t\t}\n\t\tif name != \"\" {\n\t\t\tif upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil {\n\t\t\t\tinfo.Thinking = upstream.Thinking\n\t\t\t}\n\t\t}\n\t\tout = append(out, info)\n\t}\n\treturn out\n}\n\nfunc buildVertexCompatConfigModels(entry *config.VertexCompatKey) []*ModelInfo {\n\tif entry == nil {\n\t\treturn nil\n\t}\n\treturn buildConfigModels(entry.Models, \"google\", \"vertex\")\n}\n\nfunc buildGeminiConfigModels(entry *config.GeminiKey) []*ModelInfo {\n\tif entry == nil {\n\t\treturn nil\n\t}\n\treturn buildConfigModels(entry.Models, \"google\", \"gemini\")\n}\n\nfunc buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo {\n\tif entry == nil {\n\t\treturn nil\n\t}\n\treturn buildConfigModels(entry.Models, \"anthropic\", \"claude\")\n}\n\nfunc buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo {\n\tif entry == nil {\n\t\treturn nil\n\t}\n\treturn buildConfigModels(entry.Models, \"openai\", \"openai\")\n}\n\nfunc rewriteModelInfoName(name, oldID, newID string) string {\n\ttrimmed := strings.TrimSpace(name)\n\tif trimmed == \"\" {\n\t\treturn name\n\t}\n\toldID = strings.TrimSpace(oldID)\n\tnewID = strings.TrimSpace(newID)\n\tif oldID == \"\" || newID == \"\" {\n\t\treturn name\n\t}\n\tif strings.EqualFold(oldID, newID) {\n\t\treturn name\n\t}\n\tif strings.EqualFold(trimmed, oldID) {\n\t\treturn newID\n\t}\n\tif strings.HasSuffix(trimmed, \"/\"+oldID) {\n\t\tprefix := strings.TrimSuffix(trimmed, oldID)\n\t\treturn prefix + newID\n\t}\n\tif trimmed == \"models/\"+oldID {\n\t\treturn \"models/\" + newID\n\t}\n\treturn name\n}\n\nfunc applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo {\n\tif cfg == nil || len(models) == 0 {\n\t\treturn models\n\t}\n\tchannel := coreauth.OAuthModelAliasChannel(provider, authKind)\n\tif channel == \"\" || len(cfg.OAuthModelAlias) == 0 {\n\t\treturn models\n\t}\n\taliases := cfg.OAuthModelAlias[channel]\n\tif len(aliases) == 0 {\n\t\treturn models\n\t}\n\n\ttype aliasEntry struct {\n\t\talias string\n\t\tfork  bool\n\t}\n\n\tforward := make(map[string][]aliasEntry, len(aliases))\n\tfor i := range aliases {\n\t\tname := strings.TrimSpace(aliases[i].Name)\n\t\talias := strings.TrimSpace(aliases[i].Alias)\n\t\tif name == \"\" || alias == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.EqualFold(name, alias) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.ToLower(name)\n\t\tforward[key] = append(forward[key], aliasEntry{alias: alias, fork: aliases[i].Fork})\n\t}\n\tif len(forward) == 0 {\n\t\treturn models\n\t}\n\n\tout := make([]*ModelInfo, 0, len(models))\n\tseen := make(map[string]struct{}, len(models))\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tid := strings.TrimSpace(model.ID)\n\t\tif id == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.ToLower(id)\n\t\tentries := forward[key]\n\t\tif len(entries) == 0 {\n\t\t\tif _, exists := seen[key]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[key] = struct{}{}\n\t\t\tout = append(out, model)\n\t\t\tcontinue\n\t\t}\n\n\t\tkeepOriginal := false\n\t\tfor _, entry := range entries {\n\t\t\tif entry.fork {\n\t\t\t\tkeepOriginal = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif keepOriginal {\n\t\t\tif _, exists := seen[key]; !exists {\n\t\t\t\tseen[key] = struct{}{}\n\t\t\t\tout = append(out, model)\n\t\t\t}\n\t\t}\n\n\t\taddedAlias := false\n\t\tfor _, entry := range entries {\n\t\t\tmappedID := strings.TrimSpace(entry.alias)\n\t\t\tif mappedID == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.EqualFold(mappedID, id) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\taliasKey := strings.ToLower(mappedID)\n\t\t\tif _, exists := seen[aliasKey]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[aliasKey] = struct{}{}\n\t\t\tclone := *model\n\t\t\tclone.ID = mappedID\n\t\t\tif clone.Name != \"\" {\n\t\t\t\tclone.Name = rewriteModelInfoName(clone.Name, id, mappedID)\n\t\t\t}\n\t\t\tout = append(out, &clone)\n\t\t\taddedAlias = true\n\t\t}\n\n\t\tif !keepOriginal && !addedAlias {\n\t\t\tif _, exists := seen[key]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[key] = struct{}{}\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "sdk/cliproxy/service_codex_executor_binding_test.go",
    "content": "package cliproxy\n\nimport (\n\t\"testing\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestEnsureExecutorsForAuth_CodexDoesNotReplaceInNormalMode(t *testing.T) {\n\tservice := &Service{\n\t\tcfg:         &config.Config{},\n\t\tcoreManager: coreauth.NewManager(nil, nil, nil),\n\t}\n\tauth := &coreauth.Auth{\n\t\tID:       \"codex-auth-1\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t}\n\n\tservice.ensureExecutorsForAuth(auth)\n\tfirstExecutor, okFirst := service.coreManager.Executor(\"codex\")\n\tif !okFirst || firstExecutor == nil {\n\t\tt.Fatal(\"expected codex executor after first bind\")\n\t}\n\n\tservice.ensureExecutorsForAuth(auth)\n\tsecondExecutor, okSecond := service.coreManager.Executor(\"codex\")\n\tif !okSecond || secondExecutor == nil {\n\t\tt.Fatal(\"expected codex executor after second bind\")\n\t}\n\n\tif firstExecutor != secondExecutor {\n\t\tt.Fatal(\"expected codex executor to stay unchanged in normal mode\")\n\t}\n}\n\nfunc TestEnsureExecutorsForAuthWithMode_CodexForceReplace(t *testing.T) {\n\tservice := &Service{\n\t\tcfg:         &config.Config{},\n\t\tcoreManager: coreauth.NewManager(nil, nil, nil),\n\t}\n\tauth := &coreauth.Auth{\n\t\tID:       \"codex-auth-2\",\n\t\tProvider: \"codex\",\n\t\tStatus:   coreauth.StatusActive,\n\t}\n\n\tservice.ensureExecutorsForAuth(auth)\n\tfirstExecutor, okFirst := service.coreManager.Executor(\"codex\")\n\tif !okFirst || firstExecutor == nil {\n\t\tt.Fatal(\"expected codex executor after first bind\")\n\t}\n\n\tservice.ensureExecutorsForAuthWithMode(auth, true)\n\tsecondExecutor, okSecond := service.coreManager.Executor(\"codex\")\n\tif !okSecond || secondExecutor == nil {\n\t\tt.Fatal(\"expected codex executor after forced rebind\")\n\t}\n\n\tif firstExecutor == secondExecutor {\n\t\tt.Fatal(\"expected codex executor replacement in force mode\")\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/service_excluded_models_test.go",
    "content": "package cliproxy\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) {\n\tservice := &Service{\n\t\tcfg: &config.Config{\n\t\t\tOAuthExcludedModels: map[string][]string{\n\t\t\t\t\"gemini-cli\": {\"gemini-2.5-pro\"},\n\t\t\t},\n\t\t},\n\t}\n\tauth := &coreauth.Auth{\n\t\tID:       \"auth-gemini-cli\",\n\t\tProvider: \"gemini-cli\",\n\t\tStatus:   coreauth.StatusActive,\n\t\tAttributes: map[string]string{\n\t\t\t\"auth_kind\":       \"oauth\",\n\t\t\t\"excluded_models\": \"gemini-2.5-flash\",\n\t\t},\n\t}\n\n\tregistry := GlobalModelRegistry()\n\tregistry.UnregisterClient(auth.ID)\n\tt.Cleanup(func() {\n\t\tregistry.UnregisterClient(auth.ID)\n\t})\n\n\tservice.registerModelsForAuth(auth)\n\n\tmodels := registry.GetAvailableModelsByProvider(\"gemini-cli\")\n\tif len(models) == 0 {\n\t\tt.Fatal(\"expected gemini-cli models to be registered\")\n\t}\n\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tmodelID := strings.TrimSpace(model.ID)\n\t\tif strings.EqualFold(modelID, \"gemini-2.5-flash\") {\n\t\t\tt.Fatalf(\"expected model %q to be excluded by auth attribute\", modelID)\n\t\t}\n\t}\n\n\tseenGlobalExcluded := false\n\tfor _, model := range models {\n\t\tif model == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.EqualFold(strings.TrimSpace(model.ID), \"gemini-2.5-pro\") {\n\t\t\tseenGlobalExcluded = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !seenGlobalExcluded {\n\t\tt.Fatal(\"expected global excluded model to be present when attribute override is set\")\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/service_oauth_model_alias_test.go",
    "content": "package cliproxy\n\nimport (\n\t\"testing\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc TestApplyOAuthModelAlias_Rename(t *testing.T) {\n\tcfg := &config.Config{\n\t\tOAuthModelAlias: map[string][]config.OAuthModelAlias{\n\t\t\t\"codex\": {\n\t\t\t\t{Name: \"gpt-5\", Alias: \"g5\"},\n\t\t\t},\n\t\t},\n\t}\n\tmodels := []*ModelInfo{\n\t\t{ID: \"gpt-5\", Name: \"models/gpt-5\"},\n\t}\n\n\tout := applyOAuthModelAlias(cfg, \"codex\", \"oauth\", models)\n\tif len(out) != 1 {\n\t\tt.Fatalf(\"expected 1 model, got %d\", len(out))\n\t}\n\tif out[0].ID != \"g5\" {\n\t\tt.Fatalf(\"expected model id %q, got %q\", \"g5\", out[0].ID)\n\t}\n\tif out[0].Name != \"models/g5\" {\n\t\tt.Fatalf(\"expected model name %q, got %q\", \"models/g5\", out[0].Name)\n\t}\n}\n\nfunc TestApplyOAuthModelAlias_ForkAddsAlias(t *testing.T) {\n\tcfg := &config.Config{\n\t\tOAuthModelAlias: map[string][]config.OAuthModelAlias{\n\t\t\t\"codex\": {\n\t\t\t\t{Name: \"gpt-5\", Alias: \"g5\", Fork: true},\n\t\t\t},\n\t\t},\n\t}\n\tmodels := []*ModelInfo{\n\t\t{ID: \"gpt-5\", Name: \"models/gpt-5\"},\n\t}\n\n\tout := applyOAuthModelAlias(cfg, \"codex\", \"oauth\", models)\n\tif len(out) != 2 {\n\t\tt.Fatalf(\"expected 2 models, got %d\", len(out))\n\t}\n\tif out[0].ID != \"gpt-5\" {\n\t\tt.Fatalf(\"expected first model id %q, got %q\", \"gpt-5\", out[0].ID)\n\t}\n\tif out[1].ID != \"g5\" {\n\t\tt.Fatalf(\"expected second model id %q, got %q\", \"g5\", out[1].ID)\n\t}\n\tif out[1].Name != \"models/g5\" {\n\t\tt.Fatalf(\"expected forked model name %q, got %q\", \"models/g5\", out[1].Name)\n\t}\n}\n\nfunc TestApplyOAuthModelAlias_ForkAddsMultipleAliases(t *testing.T) {\n\tcfg := &config.Config{\n\t\tOAuthModelAlias: map[string][]config.OAuthModelAlias{\n\t\t\t\"codex\": {\n\t\t\t\t{Name: \"gpt-5\", Alias: \"g5\", Fork: true},\n\t\t\t\t{Name: \"gpt-5\", Alias: \"g5-2\", Fork: true},\n\t\t\t},\n\t\t},\n\t}\n\tmodels := []*ModelInfo{\n\t\t{ID: \"gpt-5\", Name: \"models/gpt-5\"},\n\t}\n\n\tout := applyOAuthModelAlias(cfg, \"codex\", \"oauth\", models)\n\tif len(out) != 3 {\n\t\tt.Fatalf(\"expected 3 models, got %d\", len(out))\n\t}\n\tif out[0].ID != \"gpt-5\" {\n\t\tt.Fatalf(\"expected first model id %q, got %q\", \"gpt-5\", out[0].ID)\n\t}\n\tif out[1].ID != \"g5\" {\n\t\tt.Fatalf(\"expected second model id %q, got %q\", \"g5\", out[1].ID)\n\t}\n\tif out[1].Name != \"models/g5\" {\n\t\tt.Fatalf(\"expected forked model name %q, got %q\", \"models/g5\", out[1].Name)\n\t}\n\tif out[2].ID != \"g5-2\" {\n\t\tt.Fatalf(\"expected third model id %q, got %q\", \"g5-2\", out[2].ID)\n\t}\n\tif out[2].Name != \"models/g5-2\" {\n\t\tt.Fatalf(\"expected forked model name %q, got %q\", \"models/g5-2\", out[2].Name)\n\t}\n}\n"
  },
  {
    "path": "sdk/cliproxy/types.go",
    "content": "// Package cliproxy provides the core service implementation for the CLI Proxy API.\n// It includes service lifecycle management, authentication handling, file watching,\n// and integration with various AI service providers through a unified interface.\npackage cliproxy\n\nimport (\n\t\"context\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\n// TokenClientProvider loads clients backed by stored authentication tokens.\n// It provides an interface for loading authentication tokens from various sources\n// and creating clients for AI service providers.\ntype TokenClientProvider interface {\n\t// Load loads token-based clients from the configured source.\n\t//\n\t// Parameters:\n\t//   - ctx: The context for the loading operation\n\t//   - cfg: The application configuration\n\t//\n\t// Returns:\n\t//   - *TokenClientResult: The result containing loaded clients\n\t//   - error: An error if loading fails\n\tLoad(ctx context.Context, cfg *config.Config) (*TokenClientResult, error)\n}\n\n// TokenClientResult represents clients generated from persisted tokens.\n// It contains metadata about the loading operation and the number of successful authentications.\ntype TokenClientResult struct {\n\t// SuccessfulAuthed is the number of successfully authenticated clients.\n\tSuccessfulAuthed int\n}\n\n// APIKeyClientProvider loads clients backed directly by configured API keys.\n// It provides an interface for loading API key-based clients for various AI service providers.\ntype APIKeyClientProvider interface {\n\t// Load loads API key-based clients from the configuration.\n\t//\n\t// Parameters:\n\t//   - ctx: The context for the loading operation\n\t//   - cfg: The application configuration\n\t//\n\t// Returns:\n\t//   - *APIKeyClientResult: The result containing loaded clients\n\t//   - error: An error if loading fails\n\tLoad(ctx context.Context, cfg *config.Config) (*APIKeyClientResult, error)\n}\n\n// APIKeyClientResult is returned by APIKeyClientProvider.Load()\ntype APIKeyClientResult struct {\n\t// GeminiKeyCount is the number of Gemini API keys loaded\n\tGeminiKeyCount int\n\n\t// VertexCompatKeyCount is the number of Vertex-compatible API keys loaded\n\tVertexCompatKeyCount int\n\n\t// ClaudeKeyCount is the number of Claude API keys loaded\n\tClaudeKeyCount int\n\n\t// CodexKeyCount is the number of Codex API keys loaded\n\tCodexKeyCount int\n\n\t// OpenAICompatCount is the number of OpenAI compatibility API keys loaded\n\tOpenAICompatCount int\n}\n\n// WatcherFactory creates a watcher for configuration and token changes.\n// The reload callback receives the updated configuration when changes are detected.\n//\n// Parameters:\n//   - configPath: The path to the configuration file to watch\n//   - authDir: The directory containing authentication tokens to watch\n//   - reload: The callback function to call when changes are detected\n//\n// Returns:\n//   - *WatcherWrapper: A watcher wrapper instance\n//   - error: An error if watcher creation fails\ntype WatcherFactory func(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error)\n\n// WatcherWrapper exposes the subset of watcher methods required by the SDK.\ntype WatcherWrapper struct {\n\tstart func(ctx context.Context) error\n\tstop  func() error\n\n\tsetConfig             func(cfg *config.Config)\n\tsnapshotAuths         func() []*coreauth.Auth\n\tsetUpdateQueue        func(queue chan<- watcher.AuthUpdate)\n\tdispatchRuntimeUpdate func(update watcher.AuthUpdate) bool\n}\n\n// Start proxies to the underlying watcher Start implementation.\nfunc (w *WatcherWrapper) Start(ctx context.Context) error {\n\tif w == nil || w.start == nil {\n\t\treturn nil\n\t}\n\treturn w.start(ctx)\n}\n\n// Stop proxies to the underlying watcher Stop implementation.\nfunc (w *WatcherWrapper) Stop() error {\n\tif w == nil || w.stop == nil {\n\t\treturn nil\n\t}\n\treturn w.stop()\n}\n\n// SetConfig updates the watcher configuration cache.\nfunc (w *WatcherWrapper) SetConfig(cfg *config.Config) {\n\tif w == nil || w.setConfig == nil {\n\t\treturn\n\t}\n\tw.setConfig(cfg)\n}\n\n// DispatchRuntimeAuthUpdate forwards runtime auth updates (e.g., websocket providers)\n// into the watcher-managed auth update queue when available.\n// Returns true if the update was enqueued successfully.\nfunc (w *WatcherWrapper) DispatchRuntimeAuthUpdate(update watcher.AuthUpdate) bool {\n\tif w == nil || w.dispatchRuntimeUpdate == nil {\n\t\treturn false\n\t}\n\treturn w.dispatchRuntimeUpdate(update)\n}\n\n// SetClients updates the watcher file-backed clients registry.\n// SetClients and SetAPIKeyClients removed; watcher manages its own caches\n\n// SnapshotClients returns the current combined clients snapshot from the underlying watcher.\n// SnapshotClients removed; use SnapshotAuths\n\n// SnapshotAuths returns the current auth entries derived from legacy clients.\nfunc (w *WatcherWrapper) SnapshotAuths() []*coreauth.Auth {\n\tif w == nil || w.snapshotAuths == nil {\n\t\treturn nil\n\t}\n\treturn w.snapshotAuths()\n}\n\n// SetAuthUpdateQueue registers the channel used to propagate auth updates.\nfunc (w *WatcherWrapper) SetAuthUpdateQueue(queue chan<- watcher.AuthUpdate) {\n\tif w == nil || w.setUpdateQueue == nil {\n\t\treturn\n\t}\n\tw.setUpdateQueue(queue)\n}\n"
  },
  {
    "path": "sdk/cliproxy/usage/manager.go",
    "content": "package usage\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// Record contains the usage statistics captured for a single provider request.\ntype Record struct {\n\tProvider    string\n\tModel       string\n\tAPIKey      string\n\tAuthID      string\n\tAuthIndex   string\n\tSource      string\n\tRequestedAt time.Time\n\tFailed      bool\n\tDetail      Detail\n}\n\n// Detail holds the token usage breakdown.\ntype Detail struct {\n\tInputTokens     int64\n\tOutputTokens    int64\n\tReasoningTokens int64\n\tCachedTokens    int64\n\tTotalTokens     int64\n}\n\n// Plugin consumes usage records emitted by the proxy runtime.\ntype Plugin interface {\n\tHandleUsage(ctx context.Context, record Record)\n}\n\ntype queueItem struct {\n\tctx    context.Context\n\trecord Record\n}\n\n// Manager maintains a queue of usage records and delivers them to registered plugins.\ntype Manager struct {\n\tonce     sync.Once\n\tstopOnce sync.Once\n\tcancel   context.CancelFunc\n\n\tmu     sync.Mutex\n\tcond   *sync.Cond\n\tqueue  []queueItem\n\tclosed bool\n\n\tpluginsMu sync.RWMutex\n\tplugins   []Plugin\n}\n\n// NewManager constructs a manager with a buffered queue.\nfunc NewManager(buffer int) *Manager {\n\tm := &Manager{}\n\tm.cond = sync.NewCond(&m.mu)\n\treturn m\n}\n\n// Start launches the background dispatcher. Calling Start multiple times is safe.\nfunc (m *Manager) Start(ctx context.Context) {\n\tif m == nil {\n\t\treturn\n\t}\n\tm.once.Do(func() {\n\t\tif ctx == nil {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tvar workerCtx context.Context\n\t\tworkerCtx, m.cancel = context.WithCancel(ctx)\n\t\tgo m.run(workerCtx)\n\t})\n}\n\n// Stop stops the dispatcher and drains the queue.\nfunc (m *Manager) Stop() {\n\tif m == nil {\n\t\treturn\n\t}\n\tm.stopOnce.Do(func() {\n\t\tif m.cancel != nil {\n\t\t\tm.cancel()\n\t\t}\n\t\tm.mu.Lock()\n\t\tm.closed = true\n\t\tm.mu.Unlock()\n\t\tm.cond.Broadcast()\n\t})\n}\n\n// Register appends a plugin to the delivery list.\nfunc (m *Manager) Register(plugin Plugin) {\n\tif m == nil || plugin == nil {\n\t\treturn\n\t}\n\tm.pluginsMu.Lock()\n\tm.plugins = append(m.plugins, plugin)\n\tm.pluginsMu.Unlock()\n}\n\n// Publish enqueues a usage record for processing. If no plugin is registered\n// the record will be discarded downstream.\nfunc (m *Manager) Publish(ctx context.Context, record Record) {\n\tif m == nil {\n\t\treturn\n\t}\n\t// ensure worker is running even if Start was not called explicitly\n\tm.Start(context.Background())\n\tm.mu.Lock()\n\tif m.closed {\n\t\tm.mu.Unlock()\n\t\treturn\n\t}\n\tm.queue = append(m.queue, queueItem{ctx: ctx, record: record})\n\tm.mu.Unlock()\n\tm.cond.Signal()\n}\n\nfunc (m *Manager) run(ctx context.Context) {\n\tfor {\n\t\tm.mu.Lock()\n\t\tfor !m.closed && len(m.queue) == 0 {\n\t\t\tm.cond.Wait()\n\t\t}\n\t\tif len(m.queue) == 0 && m.closed {\n\t\t\tm.mu.Unlock()\n\t\t\treturn\n\t\t}\n\t\titem := m.queue[0]\n\t\tm.queue = m.queue[1:]\n\t\tm.mu.Unlock()\n\t\tm.dispatch(item)\n\t}\n}\n\nfunc (m *Manager) dispatch(item queueItem) {\n\tm.pluginsMu.RLock()\n\tplugins := make([]Plugin, len(m.plugins))\n\tcopy(plugins, m.plugins)\n\tm.pluginsMu.RUnlock()\n\tif len(plugins) == 0 {\n\t\treturn\n\t}\n\tfor _, plugin := range plugins {\n\t\tif plugin == nil {\n\t\t\tcontinue\n\t\t}\n\t\tsafeInvoke(plugin, item.ctx, item.record)\n\t}\n}\n\nfunc safeInvoke(plugin Plugin, ctx context.Context, record Record) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tlog.Errorf(\"usage: plugin panic recovered: %v\", r)\n\t\t}\n\t}()\n\tplugin.HandleUsage(ctx, record)\n}\n\nvar defaultManager = NewManager(512)\n\n// DefaultManager returns the global usage manager instance.\nfunc DefaultManager() *Manager { return defaultManager }\n\n// RegisterPlugin registers a plugin on the default manager.\nfunc RegisterPlugin(plugin Plugin) { DefaultManager().Register(plugin) }\n\n// PublishRecord publishes a record using the default manager.\nfunc PublishRecord(ctx context.Context, record Record) { DefaultManager().Publish(ctx, record) }\n\n// StartDefault starts the default manager's dispatcher.\nfunc StartDefault(ctx context.Context) { DefaultManager().Start(ctx) }\n\n// StopDefault stops the default manager's dispatcher.\nfunc StopDefault() { DefaultManager().Stop() }\n"
  },
  {
    "path": "sdk/cliproxy/watcher.go",
    "content": "package cliproxy\n\nimport (\n\t\"context\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher\"\n\tcoreauth \"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/sdk/config\"\n)\n\nfunc defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {\n\tw, err := watcher.NewWatcher(configPath, authDir, reload)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &WatcherWrapper{\n\t\tstart: func(ctx context.Context) error {\n\t\t\treturn w.Start(ctx)\n\t\t},\n\t\tstop: func() error {\n\t\t\treturn w.Stop()\n\t\t},\n\t\tsetConfig: func(cfg *config.Config) {\n\t\t\tw.SetConfig(cfg)\n\t\t},\n\t\tsnapshotAuths: func() []*coreauth.Auth { return w.SnapshotCoreAuths() },\n\t\tsetUpdateQueue: func(queue chan<- watcher.AuthUpdate) {\n\t\t\tw.SetAuthUpdateQueue(queue)\n\t\t},\n\t\tdispatchRuntimeUpdate: func(update watcher.AuthUpdate) bool {\n\t\t\treturn w.DispatchRuntimeAuthUpdate(update)\n\t\t},\n\t}, nil\n}\n"
  },
  {
    "path": "sdk/config/config.go",
    "content": "// Package config provides the public SDK configuration API.\n//\n// It re-exports the server configuration types and helpers so external projects can\n// embed CLIProxyAPI without importing internal packages.\npackage config\n\nimport internalconfig \"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n\ntype SDKConfig = internalconfig.SDKConfig\n\ntype Config = internalconfig.Config\n\ntype StreamingConfig = internalconfig.StreamingConfig\ntype TLSConfig = internalconfig.TLSConfig\ntype RemoteManagement = internalconfig.RemoteManagement\ntype AmpCode = internalconfig.AmpCode\ntype OAuthModelAlias = internalconfig.OAuthModelAlias\ntype PayloadConfig = internalconfig.PayloadConfig\ntype PayloadRule = internalconfig.PayloadRule\ntype PayloadFilterRule = internalconfig.PayloadFilterRule\ntype PayloadModelRule = internalconfig.PayloadModelRule\n\ntype GeminiKey = internalconfig.GeminiKey\ntype CodexKey = internalconfig.CodexKey\ntype ClaudeKey = internalconfig.ClaudeKey\ntype VertexCompatKey = internalconfig.VertexCompatKey\ntype VertexCompatModel = internalconfig.VertexCompatModel\ntype OpenAICompatibility = internalconfig.OpenAICompatibility\ntype OpenAICompatibilityAPIKey = internalconfig.OpenAICompatibilityAPIKey\ntype OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel\n\ntype TLS = internalconfig.TLSConfig\n\nconst (\n\tDefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository\n)\n\nfunc LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }\n\nfunc LoadConfigOptional(configFile string, optional bool) (*Config, error) {\n\treturn internalconfig.LoadConfigOptional(configFile, optional)\n}\n\nfunc SaveConfigPreserveComments(configFile string, cfg *Config) error {\n\treturn internalconfig.SaveConfigPreserveComments(configFile, cfg)\n}\n\nfunc SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {\n\treturn internalconfig.SaveConfigPreserveCommentsUpdateNestedScalar(configFile, path, value)\n}\n\nfunc NormalizeCommentIndentation(data []byte) []byte {\n\treturn internalconfig.NormalizeCommentIndentation(data)\n}\n"
  },
  {
    "path": "sdk/logging/request_logger.go",
    "content": "// Package logging re-exports request logging primitives for SDK consumers.\npackage logging\n\nimport internallogging \"github.com/router-for-me/CLIProxyAPI/v6/internal/logging\"\n\nconst defaultErrorLogsMaxFiles = 10\n\n// RequestLogger defines the interface for logging HTTP requests and responses.\ntype RequestLogger = internallogging.RequestLogger\n\n// StreamingLogWriter handles real-time logging of streaming response chunks.\ntype StreamingLogWriter = internallogging.StreamingLogWriter\n\n// FileRequestLogger implements RequestLogger using file-based storage.\ntype FileRequestLogger = internallogging.FileRequestLogger\n\n// NewFileRequestLogger creates a new file-based request logger with default error log retention (10 files).\nfunc NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {\n\treturn internallogging.NewFileRequestLogger(enabled, logsDir, configDir, defaultErrorLogsMaxFiles)\n}\n\n// NewFileRequestLoggerWithOptions creates a new file-based request logger with configurable error log retention.\nfunc NewFileRequestLoggerWithOptions(enabled bool, logsDir string, configDir string, errorLogsMaxFiles int) *FileRequestLogger {\n\treturn internallogging.NewFileRequestLogger(enabled, logsDir, configDir, errorLogsMaxFiles)\n}\n"
  },
  {
    "path": "sdk/proxyutil/proxy.go",
    "content": "package proxyutil\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"golang.org/x/net/proxy\"\n)\n\n// Mode describes how a proxy setting should be interpreted.\ntype Mode int\n\nconst (\n\t// ModeInherit means no explicit proxy behavior was configured.\n\tModeInherit Mode = iota\n\t// ModeDirect means outbound requests must bypass proxies explicitly.\n\tModeDirect\n\t// ModeProxy means a concrete proxy URL was configured.\n\tModeProxy\n\t// ModeInvalid means the proxy setting is present but malformed or unsupported.\n\tModeInvalid\n)\n\n// Setting is the normalized interpretation of a proxy configuration value.\ntype Setting struct {\n\tRaw  string\n\tMode Mode\n\tURL  *url.URL\n}\n\n// Parse normalizes a proxy configuration value into inherit, direct, or proxy modes.\nfunc Parse(raw string) (Setting, error) {\n\ttrimmed := strings.TrimSpace(raw)\n\tsetting := Setting{Raw: trimmed}\n\n\tif trimmed == \"\" {\n\t\tsetting.Mode = ModeInherit\n\t\treturn setting, nil\n\t}\n\n\tif strings.EqualFold(trimmed, \"direct\") || strings.EqualFold(trimmed, \"none\") {\n\t\tsetting.Mode = ModeDirect\n\t\treturn setting, nil\n\t}\n\n\tparsedURL, errParse := url.Parse(trimmed)\n\tif errParse != nil {\n\t\tsetting.Mode = ModeInvalid\n\t\treturn setting, fmt.Errorf(\"parse proxy URL failed: %w\", errParse)\n\t}\n\tif parsedURL.Scheme == \"\" || parsedURL.Host == \"\" {\n\t\tsetting.Mode = ModeInvalid\n\t\treturn setting, fmt.Errorf(\"proxy URL missing scheme/host\")\n\t}\n\n\tswitch parsedURL.Scheme {\n\tcase \"socks5\", \"http\", \"https\":\n\t\tsetting.Mode = ModeProxy\n\t\tsetting.URL = parsedURL\n\t\treturn setting, nil\n\tdefault:\n\t\tsetting.Mode = ModeInvalid\n\t\treturn setting, fmt.Errorf(\"unsupported proxy scheme: %s\", parsedURL.Scheme)\n\t}\n}\n\n// NewDirectTransport returns a transport that bypasses environment proxies.\nfunc NewDirectTransport() *http.Transport {\n\tif transport, ok := http.DefaultTransport.(*http.Transport); ok && transport != nil {\n\t\tclone := transport.Clone()\n\t\tclone.Proxy = nil\n\t\treturn clone\n\t}\n\treturn &http.Transport{Proxy: nil}\n}\n\n// BuildHTTPTransport constructs an HTTP transport for the provided proxy setting.\nfunc BuildHTTPTransport(raw string) (*http.Transport, Mode, error) {\n\tsetting, errParse := Parse(raw)\n\tif errParse != nil {\n\t\treturn nil, setting.Mode, errParse\n\t}\n\n\tswitch setting.Mode {\n\tcase ModeInherit:\n\t\treturn nil, setting.Mode, nil\n\tcase ModeDirect:\n\t\treturn NewDirectTransport(), setting.Mode, nil\n\tcase ModeProxy:\n\t\tif setting.URL.Scheme == \"socks5\" {\n\t\t\tvar proxyAuth *proxy.Auth\n\t\t\tif setting.URL.User != nil {\n\t\t\t\tusername := setting.URL.User.Username()\n\t\t\t\tpassword, _ := setting.URL.User.Password()\n\t\t\t\tproxyAuth = &proxy.Auth{User: username, Password: password}\n\t\t\t}\n\t\t\tdialer, errSOCKS5 := proxy.SOCKS5(\"tcp\", setting.URL.Host, proxyAuth, proxy.Direct)\n\t\t\tif errSOCKS5 != nil {\n\t\t\t\treturn nil, setting.Mode, fmt.Errorf(\"create SOCKS5 dialer failed: %w\", errSOCKS5)\n\t\t\t}\n\t\t\treturn &http.Transport{\n\t\t\t\tProxy: nil,\n\t\t\t\tDialContext: func(_ context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\t\treturn dialer.Dial(network, addr)\n\t\t\t\t},\n\t\t\t}, setting.Mode, nil\n\t\t}\n\t\treturn &http.Transport{Proxy: http.ProxyURL(setting.URL)}, setting.Mode, nil\n\tdefault:\n\t\treturn nil, setting.Mode, nil\n\t}\n}\n\n// BuildDialer constructs a proxy dialer for settings that operate at the connection layer.\nfunc BuildDialer(raw string) (proxy.Dialer, Mode, error) {\n\tsetting, errParse := Parse(raw)\n\tif errParse != nil {\n\t\treturn nil, setting.Mode, errParse\n\t}\n\n\tswitch setting.Mode {\n\tcase ModeInherit:\n\t\treturn nil, setting.Mode, nil\n\tcase ModeDirect:\n\t\treturn proxy.Direct, setting.Mode, nil\n\tcase ModeProxy:\n\t\tdialer, errDialer := proxy.FromURL(setting.URL, proxy.Direct)\n\t\tif errDialer != nil {\n\t\t\treturn nil, setting.Mode, fmt.Errorf(\"create proxy dialer failed: %w\", errDialer)\n\t\t}\n\t\treturn dialer, setting.Mode, nil\n\tdefault:\n\t\treturn nil, setting.Mode, nil\n\t}\n}\n"
  },
  {
    "path": "sdk/proxyutil/proxy_test.go",
    "content": "package proxyutil\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestParse(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\tname    string\n\t\tinput   string\n\t\twant    Mode\n\t\twantErr bool\n\t}{\n\t\t{name: \"inherit\", input: \"\", want: ModeInherit},\n\t\t{name: \"direct\", input: \"direct\", want: ModeDirect},\n\t\t{name: \"none\", input: \"none\", want: ModeDirect},\n\t\t{name: \"http\", input: \"http://proxy.example.com:8080\", want: ModeProxy},\n\t\t{name: \"https\", input: \"https://proxy.example.com:8443\", want: ModeProxy},\n\t\t{name: \"socks5\", input: \"socks5://proxy.example.com:1080\", want: ModeProxy},\n\t\t{name: \"invalid\", input: \"bad-value\", want: ModeInvalid, wantErr: true},\n\t}\n\n\tfor _, tt := range tests {\n\t\ttt := tt\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tsetting, errParse := Parse(tt.input)\n\t\t\tif tt.wantErr && errParse == nil {\n\t\t\t\tt.Fatal(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !tt.wantErr && errParse != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", errParse)\n\t\t\t}\n\t\t\tif setting.Mode != tt.want {\n\t\t\t\tt.Fatalf(\"mode = %d, want %d\", setting.Mode, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildHTTPTransportDirectBypassesProxy(t *testing.T) {\n\tt.Parallel()\n\n\ttransport, mode, errBuild := BuildHTTPTransport(\"direct\")\n\tif errBuild != nil {\n\t\tt.Fatalf(\"BuildHTTPTransport returned error: %v\", errBuild)\n\t}\n\tif mode != ModeDirect {\n\t\tt.Fatalf(\"mode = %d, want %d\", mode, ModeDirect)\n\t}\n\tif transport == nil {\n\t\tt.Fatal(\"expected transport, got nil\")\n\t}\n\tif transport.Proxy != nil {\n\t\tt.Fatal(\"expected direct transport to disable proxy function\")\n\t}\n}\n\nfunc TestBuildHTTPTransportHTTPProxy(t *testing.T) {\n\tt.Parallel()\n\n\ttransport, mode, errBuild := BuildHTTPTransport(\"http://proxy.example.com:8080\")\n\tif errBuild != nil {\n\t\tt.Fatalf(\"BuildHTTPTransport returned error: %v\", errBuild)\n\t}\n\tif mode != ModeProxy {\n\t\tt.Fatalf(\"mode = %d, want %d\", mode, ModeProxy)\n\t}\n\tif transport == nil {\n\t\tt.Fatal(\"expected transport, got nil\")\n\t}\n\n\treq, errRequest := http.NewRequest(http.MethodGet, \"https://example.com\", nil)\n\tif errRequest != nil {\n\t\tt.Fatalf(\"http.NewRequest returned error: %v\", errRequest)\n\t}\n\n\tproxyURL, errProxy := transport.Proxy(req)\n\tif errProxy != nil {\n\t\tt.Fatalf(\"transport.Proxy returned error: %v\", errProxy)\n\t}\n\tif proxyURL == nil || proxyURL.String() != \"http://proxy.example.com:8080\" {\n\t\tt.Fatalf(\"proxy URL = %v, want http://proxy.example.com:8080\", proxyURL)\n\t}\n}\n"
  },
  {
    "path": "sdk/translator/builtin/builtin.go",
    "content": "// Package builtin exposes the built-in translator registrations for SDK users.\npackage builtin\n\nimport (\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator\"\n)\n\n// Registry exposes the default registry populated with all built-in translators.\nfunc Registry() *sdktranslator.Registry {\n\treturn sdktranslator.Default()\n}\n\n// Pipeline returns a pipeline that already contains the built-in translators.\nfunc Pipeline() *sdktranslator.Pipeline {\n\treturn sdktranslator.NewPipeline(sdktranslator.Default())\n}\n"
  },
  {
    "path": "sdk/translator/format.go",
    "content": "package translator\n\n// Format identifies a request/response schema used inside the proxy.\ntype Format string\n\n// FromString converts an arbitrary identifier to a translator format.\nfunc FromString(v string) Format {\n\treturn Format(v)\n}\n\n// String returns the raw schema identifier.\nfunc (f Format) String() string {\n\treturn string(f)\n}\n"
  },
  {
    "path": "sdk/translator/formats.go",
    "content": "package translator\n\n// Common format identifiers exposed for SDK users.\nconst (\n\tFormatOpenAI         Format = \"openai\"\n\tFormatOpenAIResponse Format = \"openai-response\"\n\tFormatClaude         Format = \"claude\"\n\tFormatGemini         Format = \"gemini\"\n\tFormatGeminiCLI      Format = \"gemini-cli\"\n\tFormatCodex          Format = \"codex\"\n\tFormatAntigravity    Format = \"antigravity\"\n)\n"
  },
  {
    "path": "sdk/translator/helpers.go",
    "content": "package translator\n\nimport \"context\"\n\n// TranslateRequestByFormatName converts a request payload between schemas by their string identifiers.\nfunc TranslateRequestByFormatName(from, to Format, model string, rawJSON []byte, stream bool) []byte {\n\treturn TranslateRequest(from, to, model, rawJSON, stream)\n}\n\n// HasResponseTransformerByFormatName reports whether a response translator exists between two schemas.\nfunc HasResponseTransformerByFormatName(from, to Format) bool {\n\treturn HasResponseTransformer(from, to)\n}\n\n// TranslateStreamByFormatName converts streaming responses between schemas by their string identifiers.\nfunc TranslateStreamByFormatName(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\treturn TranslateStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\n// TranslateNonStreamByFormatName converts non-streaming responses between schemas by their string identifiers.\nfunc TranslateNonStreamByFormatName(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\treturn TranslateNonStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\n// TranslateTokenCountByFormatName converts token counts between schemas by their string identifiers.\nfunc TranslateTokenCountByFormatName(ctx context.Context, from, to Format, count int64, rawJSON []byte) string {\n\treturn TranslateTokenCount(ctx, from, to, count, rawJSON)\n}\n"
  },
  {
    "path": "sdk/translator/pipeline.go",
    "content": "package translator\n\nimport \"context\"\n\n// RequestEnvelope represents a request in the translation pipeline.\ntype RequestEnvelope struct {\n\tFormat Format\n\tModel  string\n\tStream bool\n\tBody   []byte\n}\n\n// ResponseEnvelope represents a response in the translation pipeline.\ntype ResponseEnvelope struct {\n\tFormat Format\n\tModel  string\n\tStream bool\n\tBody   []byte\n\tChunks []string\n}\n\n// RequestMiddleware decorates request translation.\ntype RequestMiddleware func(ctx context.Context, req RequestEnvelope, next RequestHandler) (RequestEnvelope, error)\n\n// ResponseMiddleware decorates response translation.\ntype ResponseMiddleware func(ctx context.Context, resp ResponseEnvelope, next ResponseHandler) (ResponseEnvelope, error)\n\n// RequestHandler performs request translation between formats.\ntype RequestHandler func(ctx context.Context, req RequestEnvelope) (RequestEnvelope, error)\n\n// ResponseHandler performs response translation between formats.\ntype ResponseHandler func(ctx context.Context, resp ResponseEnvelope) (ResponseEnvelope, error)\n\n// Pipeline orchestrates request/response transformation with middleware support.\ntype Pipeline struct {\n\tregistry           *Registry\n\trequestMiddleware  []RequestMiddleware\n\tresponseMiddleware []ResponseMiddleware\n}\n\n// NewPipeline constructs a pipeline bound to the provided registry.\nfunc NewPipeline(registry *Registry) *Pipeline {\n\tif registry == nil {\n\t\tregistry = Default()\n\t}\n\treturn &Pipeline{registry: registry}\n}\n\n// UseRequest adds request middleware executed in registration order.\nfunc (p *Pipeline) UseRequest(mw RequestMiddleware) {\n\tif mw != nil {\n\t\tp.requestMiddleware = append(p.requestMiddleware, mw)\n\t}\n}\n\n// UseResponse adds response middleware executed in registration order.\nfunc (p *Pipeline) UseResponse(mw ResponseMiddleware) {\n\tif mw != nil {\n\t\tp.responseMiddleware = append(p.responseMiddleware, mw)\n\t}\n}\n\n// TranslateRequest applies middleware and registry transformations.\nfunc (p *Pipeline) TranslateRequest(ctx context.Context, from, to Format, req RequestEnvelope) (RequestEnvelope, error) {\n\tterminal := func(ctx context.Context, input RequestEnvelope) (RequestEnvelope, error) {\n\t\ttranslated := p.registry.TranslateRequest(from, to, input.Model, input.Body, input.Stream)\n\t\tinput.Body = translated\n\t\tinput.Format = to\n\t\treturn input, nil\n\t}\n\n\thandler := terminal\n\tfor i := len(p.requestMiddleware) - 1; i >= 0; i-- {\n\t\tmw := p.requestMiddleware[i]\n\t\tnext := handler\n\t\thandler = func(ctx context.Context, r RequestEnvelope) (RequestEnvelope, error) {\n\t\t\treturn mw(ctx, r, next)\n\t\t}\n\t}\n\n\treturn handler(ctx, req)\n}\n\n// TranslateResponse applies middleware and registry transformations.\nfunc (p *Pipeline) TranslateResponse(ctx context.Context, from, to Format, resp ResponseEnvelope, originalReq, translatedReq []byte, param *any) (ResponseEnvelope, error) {\n\tterminal := func(ctx context.Context, input ResponseEnvelope) (ResponseEnvelope, error) {\n\t\tif input.Stream {\n\t\t\tinput.Chunks = p.registry.TranslateStream(ctx, from, to, input.Model, originalReq, translatedReq, input.Body, param)\n\t\t} else {\n\t\t\tinput.Body = []byte(p.registry.TranslateNonStream(ctx, from, to, input.Model, originalReq, translatedReq, input.Body, param))\n\t\t}\n\t\tinput.Format = to\n\t\treturn input, nil\n\t}\n\n\thandler := terminal\n\tfor i := len(p.responseMiddleware) - 1; i >= 0; i-- {\n\t\tmw := p.responseMiddleware[i]\n\t\tnext := handler\n\t\thandler = func(ctx context.Context, r ResponseEnvelope) (ResponseEnvelope, error) {\n\t\t\treturn mw(ctx, r, next)\n\t\t}\n\t}\n\n\treturn handler(ctx, resp)\n}\n"
  },
  {
    "path": "sdk/translator/registry.go",
    "content": "package translator\n\nimport (\n\t\"context\"\n\t\"sync\"\n)\n\n// Registry manages translation functions across schemas.\ntype Registry struct {\n\tmu        sync.RWMutex\n\trequests  map[Format]map[Format]RequestTransform\n\tresponses map[Format]map[Format]ResponseTransform\n}\n\n// NewRegistry constructs an empty translator registry.\nfunc NewRegistry() *Registry {\n\treturn &Registry{\n\t\trequests:  make(map[Format]map[Format]RequestTransform),\n\t\tresponses: make(map[Format]map[Format]ResponseTransform),\n\t}\n}\n\n// Register stores request/response transforms between two formats.\nfunc (r *Registry) Register(from, to Format, request RequestTransform, response ResponseTransform) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\n\tif _, ok := r.requests[from]; !ok {\n\t\tr.requests[from] = make(map[Format]RequestTransform)\n\t}\n\tif request != nil {\n\t\tr.requests[from][to] = request\n\t}\n\n\tif _, ok := r.responses[from]; !ok {\n\t\tr.responses[from] = make(map[Format]ResponseTransform)\n\t}\n\tr.responses[from][to] = response\n}\n\n// TranslateRequest converts a payload between schemas, returning the original payload\n// if no translator is registered.\nfunc (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tif byTarget, ok := r.requests[from]; ok {\n\t\tif fn, isOk := byTarget[to]; isOk && fn != nil {\n\t\t\treturn fn(model, rawJSON, stream)\n\t\t}\n\t}\n\treturn rawJSON\n}\n\n// HasResponseTransformer indicates whether a response translator exists.\nfunc (r *Registry) HasResponseTransformer(from, to Format) bool {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tif byTarget, ok := r.responses[from]; ok {\n\t\tif _, isOk := byTarget[to]; isOk {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TranslateStream applies the registered streaming response translator.\nfunc (r *Registry) TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tif byTarget, ok := r.responses[to]; ok {\n\t\tif fn, isOk := byTarget[from]; isOk && fn.Stream != nil {\n\t\t\treturn fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\t\t}\n\t}\n\treturn []string{string(rawJSON)}\n}\n\n// TranslateNonStream applies the registered non-stream response translator.\nfunc (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tif byTarget, ok := r.responses[to]; ok {\n\t\tif fn, isOk := byTarget[from]; isOk && fn.NonStream != nil {\n\t\t\treturn fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n\t\t}\n\t}\n\treturn string(rawJSON)\n}\n\n// TranslateNonStream applies the registered non-stream response translator.\nfunc (r *Registry) TranslateTokenCount(ctx context.Context, from, to Format, count int64, rawJSON []byte) string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tif byTarget, ok := r.responses[to]; ok {\n\t\tif fn, isOk := byTarget[from]; isOk && fn.TokenCount != nil {\n\t\t\treturn fn.TokenCount(ctx, count)\n\t\t}\n\t}\n\treturn string(rawJSON)\n}\n\nvar defaultRegistry = NewRegistry()\n\n// Default exposes the package-level registry for shared use.\nfunc Default() *Registry {\n\treturn defaultRegistry\n}\n\n// Register attaches transforms to the default registry.\nfunc Register(from, to Format, request RequestTransform, response ResponseTransform) {\n\tdefaultRegistry.Register(from, to, request, response)\n}\n\n// TranslateRequest is a helper on the default registry.\nfunc TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {\n\treturn defaultRegistry.TranslateRequest(from, to, model, rawJSON, stream)\n}\n\n// HasResponseTransformer inspects the default registry.\nfunc HasResponseTransformer(from, to Format) bool {\n\treturn defaultRegistry.HasResponseTransformer(from, to)\n}\n\n// TranslateStream is a helper on the default registry.\nfunc TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\treturn defaultRegistry.TranslateStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\n// TranslateNonStream is a helper on the default registry.\nfunc TranslateNonStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string {\n\treturn defaultRegistry.TranslateNonStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)\n}\n\n// TranslateTokenCount is a helper on the default registry.\nfunc TranslateTokenCount(ctx context.Context, from, to Format, count int64, rawJSON []byte) string {\n\treturn defaultRegistry.TranslateTokenCount(ctx, from, to, count, rawJSON)\n}\n"
  },
  {
    "path": "sdk/translator/types.go",
    "content": "// Package translator provides types and functions for converting chat requests and responses between different schemas.\npackage translator\n\nimport \"context\"\n\n// RequestTransform is a function type that converts a request payload from a source schema to a target schema.\n// It takes the model name, the raw JSON payload of the request, and a boolean indicating if the request is for a streaming response.\n// It returns the converted request payload as a byte slice.\ntype RequestTransform func(model string, rawJSON []byte, stream bool) []byte\n\n// ResponseStreamTransform is a function type that converts a streaming response from a source schema to a target schema.\n// It takes a context, the model name, the raw JSON of the original and converted requests, the raw JSON of the current response chunk, and an optional parameter.\n// It returns a slice of strings, where each string is a chunk of the converted streaming response.\ntype ResponseStreamTransform func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string\n\n// ResponseNonStreamTransform is a function type that converts a non-streaming response from a source schema to a target schema.\n// It takes a context, the model name, the raw JSON of the original and converted requests, the raw JSON of the response, and an optional parameter.\n// It returns the converted response as a single string.\ntype ResponseNonStreamTransform func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) string\n\n// ResponseTokenCountTransform is a function type that transforms a token count from a source format to a target format.\n// It takes a context and the token count as an int64, and returns the transformed token count as a string.\ntype ResponseTokenCountTransform func(ctx context.Context, count int64) string\n\n// ResponseTransform is a struct that groups together the functions for transforming streaming and non-streaming responses,\n// as well as token counts.\ntype ResponseTransform struct {\n\t// Stream is the function for transforming streaming responses.\n\tStream ResponseStreamTransform\n\t// NonStream is the function for transforming non-streaming responses.\n\tNonStream ResponseNonStreamTransform\n\t// TokenCount is the function for transforming token counts.\n\tTokenCount ResponseTokenCountTransform\n}\n"
  },
  {
    "path": "test/amp_management_test.go",
    "content": "package test\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/config\"\n)\n\nfunc init() {\n\tgin.SetMode(gin.TestMode)\n}\n\n// newAmpTestHandler creates a test handler with default ampcode configuration.\nfunc newAmpTestHandler(t *testing.T) (*management.Handler, string) {\n\tt.Helper()\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\n\tcfg := &config.Config{\n\t\tAmpCode: config.AmpCode{\n\t\t\tUpstreamURL:                   \"https://example.com\",\n\t\t\tUpstreamAPIKey:                \"test-api-key-12345\",\n\t\t\tRestrictManagementToLocalhost: true,\n\t\t\tForceModelMappings:            false,\n\t\t\tModelMappings: []config.AmpModelMapping{\n\t\t\t\t{From: \"gpt-4\", To: \"gemini-pro\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tif err := os.WriteFile(configPath, []byte(\"port: 8080\\n\"), 0644); err != nil {\n\t\tt.Fatalf(\"failed to write config file: %v\", err)\n\t}\n\n\th := management.NewHandler(cfg, configPath, nil)\n\treturn h, configPath\n}\n\n// setupAmpRouter creates a test router with all ampcode management endpoints.\nfunc setupAmpRouter(h *management.Handler) *gin.Engine {\n\tr := gin.New()\n\tmgmt := r.Group(\"/v0/management\")\n\t{\n\t\tmgmt.GET(\"/ampcode\", h.GetAmpCode)\n\t\tmgmt.GET(\"/ampcode/upstream-url\", h.GetAmpUpstreamURL)\n\t\tmgmt.PUT(\"/ampcode/upstream-url\", h.PutAmpUpstreamURL)\n\t\tmgmt.DELETE(\"/ampcode/upstream-url\", h.DeleteAmpUpstreamURL)\n\t\tmgmt.GET(\"/ampcode/upstream-api-key\", h.GetAmpUpstreamAPIKey)\n\t\tmgmt.PUT(\"/ampcode/upstream-api-key\", h.PutAmpUpstreamAPIKey)\n\t\tmgmt.DELETE(\"/ampcode/upstream-api-key\", h.DeleteAmpUpstreamAPIKey)\n\t\tmgmt.GET(\"/ampcode/upstream-api-keys\", h.GetAmpUpstreamAPIKeys)\n\t\tmgmt.PUT(\"/ampcode/upstream-api-keys\", h.PutAmpUpstreamAPIKeys)\n\t\tmgmt.PATCH(\"/ampcode/upstream-api-keys\", h.PatchAmpUpstreamAPIKeys)\n\t\tmgmt.DELETE(\"/ampcode/upstream-api-keys\", h.DeleteAmpUpstreamAPIKeys)\n\t\tmgmt.GET(\"/ampcode/restrict-management-to-localhost\", h.GetAmpRestrictManagementToLocalhost)\n\t\tmgmt.PUT(\"/ampcode/restrict-management-to-localhost\", h.PutAmpRestrictManagementToLocalhost)\n\t\tmgmt.GET(\"/ampcode/model-mappings\", h.GetAmpModelMappings)\n\t\tmgmt.PUT(\"/ampcode/model-mappings\", h.PutAmpModelMappings)\n\t\tmgmt.PATCH(\"/ampcode/model-mappings\", h.PatchAmpModelMappings)\n\t\tmgmt.DELETE(\"/ampcode/model-mappings\", h.DeleteAmpModelMappings)\n\t\tmgmt.GET(\"/ampcode/force-model-mappings\", h.GetAmpForceModelMappings)\n\t\tmgmt.PUT(\"/ampcode/force-model-mappings\", h.PutAmpForceModelMappings)\n\t}\n\treturn r\n}\n\n// TestGetAmpCode verifies GET /v0/management/ampcode returns full ampcode config.\nfunc TestGetAmpCode(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string]config.AmpCode\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tampcode := resp[\"ampcode\"]\n\tif ampcode.UpstreamURL != \"https://example.com\" {\n\t\tt.Errorf(\"expected upstream-url %q, got %q\", \"https://example.com\", ampcode.UpstreamURL)\n\t}\n\tif len(ampcode.ModelMappings) != 1 {\n\t\tt.Errorf(\"expected 1 model mapping, got %d\", len(ampcode.ModelMappings))\n\t}\n}\n\n// TestGetAmpUpstreamURL verifies GET /v0/management/ampcode/upstream-url returns the upstream URL.\nfunc TestGetAmpUpstreamURL(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-url\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string]string\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tif resp[\"upstream-url\"] != \"https://example.com\" {\n\t\tt.Errorf(\"expected %q, got %q\", \"https://example.com\", resp[\"upstream-url\"])\n\t}\n}\n\n// TestPutAmpUpstreamURL verifies PUT /v0/management/ampcode/upstream-url updates the upstream URL.\nfunc TestPutAmpUpstreamURL(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": \"https://new-upstream.com\"}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-url\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d: %s\", http.StatusOK, w.Code, w.Body.String())\n\t}\n}\n\n// TestDeleteAmpUpstreamURL verifies DELETE /v0/management/ampcode/upstream-url clears the upstream URL.\nfunc TestDeleteAmpUpstreamURL(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/upstream-url\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestGetAmpUpstreamAPIKey verifies GET /v0/management/ampcode/upstream-api-key returns the API key.\nfunc TestGetAmpUpstreamAPIKey(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-api-key\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tkey := resp[\"upstream-api-key\"].(string)\n\tif key != \"test-api-key-12345\" {\n\t\tt.Errorf(\"expected key %q, got %q\", \"test-api-key-12345\", key)\n\t}\n}\n\n// TestPutAmpUpstreamAPIKey verifies PUT /v0/management/ampcode/upstream-api-key updates the API key.\nfunc TestPutAmpUpstreamAPIKey(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": \"new-secret-key\"}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-api-key\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\nfunc TestPutAmpUpstreamAPIKeys_PersistsAndReturns(t *testing.T) {\n\th, configPath := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\":[{\"upstream-api-key\":\"  u1  \",\"api-keys\":[\"  k1  \",\"\",\"k2\"]}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-api-keys\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d: %s\", http.StatusOK, w.Code, w.Body.String())\n\t}\n\n\t// Verify it was persisted to disk\n\tloaded, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config from disk: %v\", err)\n\t}\n\tif len(loaded.AmpCode.UpstreamAPIKeys) != 1 {\n\t\tt.Fatalf(\"expected 1 upstream-api-keys entry, got %d\", len(loaded.AmpCode.UpstreamAPIKeys))\n\t}\n\tentry := loaded.AmpCode.UpstreamAPIKeys[0]\n\tif entry.UpstreamAPIKey != \"u1\" {\n\t\tt.Fatalf(\"expected upstream-api-key u1, got %q\", entry.UpstreamAPIKey)\n\t}\n\tif len(entry.APIKeys) != 2 || entry.APIKeys[0] != \"k1\" || entry.APIKeys[1] != \"k2\" {\n\t\tt.Fatalf(\"expected api-keys [k1 k2], got %#v\", entry.APIKeys)\n\t}\n\n\t// Verify it is returned by GET /ampcode\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\tvar resp map[string]config.AmpCode\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"ampcode\"].UpstreamAPIKeys; len(got) != 1 || got[0].UpstreamAPIKey != \"u1\" {\n\t\tt.Fatalf(\"expected upstream-api-keys to be present after update, got %#v\", got)\n\t}\n}\n\nfunc TestDeleteAmpUpstreamAPIKeys_ClearsAll(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\t// Seed with one entry\n\tputBody := `{\"value\":[{\"upstream-api-key\":\"u1\",\"api-keys\":[\"k1\"]}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-api-keys\", bytes.NewBufferString(putBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d: %s\", http.StatusOK, w.Code, w.Body.String())\n\t}\n\n\tdeleteBody := `{\"value\":[]}`\n\treq = httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/upstream-api-keys\", bytes.NewBufferString(deleteBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-api-keys\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\tvar resp map[string][]config.AmpUpstreamAPIKeyEntry\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\tif resp[\"upstream-api-keys\"] != nil && len(resp[\"upstream-api-keys\"]) != 0 {\n\t\tt.Fatalf(\"expected cleared list, got %#v\", resp[\"upstream-api-keys\"])\n\t}\n}\n\n// TestDeleteAmpUpstreamAPIKey verifies DELETE /v0/management/ampcode/upstream-api-key clears the API key.\nfunc TestDeleteAmpUpstreamAPIKey(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/upstream-api-key\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestGetAmpRestrictManagementToLocalhost verifies GET returns the localhost restriction setting.\nfunc TestGetAmpRestrictManagementToLocalhost(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/restrict-management-to-localhost\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string]bool\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tif resp[\"restrict-management-to-localhost\"] != true {\n\t\tt.Error(\"expected restrict-management-to-localhost to be true\")\n\t}\n}\n\n// TestPutAmpRestrictManagementToLocalhost verifies PUT updates the localhost restriction setting.\nfunc TestPutAmpRestrictManagementToLocalhost(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": false}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/restrict-management-to-localhost\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestGetAmpModelMappings verifies GET /v0/management/ampcode/model-mappings returns all mappings.\nfunc TestGetAmpModelMappings(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tmappings := resp[\"model-mappings\"]\n\tif len(mappings) != 1 {\n\t\tt.Fatalf(\"expected 1 mapping, got %d\", len(mappings))\n\t}\n\tif mappings[0].From != \"gpt-4\" || mappings[0].To != \"gemini-pro\" {\n\t\tt.Errorf(\"unexpected mapping: %+v\", mappings[0])\n\t}\n}\n\n// TestPutAmpModelMappings verifies PUT /v0/management/ampcode/model-mappings replaces all mappings.\nfunc TestPutAmpModelMappings(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": [{\"from\": \"claude-3\", \"to\": \"gpt-4o\"}, {\"from\": \"gemini\", \"to\": \"claude\"}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d: %s\", http.StatusOK, w.Code, w.Body.String())\n\t}\n}\n\n// TestPatchAmpModelMappings verifies PATCH updates existing mappings and adds new ones.\nfunc TestPatchAmpModelMappings(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": [{\"from\": \"gpt-4\", \"to\": \"updated-model\"}, {\"from\": \"new-model\", \"to\": \"target\"}]}`\n\treq := httptest.NewRequest(http.MethodPatch, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d: %s\", http.StatusOK, w.Code, w.Body.String())\n\t}\n}\n\n// TestDeleteAmpModelMappings_Specific verifies DELETE removes specified mappings by \"from\" field.\nfunc TestDeleteAmpModelMappings_Specific(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": [\"gpt-4\"]}`\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestDeleteAmpModelMappings_All verifies DELETE with empty body removes all mappings.\nfunc TestDeleteAmpModelMappings_All(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/model-mappings\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestGetAmpForceModelMappings verifies GET returns the force-model-mappings setting.\nfunc TestGetAmpForceModelMappings(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/force-model-mappings\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string]bool\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal response: %v\", err)\n\t}\n\n\tif resp[\"force-model-mappings\"] != false {\n\t\tt.Error(\"expected force-model-mappings to be false\")\n\t}\n}\n\n// TestPutAmpForceModelMappings verifies PUT updates the force-model-mappings setting.\nfunc TestPutAmpForceModelMappings(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": true}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/force-model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestPutAmpModelMappings_VerifyState verifies PUT replaces mappings and state is persisted.\nfunc TestPutAmpModelMappings_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": [{\"from\": \"model-a\", \"to\": \"model-b\"}, {\"from\": \"model-c\", \"to\": \"model-d\"}, {\"from\": \"model-e\", \"to\": \"model-f\"}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT failed: status %d, body: %s\", w.Code, w.Body.String())\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tmappings := resp[\"model-mappings\"]\n\tif len(mappings) != 3 {\n\t\tt.Fatalf(\"expected 3 mappings, got %d\", len(mappings))\n\t}\n\n\texpected := map[string]string{\"model-a\": \"model-b\", \"model-c\": \"model-d\", \"model-e\": \"model-f\"}\n\tfor _, m := range mappings {\n\t\tif expected[m.From] != m.To {\n\t\t\tt.Errorf(\"mapping %q -> expected %q, got %q\", m.From, expected[m.From], m.To)\n\t\t}\n\t}\n}\n\n// TestPatchAmpModelMappings_VerifyState verifies PATCH merges mappings correctly.\nfunc TestPatchAmpModelMappings_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": [{\"from\": \"gpt-4\", \"to\": \"updated-target\"}, {\"from\": \"new-model\", \"to\": \"new-target\"}]}`\n\treq := httptest.NewRequest(http.MethodPatch, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tmappings := resp[\"model-mappings\"]\n\tif len(mappings) != 2 {\n\t\tt.Fatalf(\"expected 2 mappings (1 updated + 1 new), got %d\", len(mappings))\n\t}\n\n\tfound := make(map[string]string)\n\tfor _, m := range mappings {\n\t\tfound[m.From] = m.To\n\t}\n\n\tif found[\"gpt-4\"] != \"updated-target\" {\n\t\tt.Errorf(\"gpt-4 should map to updated-target, got %q\", found[\"gpt-4\"])\n\t}\n\tif found[\"new-model\"] != \"new-target\" {\n\t\tt.Errorf(\"new-model should map to new-target, got %q\", found[\"new-model\"])\n\t}\n}\n\n// TestDeleteAmpModelMappings_VerifyState verifies DELETE removes specific mappings and keeps others.\nfunc TestDeleteAmpModelMappings_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tputBody := `{\"value\": [{\"from\": \"a\", \"to\": \"1\"}, {\"from\": \"b\", \"to\": \"2\"}, {\"from\": \"c\", \"to\": \"3\"}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(putBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tdelBody := `{\"value\": [\"a\", \"c\"]}`\n\treq = httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(delBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"DELETE failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tmappings := resp[\"model-mappings\"]\n\tif len(mappings) != 1 {\n\t\tt.Fatalf(\"expected 1 mapping remaining, got %d\", len(mappings))\n\t}\n\tif mappings[0].From != \"b\" || mappings[0].To != \"2\" {\n\t\tt.Errorf(\"expected b->2, got %s->%s\", mappings[0].From, mappings[0].To)\n\t}\n}\n\n// TestDeleteAmpModelMappings_NonExistent verifies DELETE with non-existent mapping doesn't affect existing ones.\nfunc TestDeleteAmpModelMappings_NonExistent(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tdelBody := `{\"value\": [\"non-existent-model\"]}`\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(delBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif len(resp[\"model-mappings\"]) != 1 {\n\t\tt.Errorf(\"original mapping should remain, got %d mappings\", len(resp[\"model-mappings\"]))\n\t}\n}\n\n// TestPutAmpModelMappings_Empty verifies PUT with empty array clears all mappings.\nfunc TestPutAmpModelMappings_Empty(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": []}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif len(resp[\"model-mappings\"]) != 0 {\n\t\tt.Errorf(\"expected 0 mappings, got %d\", len(resp[\"model-mappings\"]))\n\t}\n}\n\n// TestPutAmpUpstreamURL_VerifyState verifies PUT updates upstream URL and persists state.\nfunc TestPutAmpUpstreamURL_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": \"https://new-api.example.com\"}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-url\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-url\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]string\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"upstream-url\"] != \"https://new-api.example.com\" {\n\t\tt.Errorf(\"expected %q, got %q\", \"https://new-api.example.com\", resp[\"upstream-url\"])\n\t}\n}\n\n// TestDeleteAmpUpstreamURL_VerifyState verifies DELETE clears upstream URL.\nfunc TestDeleteAmpUpstreamURL_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/upstream-url\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"DELETE failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-url\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]string\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"upstream-url\"] != \"\" {\n\t\tt.Errorf(\"expected empty string, got %q\", resp[\"upstream-url\"])\n\t}\n}\n\n// TestPutAmpUpstreamAPIKey_VerifyState verifies PUT updates API key and persists state.\nfunc TestPutAmpUpstreamAPIKey_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": \"new-secret-api-key-xyz\"}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/upstream-api-key\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-api-key\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]string\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"upstream-api-key\"] != \"new-secret-api-key-xyz\" {\n\t\tt.Errorf(\"expected %q, got %q\", \"new-secret-api-key-xyz\", resp[\"upstream-api-key\"])\n\t}\n}\n\n// TestDeleteAmpUpstreamAPIKey_VerifyState verifies DELETE clears API key.\nfunc TestDeleteAmpUpstreamAPIKey_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/upstream-api-key\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"DELETE failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/upstream-api-key\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]string\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"upstream-api-key\"] != \"\" {\n\t\tt.Errorf(\"expected empty string, got %q\", resp[\"upstream-api-key\"])\n\t}\n}\n\n// TestPutAmpRestrictManagementToLocalhost_VerifyState verifies PUT updates localhost restriction.\nfunc TestPutAmpRestrictManagementToLocalhost_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": false}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/restrict-management-to-localhost\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/restrict-management-to-localhost\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]bool\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"restrict-management-to-localhost\"] != false {\n\t\tt.Error(\"expected false after update\")\n\t}\n}\n\n// TestPutAmpForceModelMappings_VerifyState verifies PUT updates force-model-mappings setting.\nfunc TestPutAmpForceModelMappings_VerifyState(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{\"value\": true}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/force-model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT failed: status %d\", w.Code)\n\t}\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/force-model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string]bool\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif resp[\"force-model-mappings\"] != true {\n\t\tt.Error(\"expected true after update\")\n\t}\n}\n\n// TestPutBoolField_EmptyObject verifies PUT with empty object returns 400.\nfunc TestPutBoolField_EmptyObject(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tbody := `{}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/force-model-mappings\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"expected status %d for empty object, got %d\", http.StatusBadRequest, w.Code)\n\t}\n}\n\n// TestComplexMappingsWorkflow tests a full workflow: PUT, PATCH, DELETE, and GET.\nfunc TestComplexMappingsWorkflow(t *testing.T) {\n\th, _ := newAmpTestHandler(t)\n\tr := setupAmpRouter(h)\n\n\tputBody := `{\"value\": [{\"from\": \"m1\", \"to\": \"t1\"}, {\"from\": \"m2\", \"to\": \"t2\"}, {\"from\": \"m3\", \"to\": \"t3\"}, {\"from\": \"m4\", \"to\": \"t4\"}]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(putBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tpatchBody := `{\"value\": [{\"from\": \"m2\", \"to\": \"t2-updated\"}, {\"from\": \"m5\", \"to\": \"t5\"}]}`\n\treq = httptest.NewRequest(http.MethodPatch, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(patchBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tdelBody := `{\"value\": [\"m1\", \"m3\"]}`\n\treq = httptest.NewRequest(http.MethodDelete, \"/v0/management/ampcode/model-mappings\", bytes.NewBufferString(delBody))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\treq = httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw = httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tmappings := resp[\"model-mappings\"]\n\tif len(mappings) != 3 {\n\t\tt.Fatalf(\"expected 3 mappings (m2, m4, m5), got %d\", len(mappings))\n\t}\n\n\texpected := map[string]string{\"m2\": \"t2-updated\", \"m4\": \"t4\", \"m5\": \"t5\"}\n\tfound := make(map[string]string)\n\tfor _, m := range mappings {\n\t\tfound[m.From] = m.To\n\t}\n\n\tfor from, to := range expected {\n\t\tif found[from] != to {\n\t\t\tt.Errorf(\"mapping %s: expected %q, got %q\", from, to, found[from])\n\t\t}\n\t}\n}\n\n// TestNilHandlerGetAmpCode verifies handler works with empty config.\nfunc TestNilHandlerGetAmpCode(t *testing.T) {\n\tcfg := &config.Config{}\n\th := management.NewHandler(cfg, \"\", nil)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n}\n\n// TestEmptyConfigGetAmpModelMappings verifies GET returns empty array for fresh config.\nfunc TestEmptyConfigGetAmpModelMappings(t *testing.T) {\n\tcfg := &config.Config{}\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"port: 8080\\n\"), 0644); err != nil {\n\t\tt.Fatalf(\"failed to write config: %v\", err)\n\t}\n\n\th := management.NewHandler(cfg, configPath, nil)\n\tr := setupAmpRouter(h)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/v0/management/ampcode/model-mappings\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, w.Code)\n\t}\n\n\tvar resp map[string][]config.AmpModelMapping\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\tif len(resp[\"model-mappings\"]) != 0 {\n\t\tt.Errorf(\"expected 0 mappings, got %d\", len(resp[\"model-mappings\"]))\n\t}\n}\n"
  },
  {
    "path": "test/builtin_tools_translation_test.go",
    "content": "package test\n\nimport (\n\t\"testing\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator\"\n\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) {\n\tin := []byte(`{\n\t\t\"model\":\"gpt-5\",\n\t\t\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\n\t\t\"tools\":[{\"type\":\"web_search\",\"search_context_size\":\"high\"}],\n\t\t\"tool_choice\":{\"type\":\"web_search\"}\n\t}`)\n\n\tout := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatCodex, \"gpt-5\", in, false)\n\n\tif got := gjson.GetBytes(out, \"tools.#\").Int(); got != 1 {\n\t\tt.Fatalf(\"expected 1 tool, got %d: %s\", got, string(out))\n\t}\n\tif got := gjson.GetBytes(out, \"tools.0.type\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"expected tools[0].type=web_search, got %q: %s\", got, string(out))\n\t}\n\tif got := gjson.GetBytes(out, \"tools.0.search_context_size\").String(); got != \"high\" {\n\t\tt.Fatalf(\"expected tools[0].search_context_size=high, got %q: %s\", got, string(out))\n\t}\n\tif got := gjson.GetBytes(out, \"tool_choice.type\").String(); got != \"web_search\" {\n\t\tt.Fatalf(\"expected tool_choice.type=web_search, got %q: %s\", got, string(out))\n\t}\n}\n\nfunc TestOpenAIResponsesToOpenAI_IgnoresBuiltinTools(t *testing.T) {\n\tin := []byte(`{\n\t\t\"model\":\"gpt-5\",\n\t\t\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"hi\"}]}],\n\t\t\"tools\":[{\"type\":\"web_search\",\"search_context_size\":\"low\"}]\n\t}`)\n\n\tout := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, \"gpt-5\", in, false)\n\n\tif got := gjson.GetBytes(out, \"tools.#\").Int(); got != 0 {\n\t\tt.Fatalf(\"expected 0 tools (builtin tools not supported in Chat Completions), got %d: %s\", got, string(out))\n\t}\n}\n"
  },
  {
    "path": "test/thinking_conversion_test.go",
    "content": "package test\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/translator\"\n\n\t// Import provider packages to trigger init() registration of ProviderAppliers\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi\"\n\t_ \"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai\"\n\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/registry\"\n\t\"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking\"\n\tsdktranslator \"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator\"\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// thinkingTestCase represents a common test case structure for both suffix and body tests.\ntype thinkingTestCase struct {\n\tname            string\n\tfrom            string\n\tto              string\n\tmodel           string\n\tinputJSON       string\n\texpectField     string\n\texpectValue     string\n\texpectField2    string\n\texpectValue2    string\n\tincludeThoughts string\n\texpectErr       bool\n}\n\n// TestThinkingE2EMatrix_Suffix tests the thinking configuration transformation using model name suffix.\n// Data flow: Input JSON → TranslateRequest → ApplyThinking → Validate Output\n// No helper functions are used; all test data is inline.\nfunc TestThinkingE2EMatrix_Suffix(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\tuid := fmt.Sprintf(\"thinking-e2e-suffix-%d\", time.Now().UnixNano())\n\n\treg.RegisterClient(uid, \"test\", getTestModels())\n\tdefer reg.UnregisterClient(uid)\n\n\tcases := []thinkingTestCase{\n\t\t// level-model (Levels=minimal/low/medium/high, ZeroAllowed=false, DynamicAllowed=false)\n\n\t\t// Case 1: No suffix → injected default → medium\n\t\t{\n\t\t\tname:        \"1\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 2: Specified medium → medium\n\t\t{\n\t\t\tname:        \"2\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(medium)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(medium)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 3: Specified xhigh → out of range error\n\t\t{\n\t\t\tname:        \"3\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(xhigh)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(xhigh)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 4: Level none → clamped to minimal (ZeroAllowed=false)\n\t\t{\n\t\t\tname:        \"4\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(none)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 5: Level auto → DynamicAllowed=false → medium (mid-range)\n\t\t{\n\t\t\tname:        \"5\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(auto)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 6: No suffix from gemini → injected default → medium\n\t\t{\n\t\t\tname:        \"6\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 7: Budget 8192 → medium\n\t\t{\n\t\t\tname:        \"7\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 8: Budget 64000 → clamped to high\n\t\t{\n\t\t\tname:        \"8\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(64000)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(64000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 9: Budget 0 → clamped to minimal (ZeroAllowed=false)\n\t\t{\n\t\t\tname:        \"9\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(0)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 10: Budget -1 → auto → DynamicAllowed=false → medium (mid-range)\n\t\t{\n\t\t\tname:        \"10\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(-1)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 11: Claude source no suffix → passthrough (no thinking)\n\t\t{\n\t\t\tname:        \"11\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 12: Budget 8192 → medium\n\t\t{\n\t\t\tname:        \"12\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 13: Budget 64000 → clamped to high\n\t\t{\n\t\t\tname:        \"13\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(64000)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(64000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 14: Budget 0 → clamped to minimal (ZeroAllowed=false)\n\t\t{\n\t\t\tname:        \"14\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 15: Budget -1 → auto → DynamicAllowed=false → medium (mid-range)\n\t\t{\n\t\t\tname:        \"15\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// level-subset-model (Levels=low/high, ZeroAllowed=false, DynamicAllowed=false)\n\n\t\t// Case 16: Budget 8192 → medium → rounded down to low\n\t\t{\n\t\t\tname:        \"16\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-subset-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"level-subset-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 17: Budget 1 → minimal → clamped to low (min supported)\n\t\t{\n\t\t\tname:            \"17\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"level-subset-model(1)\",\n\t\t\tinputJSON:       `{\"model\":\"level-subset-model(1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true)\n\n\t\t// Case 18: No suffix → passthrough\n\t\t{\n\t\t\tname:        \"18\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 19: Effort medium → 8192\n\t\t{\n\t\t\tname:            \"19\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(medium)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(medium)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 20: Effort xhigh → clamped to 20000 (max)\n\t\t{\n\t\t\tname:            \"20\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(xhigh)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(xhigh)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 21: Effort none → clamped to 128 (min) → includeThoughts=false\n\t\t{\n\t\t\tname:            \"21\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(none)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"128\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 22: Effort auto → DynamicAllowed=true → -1\n\t\t{\n\t\t\tname:            \"22\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(auto)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 23: Claude source no suffix → passthrough\n\t\t{\n\t\t\tname:        \"23\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 24: Budget 8192 → 8192\n\t\t{\n\t\t\tname:            \"24\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 25: Budget 64000 → clamped to 20000 (max)\n\t\t{\n\t\t\tname:            \"25\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 26: Budget 0 → clamped to 128 (min) → includeThoughts=false\n\t\t{\n\t\t\tname:            \"26\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(0)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"128\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 27: Budget -1 → DynamicAllowed=true → -1\n\t\t{\n\t\t\tname:            \"27\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(-1)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// gemini-mixed-model (Min=128, Max=32768, Levels=low/high, ZeroAllowed=false, DynamicAllowed=true)\n\n\t\t// Case 28: OpenAI source no suffix → passthrough\n\t\t{\n\t\t\tname:        \"28\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-mixed-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 29: Effort high → low/high supported → high\n\t\t{\n\t\t\tname:            \"29\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(high)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(high)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 30: Effort xhigh → clamped to high\n\t\t{\n\t\t\tname:            \"30\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(xhigh)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(xhigh)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 31: Effort none → clamped to low (min supported) → includeThoughts=false\n\t\t{\n\t\t\tname:            \"31\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(none)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 32: Effort auto → DynamicAllowed=true → -1 (budget)\n\t\t{\n\t\t\tname:            \"32\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(auto)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 33: Claude source no suffix → passthrough\n\t\t{\n\t\t\tname:        \"33\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-mixed-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 34: Budget 8192 → 8192 (keep budget)\n\t\t{\n\t\t\tname:            \"34\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 35: Budget 64000 → clamped to 32768 (max)\n\t\t{\n\t\t\tname:            \"35\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(64000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"32768\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 36: Budget 0 → minimal → clamped to low (min level) → includeThoughts=false\n\t\t{\n\t\t\tname:            \"36\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(0)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 37: Budget -1 → DynamicAllowed=true → -1 (budget)\n\t\t{\n\t\t\tname:            \"37\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model(-1)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// claude-budget-model (Min=1024, Max=128000, ZeroAllowed=true, DynamicAllowed=false)\n\n\t\t// Case 38: OpenAI source no suffix → passthrough\n\t\t{\n\t\t\tname:        \"38\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 39: Effort medium → 8192\n\t\t{\n\t\t\tname:        \"39\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(medium)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(medium)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 40: Effort xhigh → clamped to 32768 (matrix value)\n\t\t{\n\t\t\tname:        \"40\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(xhigh)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(xhigh)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"32768\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 41: Effort none → ZeroAllowed=true → disabled\n\t\t{\n\t\t\tname:        \"41\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(none)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.type\",\n\t\t\texpectValue: \"disabled\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 42: Effort auto → DynamicAllowed=false → 64512 (mid-range)\n\t\t{\n\t\t\tname:        \"42\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(auto)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"64512\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 43: Gemini source no suffix → passthrough\n\t\t{\n\t\t\tname:        \"43\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 44: Budget 8192 → 8192\n\t\t{\n\t\t\tname:        \"44\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 45: Budget 200000 → clamped to 128000 (max)\n\t\t{\n\t\t\tname:        \"45\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(200000)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(200000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"128000\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 46: Budget 0 → ZeroAllowed=true → disabled\n\t\t{\n\t\t\tname:        \"46\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(0)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"thinking.type\",\n\t\t\texpectValue: \"disabled\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 47: Budget -1 → auto → DynamicAllowed=false → 64512 (mid-range)\n\t\t{\n\t\t\tname:        \"47\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(-1)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"64512\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// antigravity-budget-model (Min=128, Max=20000, ZeroAllowed=true, DynamicAllowed=true)\n\n\t\t// Case 48: Gemini to Antigravity no suffix → passthrough\n\t\t{\n\t\t\tname:        \"48\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"antigravity-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 49: Effort medium → 8192\n\t\t{\n\t\t\tname:            \"49\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(medium)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(medium)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 50: Effort xhigh → clamped to 20000 (max)\n\t\t{\n\t\t\tname:            \"50\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(xhigh)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(xhigh)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 51: Effort none → ZeroAllowed=true → 0 → includeThoughts=false\n\t\t{\n\t\t\tname:            \"51\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(none)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(none)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"0\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 52: Effort auto → DynamicAllowed=true → -1\n\t\t{\n\t\t\tname:            \"52\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(auto)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(auto)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 53: Claude to Antigravity no suffix → passthrough\n\t\t{\n\t\t\tname:        \"53\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"antigravity-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 54: Budget 8192 → 8192\n\t\t{\n\t\t\tname:            \"54\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 55: Budget 64000 → clamped to 20000 (max)\n\t\t{\n\t\t\tname:            \"55\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(64000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 56: Budget 0 → ZeroAllowed=true → 0 → includeThoughts=false\n\t\t{\n\t\t\tname:            \"56\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(0)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"0\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 57: Budget -1 → DynamicAllowed=true → -1\n\t\t{\n\t\t\tname:            \"57\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(-1)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// no-thinking-model (Thinking=nil)\n\n\t\t// Case 58: No thinking support → no configuration\n\t\t{\n\t\t\tname:        \"58\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 59: Budget 8192 → no thinking support → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"59\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 60: Budget 0 → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"60\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(0)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 61: Budget -1 → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"61\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(-1)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 62: Claude source no suffix → no configuration\n\t\t{\n\t\t\tname:        \"62\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 63: Budget 8192 → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"63\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 64: Budget 0 → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"64\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 65: Budget -1 → suffix stripped → no configuration\n\t\t{\n\t\t\tname:        \"65\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// user-defined-model (UserDefined=true, Thinking=nil)\n\n\t\t// Case 66: User defined model no suffix → passthrough\n\t\t{\n\t\t\tname:        \"66\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 67: Budget 8192 → passthrough logic → medium\n\t\t{\n\t\t\tname:        \"67\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 68: Budget 64000 → passthrough logic → xhigh\n\t\t{\n\t\t\tname:        \"68\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model(64000)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(64000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"xhigh\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 69: Budget 0 → passthrough logic → none\n\t\t{\n\t\t\tname:        \"69\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(0)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"none\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 70: Budget -1 → passthrough logic → auto\n\t\t{\n\t\t\tname:        \"70\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(-1)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"auto\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 71: Claude to Codex no suffix → injected default → medium\n\t\t{\n\t\t\tname:        \"71\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 72: Budget 8192 → passthrough logic → medium\n\t\t{\n\t\t\tname:        \"72\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 73: Budget 64000 → passthrough logic → xhigh\n\t\t{\n\t\t\tname:        \"73\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model(64000)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(64000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"xhigh\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 74: Budget 0 → passthrough logic → none\n\t\t{\n\t\t\tname:        \"74\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model(0)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"none\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 75: Budget -1 → passthrough logic → auto\n\t\t{\n\t\t\tname:        \"75\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"auto\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 76: OpenAI to Gemini budget 8192 → passthrough → 8192\n\t\t{\n\t\t\tname:            \"76\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"user-defined-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"user-defined-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 77: OpenAI to Claude budget 8192 → passthrough → 8192\n\t\t{\n\t\t\tname:        \"77\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"user-defined-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 78: OpenAI-Response to Gemini budget 8192 → passthrough → 8192\n\t\t{\n\t\t\tname:            \"78\",\n\t\t\tfrom:            \"openai-response\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"user-defined-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"user-defined-model(8192)\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 79: OpenAI-Response to Claude budget 8192 → passthrough → 8192\n\t\t{\n\t\t\tname:        \"79\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"user-defined-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model(8192)\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// Same-protocol passthrough tests (80-89)\n\n\t\t// Case 80: OpenAI to OpenAI, level high → passthrough reasoning_effort\n\t\t{\n\t\t\tname:        \"80\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(high)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(high)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 81: OpenAI to OpenAI, level xhigh → out of range error\n\t\t{\n\t\t\tname:        \"81\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model(xhigh)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(xhigh)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 82: OpenAI-Response to Codex, level high → passthrough reasoning.effort\n\t\t{\n\t\t\tname:        \"82\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(high)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(high)\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 83: OpenAI-Response to Codex, level xhigh → out of range error\n\t\t{\n\t\t\tname:        \"83\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model(xhigh)\",\n\t\t\tinputJSON:   `{\"model\":\"level-model(xhigh)\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 84: Gemini to Gemini, budget 8192 → passthrough thinkingBudget\n\t\t{\n\t\t\tname:            \"84\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 85: Gemini to Gemini, budget 64000 → clamped to Max\n\t\t{\n\t\t\tname:            \"85\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 86: Claude to Claude, budget 8192 → passthrough thinking.budget_tokens\n\t\t{\n\t\t\tname:        \"86\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 87: Claude to Claude, budget 200000 → clamped to Max\n\t\t{\n\t\t\tname:        \"87\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model(200000)\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model(200000)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"128000\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 88: Gemini-CLI to Antigravity, budget 8192 → passthrough thinkingBudget\n\t\t{\n\t\t\tname:            \"88\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(8192)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 89: Gemini-CLI to Antigravity, budget 64000 → clamped to Max\n\t\t{\n\t\t\tname:            \"89\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model(64000)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// iflow tests: glm-test and minimax-test (Cases 90-105)\n\n\t\t// glm-test (from: openai, claude)\n\t\t// Case 90: OpenAI to iflow, no suffix → passthrough\n\t\t{\n\t\t\tname:        \"90\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 91: OpenAI to iflow, (medium) → enable_thinking=true\n\t\t{\n\t\t\tname:        \"91\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(medium)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(medium)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 92: OpenAI to iflow, (auto) → enable_thinking=true\n\t\t{\n\t\t\tname:        \"92\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(auto)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 93: OpenAI to iflow, (none) → enable_thinking=false\n\t\t{\n\t\t\tname:        \"93\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(none)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 94: Claude to iflow, no suffix → passthrough\n\t\t{\n\t\t\tname:        \"94\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 95: Claude to iflow, (8192) → enable_thinking=true\n\t\t{\n\t\t\tname:        \"95\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(8192)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 96: Claude to iflow, (-1) → enable_thinking=true\n\t\t{\n\t\t\tname:        \"96\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(-1)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 97: Claude to iflow, (0) → enable_thinking=false\n\t\t{\n\t\t\tname:        \"97\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test(0)\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test(0)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// minimax-test (from: openai, gemini)\n\t\t// Case 98: OpenAI to iflow, no suffix → passthrough\n\t\t{\n\t\t\tname:        \"98\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 99: OpenAI to iflow, (medium) → reasoning_split=true\n\t\t{\n\t\t\tname:        \"99\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(medium)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(medium)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 100: OpenAI to iflow, (auto) → reasoning_split=true\n\t\t{\n\t\t\tname:        \"100\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(auto)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(auto)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 101: OpenAI to iflow, (none) → reasoning_split=false\n\t\t{\n\t\t\tname:        \"101\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(none)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(none)\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 102: Gemini to iflow, no suffix → passthrough\n\t\t{\n\t\t\tname:        \"102\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 103: Gemini to iflow, (8192) → reasoning_split=true\n\t\t{\n\t\t\tname:        \"103\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(8192)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 104: Gemini to iflow, (-1) → reasoning_split=true\n\t\t{\n\t\t\tname:        \"104\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(-1)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(-1)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 105: Gemini to iflow, (0) → reasoning_split=false\n\t\t{\n\t\t\tname:        \"105\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test(0)\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test(0)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// Gemini Family Cross-Channel Consistency (Cases 106-114)\n\t\t// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior\n\n\t\t// Case 106: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max\n\t\t{\n\t\t\tname:            \"106\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 107: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max\n\t\t{\n\t\t\tname:            \"107\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"gemini-cli\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 108: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max\n\t\t{\n\t\t\tname:            \"108\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 109: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max\n\t\t{\n\t\t\tname:            \"109\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model(64000)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(64000)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 110: Gemini to Antigravity, budget 8192 → passthrough (normal value)\n\t\t{\n\t\t\tname:            \"110\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(8192)\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 111: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value)\n\t\t{\n\t\t\tname:            \"111\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model(8192)\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model(8192)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t}\n\n\trunThinkingTests(t, cases)\n}\n\n// TestThinkingE2EMatrix_Body tests the thinking configuration transformation using request body parameters.\n// Data flow: Input JSON with thinking params → TranslateRequest → ApplyThinking → Validate Output\nfunc TestThinkingE2EMatrix_Body(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\tuid := fmt.Sprintf(\"thinking-e2e-body-%d\", time.Now().UnixNano())\n\n\treg.RegisterClient(uid, \"test\", getTestModels())\n\tdefer reg.UnregisterClient(uid)\n\n\tcases := []thinkingTestCase{\n\t\t// level-model (Levels=minimal/low/medium/high, ZeroAllowed=false, DynamicAllowed=false)\n\n\t\t// Case 1: No param → injected default → medium\n\t\t{\n\t\t\tname:        \"1\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 2: reasoning_effort=medium → medium\n\t\t{\n\t\t\tname:        \"2\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 3: reasoning_effort=xhigh → out of range error\n\t\t{\n\t\t\tname:        \"3\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 4: reasoning_effort=none → clamped to minimal\n\t\t{\n\t\t\tname:        \"4\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 5: reasoning_effort=auto → medium (DynamicAllowed=false)\n\t\t{\n\t\t\tname:        \"5\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 6: No param from gemini → injected default → medium\n\t\t{\n\t\t\tname:        \"6\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 7: thinkingBudget=8192 → medium\n\t\t{\n\t\t\tname:        \"7\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 8: thinkingBudget=64000 → clamped to high\n\t\t{\n\t\t\tname:        \"8\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 9: thinkingBudget=0 → clamped to minimal\n\t\t{\n\t\t\tname:        \"9\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 10: thinkingBudget=-1 → medium (DynamicAllowed=false)\n\t\t{\n\t\t\tname:        \"10\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 11: Claude no param → passthrough (no thinking)\n\t\t{\n\t\t\tname:        \"11\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 12: thinking.budget_tokens=8192 → medium\n\t\t{\n\t\t\tname:        \"12\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 13: thinking.budget_tokens=64000 → clamped to high\n\t\t{\n\t\t\tname:        \"13\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64000}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 14: thinking.budget_tokens=0 → clamped to minimal\n\t\t{\n\t\t\tname:        \"14\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 15: thinking.budget_tokens=-1 → medium (DynamicAllowed=false)\n\t\t{\n\t\t\tname:        \"15\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// level-subset-model (Levels=low/high, ZeroAllowed=false, DynamicAllowed=false)\n\n\t\t// Case 16: thinkingBudget=8192 → medium → rounded down to low\n\t\t{\n\t\t\tname:        \"16\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-subset-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-subset-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 17: thinking.budget_tokens=1 → minimal → clamped to low\n\t\t{\n\t\t\tname:            \"17\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"level-subset-model\",\n\t\t\tinputJSON:       `{\"model\":\"level-subset-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true)\n\n\t\t// Case 18: No param → passthrough\n\t\t{\n\t\t\tname:        \"18\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 19: reasoning_effort=medium → 8192\n\t\t{\n\t\t\tname:            \"19\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 20: reasoning_effort=xhigh → clamped to 20000\n\t\t{\n\t\t\tname:            \"20\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 21: reasoning_effort=none → clamped to 128 → includeThoughts=false\n\t\t{\n\t\t\tname:            \"21\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"128\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 22: reasoning_effort=auto → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"22\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 23: Claude no param → passthrough\n\t\t{\n\t\t\tname:        \"23\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 24: thinking.budget_tokens=8192 → 8192\n\t\t{\n\t\t\tname:            \"24\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 25: thinking.budget_tokens=64000 → clamped to 20000\n\t\t{\n\t\t\tname:            \"25\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64000}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 26: thinking.budget_tokens=0 → clamped to 128 → includeThoughts=false\n\t\t{\n\t\t\tname:            \"26\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"128\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 27: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"27\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// gemini-mixed-model (Min=128, Max=32768, Levels=low/high, ZeroAllowed=false, DynamicAllowed=true)\n\n\t\t// Case 28: No param → passthrough\n\t\t{\n\t\t\tname:        \"28\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-mixed-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 29: reasoning_effort=high → high\n\t\t{\n\t\t\tname:            \"29\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"high\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 30: reasoning_effort=xhigh → clamped to high\n\t\t{\n\t\t\tname:            \"30\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 31: reasoning_effort=none → clamped to low → includeThoughts=false\n\t\t{\n\t\t\tname:            \"31\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 32: reasoning_effort=auto → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"32\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 33: Claude no param → passthrough\n\t\t{\n\t\t\tname:        \"33\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-mixed-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 34: thinking.budget_tokens=8192 → 8192 (keeps budget)\n\t\t{\n\t\t\tname:            \"34\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 35: thinking.budget_tokens=64000 → clamped to 32768 (keeps budget)\n\t\t{\n\t\t\tname:            \"35\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64000}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"32768\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 36: thinking.budget_tokens=0 → clamped to low → includeThoughts=false\n\t\t{\n\t\t\tname:            \"36\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"low\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 37: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"37\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// claude-budget-model (Min=1024, Max=128000, ZeroAllowed=true, DynamicAllowed=false)\n\n\t\t// Case 38: No param → passthrough\n\t\t{\n\t\t\tname:        \"38\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 39: reasoning_effort=medium → 8192\n\t\t{\n\t\t\tname:        \"39\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 40: reasoning_effort=xhigh → clamped to 32768\n\t\t{\n\t\t\tname:        \"40\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"32768\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 41: reasoning_effort=none → disabled\n\t\t{\n\t\t\tname:        \"41\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField: \"thinking.type\",\n\t\t\texpectValue: \"disabled\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 42: reasoning_effort=auto → 64512 (mid-range)\n\t\t{\n\t\t\tname:        \"42\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"64512\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 43: Gemini no param → passthrough\n\t\t{\n\t\t\tname:        \"43\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 44: thinkingBudget=8192 → 8192\n\t\t{\n\t\t\tname:        \"44\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 45: thinkingBudget=200000 → clamped to 128000\n\t\t{\n\t\t\tname:        \"45\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":200000}}}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"128000\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 46: thinkingBudget=0 → disabled\n\t\t{\n\t\t\tname:        \"46\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"thinking.type\",\n\t\t\texpectValue: \"disabled\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 47: thinkingBudget=-1 → 64512 (mid-range)\n\t\t{\n\t\t\tname:        \"47\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"64512\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// antigravity-budget-model (Min=128, Max=20000, ZeroAllowed=true, DynamicAllowed=true)\n\n\t\t// Case 48: Gemini no param → passthrough\n\t\t{\n\t\t\tname:        \"48\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"antigravity-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 49: thinkingLevel=medium → 8192\n\t\t{\n\t\t\tname:            \"49\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"medium\"}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 50: thinkingLevel=xhigh → clamped to 20000\n\t\t{\n\t\t\tname:            \"50\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"xhigh\"}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 51: thinkingLevel=none → 0 (ZeroAllowed=true)\n\t\t{\n\t\t\tname:            \"51\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"none\"}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"0\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 52: thinkingBudget=-1 → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"52\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 53: Claude no param → passthrough\n\t\t{\n\t\t\tname:        \"53\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"antigravity-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 54: thinking.budget_tokens=8192 → 8192\n\t\t{\n\t\t\tname:            \"54\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 55: thinking.budget_tokens=64000 → clamped to 20000\n\t\t{\n\t\t\tname:            \"55\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64000}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 56: thinking.budget_tokens=0 → 0 (ZeroAllowed=true)\n\t\t{\n\t\t\tname:            \"56\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"0\",\n\t\t\tincludeThoughts: \"false\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 57: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true)\n\t\t{\n\t\t\tname:            \"57\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"-1\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t// no-thinking-model (Thinking=nil)\n\n\t\t// Case 58: Gemini no param → passthrough\n\t\t{\n\t\t\tname:        \"58\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 59: thinkingBudget=8192 → stripped\n\t\t{\n\t\t\tname:        \"59\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 60: thinkingBudget=0 → stripped\n\t\t{\n\t\t\tname:        \"60\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 61: thinkingBudget=-1 → stripped\n\t\t{\n\t\t\tname:        \"61\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 62: Claude no param → passthrough\n\t\t{\n\t\t\tname:        \"62\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 63: thinking.budget_tokens=8192 → stripped\n\t\t{\n\t\t\tname:        \"63\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 64: thinking.budget_tokens=0 → stripped\n\t\t{\n\t\t\tname:        \"64\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 65: thinking.budget_tokens=-1 → stripped\n\t\t{\n\t\t\tname:        \"65\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// user-defined-model (UserDefined=true, Thinking=nil)\n\n\t\t// Case 66: Gemini no param → passthrough\n\t\t{\n\t\t\tname:        \"66\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 67: thinkingBudget=8192 → medium\n\t\t{\n\t\t\tname:        \"67\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 68: thinkingBudget=64000 → xhigh (passthrough)\n\t\t{\n\t\t\tname:        \"68\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"xhigh\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 69: thinkingBudget=0 → none\n\t\t{\n\t\t\tname:        \"69\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"none\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 70: thinkingBudget=-1 → auto\n\t\t{\n\t\t\tname:        \"70\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"auto\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 71: Claude no param → injected default → medium\n\t\t{\n\t\t\tname:        \"71\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 72: thinking.budget_tokens=8192 → medium\n\t\t{\n\t\t\tname:        \"72\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 73: thinking.budget_tokens=64000 → xhigh (passthrough)\n\t\t{\n\t\t\tname:        \"73\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64000}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"xhigh\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 74: thinking.budget_tokens=0 → none\n\t\t{\n\t\t\tname:        \"74\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"none\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 75: thinking.budget_tokens=-1 → auto\n\t\t{\n\t\t\tname:        \"75\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"auto\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 76: OpenAI reasoning_effort=medium to Gemini → 8192\n\t\t{\n\t\t\tname:            \"76\",\n\t\t\tfrom:            \"openai\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"user-defined-model\",\n\t\t\tinputJSON:       `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 77: OpenAI reasoning_effort=medium to Claude → 8192\n\t\t{\n\t\t\tname:        \"77\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 78: OpenAI-Response reasoning.effort=medium to Gemini → 8192\n\t\t{\n\t\t\tname:            \"78\",\n\t\t\tfrom:            \"openai-response\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"user-defined-model\",\n\t\t\tinputJSON:       `{\"model\":\"user-defined-model\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning\":{\"effort\":\"medium\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 79: OpenAI-Response reasoning.effort=medium to Claude → 8192\n\t\t{\n\t\t\tname:        \"79\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"user-defined-model\",\n\t\t\tinputJSON:   `{\"model\":\"user-defined-model\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning\":{\"effort\":\"medium\"}}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// Same-protocol passthrough tests (80-89)\n\n\t\t// Case 80: OpenAI to OpenAI, reasoning_effort=high → passthrough\n\t\t{\n\t\t\tname:        \"80\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"high\"}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → out of range error\n\t\t{\n\t\t\tname:        \"81\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 82: OpenAI-Response to Codex, reasoning.effort=high → passthrough\n\t\t{\n\t\t\tname:        \"82\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning\":{\"effort\":\"high\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → out of range error\n\t\t{\n\t\t\tname:        \"83\",\n\t\t\tfrom:        \"openai-response\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"input\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning\":{\"effort\":\"xhigh\"}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 84: Gemini to Gemini, thinkingBudget=8192 → passthrough\n\t\t{\n\t\t\tname:            \"84\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 85: Gemini to Gemini, thinkingBudget=64000 → exceeds Max error\n\t\t{\n\t\t\tname:        \"85\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 86: Claude to Claude, thinking.budget_tokens=8192 → passthrough\n\t\t{\n\t\t\tname:        \"86\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField: \"thinking.budget_tokens\",\n\t\t\texpectValue: \"8192\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 87: Claude to Claude, thinking.budget_tokens=200000 → exceeds Max error\n\t\t{\n\t\t\tname:        \"87\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":200000}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 88: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough\n\t\t{\n\t\t\tname:            \"88\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 89: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error\n\t\t{\n\t\t\tname:        \"89\",\n\t\t\tfrom:        \"gemini-cli\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"antigravity-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"antigravity-budget-model\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\n\t\t// iflow tests: glm-test and minimax-test (Cases 90-105)\n\n\t\t// glm-test (from: openai, claude)\n\t\t// Case 90: OpenAI to iflow, no param → passthrough\n\t\t{\n\t\t\tname:        \"90\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 91: OpenAI to iflow, reasoning_effort=medium → enable_thinking=true\n\t\t{\n\t\t\tname:        \"91\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 92: OpenAI to iflow, reasoning_effort=auto → enable_thinking=true\n\t\t{\n\t\t\tname:        \"92\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 93: OpenAI to iflow, reasoning_effort=none → enable_thinking=false\n\t\t{\n\t\t\tname:        \"93\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 94: Claude to iflow, no param → passthrough\n\t\t{\n\t\t\tname:        \"94\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 95: Claude to iflow, thinking.budget_tokens=8192 → enable_thinking=true\n\t\t{\n\t\t\tname:        \"95\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":8192}}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 96: Claude to iflow, thinking.budget_tokens=-1 → enable_thinking=true\n\t\t{\n\t\t\tname:        \"96\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":-1}}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 97: Claude to iflow, thinking.budget_tokens=0 → enable_thinking=false\n\t\t{\n\t\t\tname:        \"97\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":0}}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// minimax-test (from: openai, gemini)\n\t\t// Case 98: OpenAI to iflow, no param → passthrough\n\t\t{\n\t\t\tname:        \"98\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 99: OpenAI to iflow, reasoning_effort=medium → reasoning_split=true\n\t\t{\n\t\t\tname:        \"99\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 100: OpenAI to iflow, reasoning_effort=auto → reasoning_split=true\n\t\t{\n\t\t\tname:        \"100\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"auto\"}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 101: OpenAI to iflow, reasoning_effort=none → reasoning_split=false\n\t\t{\n\t\t\tname:        \"101\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 102: Gemini to iflow, no param → passthrough\n\t\t{\n\t\t\tname:        \"102\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}]}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 103: Gemini to iflow, thinkingBudget=8192 → reasoning_split=true\n\t\t{\n\t\t\tname:        \"103\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 104: Gemini to iflow, thinkingBudget=-1 → reasoning_split=true\n\t\t{\n\t\t\tname:        \"104\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t// Case 105: Gemini to iflow, thinkingBudget=0 → reasoning_split=false\n\t\t{\n\t\t\tname:        \"105\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"false\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// Gemini Family Cross-Channel Consistency (Cases 106-114)\n\t\t// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior\n\n\t\t// Case 106: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)\n\t\t{\n\t\t\tname:        \"106\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 107: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation)\n\t\t{\n\t\t\tname:        \"107\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"gemini-cli\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 108: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)\n\t\t{\n\t\t\tname:        \"108\",\n\t\t\tfrom:        \"gemini-cli\",\n\t\t\tto:          \"antigravity\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 109: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation)\n\t\t{\n\t\t\tname:        \"109\",\n\t\t\tfrom:        \"gemini-cli\",\n\t\t\tto:          \"gemini\",\n\t\t\tmodel:       \"gemini-budget-model\",\n\t\t\tinputJSON:   `{\"model\":\"gemini-budget-model\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":64000}}}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   true,\n\t\t},\n\t\t// Case 110: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value)\n\t\t{\n\t\t\tname:            \"110\",\n\t\t\tfrom:            \"gemini\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t// Case 111: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value)\n\t\t{\n\t\t\tname:            \"111\",\n\t\t\tfrom:            \"gemini-cli\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t}\n\n\trunThinkingTests(t, cases)\n}\n\n// TestThinkingE2EClaudeAdaptive_Body covers Group 3 cases in docs/thinking-e2e-test-cases.md.\n// It focuses on Claude 4.6 adaptive thinking and effort/level cross-protocol semantics (body-only).\nfunc TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {\n\treg := registry.GetGlobalRegistry()\n\tuid := fmt.Sprintf(\"thinking-e2e-claude-adaptive-%d\", time.Now().UnixNano())\n\n\treg.RegisterClient(uid, \"test\", getTestModels())\n\tdefer reg.UnregisterClient(uid)\n\n\tcases := []thinkingTestCase{\n\t\t// A subgroup: OpenAI -> Claude (reasoning_effort -> output_config.effort)\n\t\t{\n\t\t\tname:        \"A1\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"minimal\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A2\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"low\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A3\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"medium\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A4\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"high\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A5\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-opus-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-opus-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"max\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A6\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"xhigh\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A7\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-opus-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-opus-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"max\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"max\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"A8\",\n\t\t\tfrom:        \"openai\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"max\"}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// B subgroup: Gemini -> Claude (thinkingLevel/thinkingBudget -> output_config.effort)\n\t\t{\n\t\t\tname:        \"B1\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"minimal\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B2\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"low\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B3\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"medium\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B4\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"high\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B5\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-opus-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-opus-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"xhigh\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"max\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B6\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"xhigh\"}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B7\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":512}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B8\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":1024}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B9\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B10\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":24576}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B11\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-opus-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-opus-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":32768}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"max\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B12\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":32768}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B13\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}`,\n\t\t\texpectField: \"thinking.type\",\n\t\t\texpectValue: \"disabled\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"B14\",\n\t\t\tfrom:        \"gemini\",\n\t\t\tto:          \"claude\",\n\t\t\tmodel:       \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:   `{\"model\":\"claude-sonnet-4-6-model\",\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}],\"generationConfig\":{\"thinkingConfig\":{\"thinkingBudget\":-1}}}`,\n\t\t\texpectField: \"output_config.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t// C subgroup: Claude adaptive + effort cross-protocol conversion\n\t\t{\n\t\t\tname:        \"C1\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"minimal\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C2\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"low\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C3\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"medium\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"medium\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C4\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C5\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"xhigh\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C6\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"max\"}}`,\n\t\t\texpectField: \"reasoning_effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C7\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"openai\",\n\t\t\tmodel:       \"no-thinking-model\",\n\t\t\tinputJSON:   `{\"model\":\"no-thinking-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField: \"\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t{\n\t\t\tname:            \"C8\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"level-subset-model\",\n\t\t\tinputJSON:       `{\"model\":\"level-subset-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C9\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"low\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"1024\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C10\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"medium\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"8192\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C11\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C12\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C13\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"gemini\",\n\t\t\tmodel:           \"gemini-mixed-model\",\n\t\t\tinputJSON:       `{\"model\":\"gemini-mixed-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField:     \"generationConfig.thinkingConfig.thinkingLevel\",\n\t\t\texpectValue:     \"high\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t{\n\t\t\tname:        \"C14\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"minimal\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"minimal\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C15\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"low\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"low\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C16\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C17\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"xhigh\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C18\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"codex\",\n\t\t\tmodel:       \"level-model\",\n\t\t\tinputJSON:   `{\"model\":\"level-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"max\"}}`,\n\t\t\texpectField: \"reasoning.effort\",\n\t\t\texpectValue: \"high\",\n\t\t\texpectErr:   false,\n\t\t},\n\n\t\t{\n\t\t\tname:        \"C19\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"glm-test\",\n\t\t\tinputJSON:   `{\"model\":\"glm-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"minimal\"}}`,\n\t\t\texpectField: \"chat_template_kwargs.enable_thinking\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:        \"C20\",\n\t\t\tfrom:        \"claude\",\n\t\t\tto:          \"iflow\",\n\t\t\tmodel:       \"minimax-test\",\n\t\t\tinputJSON:   `{\"model\":\"minimax-test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField: \"reasoning_split\",\n\t\t\texpectValue: \"true\",\n\t\t\texpectErr:   false,\n\t\t},\n\t\t{\n\t\t\tname:            \"C21\",\n\t\t\tfrom:            \"claude\",\n\t\t\tto:              \"antigravity\",\n\t\t\tmodel:           \"antigravity-budget-model\",\n\t\t\tinputJSON:       `{\"model\":\"antigravity-budget-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"}}`,\n\t\t\texpectField:     \"request.generationConfig.thinkingConfig.thinkingBudget\",\n\t\t\texpectValue:     \"20000\",\n\t\t\tincludeThoughts: \"true\",\n\t\t\texpectErr:       false,\n\t\t},\n\n\t\t{\n\t\t\tname:         \"C22\",\n\t\t\tfrom:         \"claude\",\n\t\t\tto:           \"claude\",\n\t\t\tmodel:        \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:    `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"medium\"}}`,\n\t\t\texpectField:  \"thinking.type\",\n\t\t\texpectValue:  \"adaptive\",\n\t\t\texpectField2: \"output_config.effort\",\n\t\t\texpectValue2: \"medium\",\n\t\t\texpectErr:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"C23\",\n\t\t\tfrom:         \"claude\",\n\t\t\tto:           \"claude\",\n\t\t\tmodel:        \"claude-opus-4-6-model\",\n\t\t\tinputJSON:    `{\"model\":\"claude-opus-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"max\"}}`,\n\t\t\texpectField:  \"thinking.type\",\n\t\t\texpectValue:  \"adaptive\",\n\t\t\texpectField2: \"output_config.effort\",\n\t\t\texpectValue2: \"max\",\n\t\t\texpectErr:    false,\n\t\t},\n\t\t{\n\t\t\tname:      \"C24\",\n\t\t\tfrom:      \"claude\",\n\t\t\tto:        \"claude\",\n\t\t\tmodel:     \"claude-opus-4-6-model\",\n\t\t\tinputJSON: `{\"model\":\"claude-opus-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"xhigh\"}}`,\n\t\t\texpectErr: true,\n\t\t},\n\t\t{\n\t\t\tname:         \"C25\",\n\t\t\tfrom:         \"claude\",\n\t\t\tto:           \"claude\",\n\t\t\tmodel:        \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON:    `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"}}`,\n\t\t\texpectField:  \"thinking.type\",\n\t\t\texpectValue:  \"adaptive\",\n\t\t\texpectField2: \"output_config.effort\",\n\t\t\texpectValue2: \"high\",\n\t\t\texpectErr:    false,\n\t\t},\n\t\t{\n\t\t\tname:      \"C26\",\n\t\t\tfrom:      \"claude\",\n\t\t\tto:        \"claude\",\n\t\t\tmodel:     \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON: `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"max\"}}`,\n\t\t\texpectErr: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"C27\",\n\t\t\tfrom:      \"claude\",\n\t\t\tto:        \"claude\",\n\t\t\tmodel:     \"claude-sonnet-4-6-model\",\n\t\t\tinputJSON: `{\"model\":\"claude-sonnet-4-6-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"xhigh\"}}`,\n\t\t\texpectErr: true,\n\t\t},\n\t}\n\n\trunThinkingTests(t, cases)\n}\n\n// getTestModels returns the shared model definitions for E2E tests.\nfunc getTestModels() []*registry.ModelInfo {\n\treturn []*registry.ModelInfo{\n\t\t{\n\t\t\tID:          \"level-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"openai\",\n\t\t\tDisplayName: \"Level Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Levels: []string{\"minimal\", \"low\", \"medium\", \"high\"}, ZeroAllowed: false, DynamicAllowed: false},\n\t\t},\n\t\t{\n\t\t\tID:          \"level-subset-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"gemini\",\n\t\t\tDisplayName: \"Level Subset Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Levels: []string{\"low\", \"high\"}, ZeroAllowed: false, DynamicAllowed: false},\n\t\t},\n\t\t{\n\t\t\tID:          \"gemini-budget-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"gemini\",\n\t\t\tDisplayName: \"Gemini Budget Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Min: 128, Max: 20000, ZeroAllowed: false, DynamicAllowed: true},\n\t\t},\n\t\t{\n\t\t\tID:          \"gemini-mixed-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"gemini\",\n\t\t\tDisplayName: \"Gemini Mixed Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{\"low\", \"high\"}, ZeroAllowed: false, DynamicAllowed: true},\n\t\t},\n\t\t{\n\t\t\tID:          \"claude-budget-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"claude\",\n\t\t\tDisplayName: \"Claude Budget Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},\n\t\t},\n\t\t{\n\t\t\tID:                  \"claude-sonnet-4-6-model\",\n\t\t\tObject:              \"model\",\n\t\t\tCreated:             1771372800, // 2026-02-17\n\t\t\tOwnedBy:             \"anthropic\",\n\t\t\tType:                \"claude\",\n\t\t\tDisplayName:         \"Claude 4.6 Sonnet\",\n\t\t\tContextLength:       200000,\n\t\t\tMaxCompletionTokens: 64000,\n\t\t\tThinking:            &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{\"low\", \"medium\", \"high\"}},\n\t\t},\n\t\t{\n\t\t\tID:                  \"claude-opus-4-6-model\",\n\t\t\tObject:              \"model\",\n\t\t\tCreated:             1770318000, // 2026-02-05\n\t\t\tOwnedBy:             \"anthropic\",\n\t\t\tType:                \"claude\",\n\t\t\tDisplayName:         \"Claude 4.6 Opus\",\n\t\t\tDescription:         \"Premium model combining maximum intelligence with practical performance\",\n\t\t\tContextLength:       1000000,\n\t\t\tMaxCompletionTokens: 128000,\n\t\t\tThinking:            &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{\"low\", \"medium\", \"high\", \"max\"}},\n\t\t},\n\t\t{\n\t\t\tID:          \"antigravity-budget-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"gemini-cli\",\n\t\t\tDisplayName: \"Antigravity Budget Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Min: 128, Max: 20000, ZeroAllowed: true, DynamicAllowed: true},\n\t\t},\n\t\t{\n\t\t\tID:          \"no-thinking-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"openai\",\n\t\t\tDisplayName: \"No Thinking Model\",\n\t\t\tThinking:    nil,\n\t\t},\n\t\t{\n\t\t\tID:          \"user-defined-model\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"openai\",\n\t\t\tDisplayName: \"User Defined Model\",\n\t\t\tUserDefined: true,\n\t\t\tThinking:    nil,\n\t\t},\n\t\t{\n\t\t\tID:          \"glm-test\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"iflow\",\n\t\t\tDisplayName: \"GLM Test Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Levels: []string{\"none\", \"auto\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"}},\n\t\t},\n\t\t{\n\t\t\tID:          \"minimax-test\",\n\t\t\tObject:      \"model\",\n\t\t\tCreated:     1700000000,\n\t\t\tOwnedBy:     \"test\",\n\t\t\tType:        \"iflow\",\n\t\t\tDisplayName: \"MiniMax Test Model\",\n\t\t\tThinking:    &registry.ThinkingSupport{Levels: []string{\"none\", \"auto\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"}},\n\t\t},\n\t}\n}\n\n// runThinkingTests runs thinking test cases using the real data flow path.\nfunc runThinkingTests(t *testing.T, cases []thinkingTestCase) {\n\tfor _, tc := range cases {\n\t\ttc := tc\n\t\ttestName := fmt.Sprintf(\"Case%s_%s->%s_%s\", tc.name, tc.from, tc.to, tc.model)\n\t\tt.Run(testName, func(t *testing.T) {\n\t\t\tsuffixResult := thinking.ParseSuffix(tc.model)\n\t\t\tbaseModel := suffixResult.ModelName\n\n\t\t\ttranslateTo := tc.to\n\t\t\tapplyTo := tc.to\n\t\t\tif tc.to == \"iflow\" {\n\t\t\t\ttranslateTo = \"openai\"\n\t\t\t\tapplyTo = \"iflow\"\n\t\t\t}\n\n\t\t\tbody := sdktranslator.TranslateRequest(\n\t\t\t\tsdktranslator.FromString(tc.from),\n\t\t\t\tsdktranslator.FromString(translateTo),\n\t\t\t\tbaseModel,\n\t\t\t\t[]byte(tc.inputJSON),\n\t\t\t\ttrue,\n\t\t\t)\n\t\t\tif applyTo == \"claude\" {\n\t\t\t\tbody, _ = sjson.SetBytes(body, \"max_tokens\", 200000)\n\t\t\t}\n\n\t\t\tbody, err := thinking.ApplyThinking(body, tc.model, tc.from, applyTo, applyTo)\n\n\t\t\tif tc.expectErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"expected error but got none, body=%s\", string(body))\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v, body=%s\", err, string(body))\n\t\t\t}\n\n\t\t\tif tc.expectField == \"\" {\n\t\t\t\tvar hasThinking bool\n\t\t\t\tswitch tc.to {\n\t\t\t\tcase \"gemini\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"generationConfig.thinkingConfig\").Exists()\n\t\t\t\tcase \"gemini-cli\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"request.generationConfig.thinkingConfig\").Exists()\n\t\t\t\tcase \"antigravity\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"request.generationConfig.thinkingConfig\").Exists()\n\t\t\t\tcase \"claude\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"thinking\").Exists()\n\t\t\t\tcase \"openai\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"reasoning_effort\").Exists()\n\t\t\t\tcase \"codex\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"reasoning.effort\").Exists() || gjson.GetBytes(body, \"reasoning\").Exists()\n\t\t\t\tcase \"iflow\":\n\t\t\t\t\thasThinking = gjson.GetBytes(body, \"chat_template_kwargs.enable_thinking\").Exists() || gjson.GetBytes(body, \"reasoning_split\").Exists()\n\t\t\t\t}\n\t\t\t\tif hasThinking {\n\t\t\t\t\tt.Fatalf(\"expected no thinking field but found one, body=%s\", string(body))\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassertField := func(fieldPath, expected string) {\n\t\t\t\tval := gjson.GetBytes(body, fieldPath)\n\t\t\t\tif !val.Exists() {\n\t\t\t\t\tt.Fatalf(\"expected field %s not found, body=%s\", fieldPath, string(body))\n\t\t\t\t}\n\t\t\t\tactualValue := val.String()\n\t\t\t\tif val.Type == gjson.Number {\n\t\t\t\t\tactualValue = fmt.Sprintf(\"%d\", val.Int())\n\t\t\t\t}\n\t\t\t\tif actualValue != expected {\n\t\t\t\t\tt.Fatalf(\"field %s: expected %q, got %q, body=%s\", fieldPath, expected, actualValue, string(body))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tassertField(tc.expectField, tc.expectValue)\n\t\t\tif tc.expectField2 != \"\" {\n\t\t\t\tassertField(tc.expectField2, tc.expectValue2)\n\t\t\t}\n\n\t\t\tif tc.includeThoughts != \"\" && (tc.to == \"gemini\" || tc.to == \"gemini-cli\" || tc.to == \"antigravity\") {\n\t\t\t\tpath := \"generationConfig.thinkingConfig.includeThoughts\"\n\t\t\t\tif tc.to == \"gemini-cli\" || tc.to == \"antigravity\" {\n\t\t\t\t\tpath = \"request.generationConfig.thinkingConfig.includeThoughts\"\n\t\t\t\t}\n\t\t\t\titVal := gjson.GetBytes(body, path)\n\t\t\t\tif !itVal.Exists() {\n\t\t\t\t\tt.Fatalf(\"expected includeThoughts field not found, body=%s\", string(body))\n\t\t\t\t}\n\t\t\t\tactual := fmt.Sprintf(\"%v\", itVal.Bool())\n\t\t\t\tif actual != tc.includeThoughts {\n\t\t\t\t\tt.Fatalf(\"includeThoughts: expected %s, got %s, body=%s\", tc.includeThoughts, actual, string(body))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Verify clear_thinking for iFlow GLM models when enable_thinking=true\n\t\t\tif tc.to == \"iflow\" && tc.expectField == \"chat_template_kwargs.enable_thinking\" && tc.expectValue == \"true\" {\n\t\t\t\tbaseModel := thinking.ParseSuffix(tc.model).ModelName\n\t\t\t\tisGLM := strings.HasPrefix(strings.ToLower(baseModel), \"glm\")\n\t\t\t\tctVal := gjson.GetBytes(body, \"chat_template_kwargs.clear_thinking\")\n\t\t\t\tif isGLM {\n\t\t\t\t\tif !ctVal.Exists() {\n\t\t\t\t\t\tt.Fatalf(\"expected clear_thinking field not found for GLM model, body=%s\", string(body))\n\t\t\t\t\t}\n\t\t\t\t\tif ctVal.Bool() != false {\n\t\t\t\t\t\tt.Fatalf(\"clear_thinking: expected false, got %v, body=%s\", ctVal.Bool(), string(body))\n\t\t\t\t\t}\n\t\t\t\t} else if ctVal.Exists() {\n\t\t\t\t\tt.Fatalf(\"expected no clear_thinking field for non-GLM enable_thinking model, body=%s\", string(body))\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  }
]