Browse Source

Add claude code multiplexer service

世界 1 week ago
parent
commit
dd2b5fd8ac

+ 73 - 5
.github/workflows/build.yml

@@ -93,10 +93,6 @@ jobs:
           - { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" }
           - { os: windows, arch: arm64 }
 
-          - { os: darwin, arch: amd64 }
-          - { os: darwin, arch: arm64 }
-          - { os: darwin, arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
-
           - { os: android, arch: arm64, ndk: "aarch64-linux-android21" }
           - { os: android, arch: arm, ndk: "armv7a-linux-androideabi21" }
           - { os: android, arch: amd64, ndk: "x86_64-linux-android21" }
@@ -146,7 +142,7 @@ jobs:
       - name: Set build tags
         run: |
           set -xeuo pipefail
-          TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0'
+          TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
           echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
       - name: Build
         if: matrix.os != 'android'
@@ -285,6 +281,77 @@ jobs:
         with:
           name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.go386 && format('_{0}', matrix.go386) }}${{ matrix.gomips && format('_{0}', matrix.gomips) }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
           path: "dist"
+  build_darwin:
+    name: Build Darwin binaries
+    if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary'
+    runs-on: macos-latest
+    needs:
+      - calculate_version
+    strategy:
+      matrix:
+        include:
+          - { arch: amd64 }
+          - { arch: arm64 }
+          - { arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
+    steps:
+      - name: Checkout
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+        with:
+          fetch-depth: 0
+      - name: Setup Go
+        if: ${{ ! matrix.legacy_go124 }}
+        uses: actions/setup-go@v5
+        with:
+          go-version: ^1.25.3
+      - name: Setup Go 1.24
+        if: matrix.legacy_go124
+        uses: actions/setup-go@v5
+        with:
+          go-version: ~1.24.6
+      - name: Set tag
+        run: |-
+          git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
+          git tag v${{ needs.calculate_version.outputs.version }} -f
+      - name: Set build tags
+        run: |
+          set -xeuo pipefail
+          TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
+          echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
+      - name: Build
+        run: |
+          set -xeuo pipefail
+          mkdir -p dist
+          go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
+          -ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0' \
+          ./cmd/sing-box
+        env:
+          CGO_ENABLED: "1"
+          GOOS: darwin
+          GOARCH: ${{ matrix.arch }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Set name
+        run: |-
+          DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-darwin-${{ matrix.arch }}"
+          if [[ -n "${{ matrix.legacy_name }}" ]]; then
+            DIR_NAME="${DIR_NAME}-legacy-${{ matrix.legacy_name }}"
+          fi
+          echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}"
+      - name: Archive
+        run: |
+          set -xeuo pipefail
+          cd dist
+          mkdir -p "${DIR_NAME}"
+          cp ../LICENSE "${DIR_NAME}"
+          cp sing-box "${DIR_NAME}"
+          tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}"
+          rm -r "${DIR_NAME}"
+      - name: Cleanup
+        run: rm dist/sing-box
+      - name: Upload artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: binary-darwin_${{ matrix.arch }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
+          path: "dist"
   build_android:
     name: Build Android
     if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android'
@@ -619,6 +686,7 @@ jobs:
     needs:
       - calculate_version
       - build
+      - build_darwin
       - build_android
       - build_apple
     steps:

+ 1 - 1
.github/workflows/linux.yml

@@ -85,7 +85,7 @@ jobs:
       - name: Set build tags
         run: |
           set -xeuo pipefail
-          TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0'
+          TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
           echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
       - name: Build
         run: |

+ 0 - 103
.goreleaser.fury.yaml

@@ -1,103 +0,0 @@
-project_name: sing-box
-builds:
-  - id: main
-    main: ./cmd/sing-box
-    flags:
-      - -v
-      - -trimpath
-    ldflags:
-      - -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
-      - -s
-      - -buildid=
-    tags:
-      - with_gvisor
-      - with_quic
-      - with_dhcp
-      - with_wireguard
-      - with_utls
-      - with_acme
-      - with_clash_api
-      - with_tailscale
-    env:
-      - CGO_ENABLED=0
-    targets:
-      - linux_386
-      - linux_amd64_v1
-      - linux_arm64
-      - linux_arm_7
-      - linux_s390x
-      - linux_riscv64
-      - linux_mips64le
-    mod_timestamp: '{{ .CommitTimestamp }}'
-snapshot:
-  name_template: "{{ .Version }}.{{ .ShortCommit }}"
-nfpms:
-  - &template
-    id: package
-    package_name: sing-box
-    file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
-    builds:
-      - main
-    homepage: https://sing-box.sagernet.org/
-    maintainer: nekohasekai <[email protected]>
-    description: The universal proxy platform.
-    license: GPLv3 or later
-    formats:
-      - deb
-      - rpm
-    priority: extra
-    contents:
-      - src: release/config/config.json
-        dst: /etc/sing-box/config.json
-        type: "config|noreplace"
-
-      - src: release/config/sing-box.service
-        dst: /usr/lib/systemd/system/sing-box.service
-      - src: release/config/[email protected]
-        dst: /usr/lib/systemd/system/[email protected]
-      - src: release/config/sing-box.sysusers
-        dst: /usr/lib/sysusers.d/sing-box.conf
-      - src: release/config/sing-box.rules
-        dst: /usr/share/polkit-1/rules.d/sing-box.rules
-      - src: release/config/sing-box-split-dns.xml
-        dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
-
-      - src: release/completions/sing-box.bash
-        dst: /usr/share/bash-completion/completions/sing-box.bash
-      - src: release/completions/sing-box.fish
-        dst: /usr/share/fish/vendor_completions.d/sing-box.fish
-      - src: release/completions/sing-box.zsh
-        dst: /usr/share/zsh/site-functions/_sing-box
-
-      - src: LICENSE
-        dst: /usr/share/licenses/sing-box/LICENSE
-    deb:
-      signature:
-        key_file: "{{ .Env.NFPM_KEY_PATH }}"
-      fields:
-        Bugs: https://github.com/SagerNet/sing-box/issues
-    rpm:
-      signature:
-        key_file: "{{ .Env.NFPM_KEY_PATH }}"
-    conflicts:
-      - sing-box-beta
-  - id: package_beta
-    <<: *template
-    package_name: sing-box-beta
-    file_name_template: '{{ .ProjectName }}-beta_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
-    formats:
-      - deb
-      - rpm
-    conflicts:
-      - sing-box
-release:
-  disable: true
-furies:
-  - account: sagernet
-    ids:
-      - package
-    disable: "{{ not (not .Prerelease) }}"
-  - account: sagernet
-    ids:
-      - package_beta
-    disable: "{{ not .Prerelease }}"

+ 0 - 213
.goreleaser.yaml

@@ -1,213 +0,0 @@
-version: 2
-project_name: sing-box
-builds:
-  - &template
-    id: main
-    main: ./cmd/sing-box
-    flags:
-      - -v
-      - -trimpath
-    ldflags:
-      - -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
-      - -s
-      - -buildid=
-    tags:
-      - with_gvisor
-      - with_quic
-      - with_dhcp
-      - with_wireguard
-      - with_utls
-      - with_acme
-      - with_clash_api
-      - with_tailscale
-    env:
-      - CGO_ENABLED=0
-      - GOTOOLCHAIN=local
-    targets:
-      - linux_386
-      - linux_amd64_v1
-      - linux_arm64
-      - linux_arm_6
-      - linux_arm_7
-      - linux_s390x
-      - linux_riscv64
-      - linux_mips64le
-      - windows_amd64_v1
-      - windows_386
-      - windows_arm64
-      - darwin_amd64_v1
-      - darwin_arm64
-    mod_timestamp: '{{ .CommitTimestamp }}'
-  - id: legacy
-    <<: *template
-    tags:
-      - with_gvisor
-      - with_quic
-      - with_dhcp
-      - with_wireguard
-      - with_utls
-      - with_acme
-      - with_clash_api
-      - with_tailscale
-    env:
-      - CGO_ENABLED=0
-      - GOROOT={{ .Env.GOPATH }}/go_legacy
-    tool: "{{ .Env.GOPATH }}/go_legacy/bin/go"
-    targets:
-      - windows_amd64_v1
-      - windows_386
-  - id: android
-    <<: *template
-    env:
-      - CGO_ENABLED=1
-      - GOTOOLCHAIN=local
-    overrides:
-      - goos: android
-        goarch: arm
-        goarm: 7
-        env:
-          - CC=armv7a-linux-androideabi21-clang
-          - CXX=armv7a-linux-androideabi21-clang++
-      - goos: android
-        goarch: arm64
-        env:
-          - CC=aarch64-linux-android21-clang
-          - CXX=aarch64-linux-android21-clang++
-      - goos: android
-        goarch: 386
-        env:
-          - CC=i686-linux-android21-clang
-          - CXX=i686-linux-android21-clang++
-      - goos: android
-        goarch: amd64
-        goamd64: v1
-        env:
-          - CC=x86_64-linux-android21-clang
-          - CXX=x86_64-linux-android21-clang++
-    targets:
-      - android_arm_7
-      - android_arm64
-      - android_386
-      - android_amd64
-archives:
-  - &template
-    id: archive
-    builds:
-      - main
-      - android
-    formats:
-      - tar.gz
-    format_overrides:
-      - goos: windows
-        formats:
-          - zip
-    wrap_in_directory: true
-    files:
-      - LICENSE
-    name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
-  - id: archive-legacy
-    <<: *template
-    builds:
-      - legacy
-    name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy'
-nfpms:
-  - id: package
-    package_name: sing-box
-    file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
-    builds:
-      - main
-    homepage: https://sing-box.sagernet.org/
-    maintainer: nekohasekai <[email protected]>
-    description: The universal proxy platform.
-    license: GPLv3 or later
-    formats:
-      - deb
-      - rpm
-      - archlinux
-#      - apk
-#      - ipk
-    priority: extra
-    contents:
-      - src: release/config/config.json
-        dst: /etc/sing-box/config.json
-        type: "config|noreplace"
-
-      - src: release/config/sing-box.service
-        dst: /usr/lib/systemd/system/sing-box.service
-      - src: release/config/[email protected]
-        dst: /usr/lib/systemd/system/[email protected]
-      - src: release/config/sing-box.sysusers
-        dst: /usr/lib/sysusers.d/sing-box.conf
-      - src: release/config/sing-box.rules
-        dst: /usr/share/polkit-1/rules.d/sing-box.rules
-      - src: release/config/sing-box-split-dns.xml
-        dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
-
-      - src: release/completions/sing-box.bash
-        dst: /usr/share/bash-completion/completions/sing-box.bash
-      - src: release/completions/sing-box.fish
-        dst: /usr/share/fish/vendor_completions.d/sing-box.fish
-      - src: release/completions/sing-box.zsh
-        dst: /usr/share/zsh/site-functions/_sing-box
-
-      - src: LICENSE
-        dst: /usr/share/licenses/sing-box/LICENSE
-    deb:
-      signature:
-        key_file: "{{ .Env.NFPM_KEY_PATH }}"
-      fields:
-        Bugs: https://github.com/SagerNet/sing-box/issues
-    rpm:
-      signature:
-        key_file: "{{ .Env.NFPM_KEY_PATH }}"
-    overrides:
-      apk:
-        contents:
-          - src: release/config/config.json
-            dst: /etc/sing-box/config.json
-            type: config
-
-          - src: release/config/sing-box.initd
-            dst: /etc/init.d/sing-box
-
-          - src: release/completions/sing-box.bash
-            dst: /usr/share/bash-completion/completions/sing-box.bash
-          - src: release/completions/sing-box.fish
-            dst: /usr/share/fish/vendor_completions.d/sing-box.fish
-          - src: release/completions/sing-box.zsh
-            dst: /usr/share/zsh/site-functions/_sing-box
-
-          - src: LICENSE
-            dst: /usr/share/licenses/sing-box/LICENSE
-      ipk:
-        contents:
-          - src: release/config/config.json
-            dst: /etc/sing-box/config.json
-            type: config
-
-          - src: release/config/openwrt.init
-            dst: /etc/init.d/sing-box
-          - src: release/config/openwrt.conf
-            dst: /etc/config/sing-box
-source:
-  enabled: false
-  name_template: '{{ .ProjectName }}-{{ .Version }}.source'
-  prefix_template: '{{ .ProjectName }}-{{ .Version }}/'
-checksum:
-  disable: true
-  name_template: '{{ .ProjectName }}-{{ .Version }}.checksum'
-signs:
-  - artifacts: checksum
-release:
-  github:
-    owner: SagerNet
-    name: sing-box
-  draft: true
-  prerelease: auto
-  mode: replace
-  ids:
-    - archive
-    - package
-  skip_upload: true
-partial:
-  by: target

+ 1 - 1
Dockerfile

@@ -13,7 +13,7 @@ RUN set -ex \
     && export COMMIT=$(git rev-parse --short HEAD) \
     && export VERSION=$(go run ./cmd/internal/read_tag) \
     && go build -v -trimpath -tags \
-        "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0" \
+        "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0" \
         -o /go/bin/sing-box \
         -ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
         ./cmd/sing-box

+ 1 - 1
Makefile

@@ -1,6 +1,6 @@
 NAME = sing-box
 COMMIT = $(shell git rev-parse --short HEAD)
-TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0
+TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0
 
 GOHOSTOS = $(shell go env GOHOSTOS)
 GOHOSTARCH = $(shell go env GOHOSTARCH)

+ 1 - 0
constant/proxy.go

@@ -28,6 +28,7 @@ const (
 	TypeDERP         = "derp"
 	TypeResolved     = "resolved"
 	TypeSSMAPI       = "ssm-api"
+	TypeCCM          = "ccm"
 )
 
 const (

+ 104 - 0
docs/configuration/service/ccm.md

@@ -0,0 +1,104 @@
+---
+icon: material/new-box
+---
+
+!!! question "Since sing-box 1.13.0"
+
+# CCM
+
+CCM (Claude Code Multiplexer) service is a multiplexing service that allows you to access your local Claude Code subscription remotely through custom tokens.
+
+It handles OAuth authentication with Claude's API on your local machine while allowing remote Claude Code to authenticate using Auth Tokens via the `ANTHROPIC_AUTH_TOKEN` environment variable.
+
+### Structure
+
+```json
+{
+  "type": "ccm",
+
+  ... // Listen Fields
+
+  "credential_path": "",
+  "usages_path": "",
+  "users": [],
+  "headers": {},
+  "detour": "",
+  "tls": {}
+}
+```
+
+### Listen Fields
+
+See [Listen Fields](/configuration/shared/listen/) for details.
+
+### Fields
+
+#### credential_path
+
+Path to the Claude Code OAuth credentials file.
+
+Defaults to `~/.claude/.credentials.json` if not specified.
+
+On macOS, credentials are read from the system keychain first, then fall back to the file if unavailable.
+
+Refreshed tokens are automatically written back to the same location.
+
+#### usages_path
+
+Path to the file for storing aggregated API usage statistics.
+
+Usage tracking is disabled if not specified.
+
+When enabled, the service tracks and saves comprehensive statistics including:
+- Request counts
+- Token usage (input, output, cache read, cache creation)
+- Calculated costs in USD based on Claude API pricing
+
+Statistics are organized by model, context window (200k standard vs 1M premium), and optionally by user when authentication is enabled.
+
+The statistics file is automatically saved every minute and upon service shutdown.
+
+#### users
+
+List of authorized users for token authentication.
+
+If empty, no authentication is required.
+
+Claude Code authenticates by setting the `ANTHROPIC_AUTH_TOKEN` environment variable to their token value.
+
+#### headers
+
+Custom HTTP headers to send to the Claude API.
+
+These headers will override any existing headers with the same name.
+
+#### detour
+
+Outbound tag for connecting to the Claude API.
+
+#### tls
+
+TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
+
+### Example
+
+```json
+{
+  "services": [
+    {
+      "type": "ccm",
+      "listen": "127.0.0.1",
+      "listen_port": 8080
+    }
+  ]
+}
+```
+
+Connect to the CCM service:
+
+```bash
+export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
+export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context"
+
+claude
+```

+ 104 - 0
docs/configuration/service/ccm.zh.md

@@ -0,0 +1,104 @@
+---
+icon: material/new-box
+---
+
+!!! question "自 sing-box 1.13.0 起"
+
+# CCM
+
+CCM(Claude Code 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 Claude Code 订阅。
+
+它在本地机器上处理与 Claude API 的 OAuth 身份验证,同时允许远程 Claude Code 通过 `ANTHROPIC_AUTH_TOKEN` 环境变量使用认证令牌进行身份验证。
+
+### 结构
+
+```json
+{
+  "type": "ccm",
+
+  ... // 监听字段
+
+  "credential_path": "",
+  "usages_path": "",
+  "users": [],
+  "headers": {},
+  "detour": "",
+  "tls": {}
+}
+```
+
+### 监听字段
+
+参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。
+
+### 字段
+
+#### credential_path
+
+Claude Code OAuth 凭据文件的路径。
+
+如果未指定,默认使用 `~/.claude/.credentials.json`。
+
+在 macOS 上,首先从系统钥匙串读取凭据,如果不可用则回退到文件。
+
+刷新的令牌会自动写回相同位置。
+
+#### usages_path
+
+用于存储聚合 API 使用统计信息的文件路径。
+
+如果未指定,使用跟踪将被禁用。
+
+启用后,服务会跟踪并保存全面的统计信息,包括:
+- 请求计数
+- 令牌使用量(输入、输出、缓存读取、缓存创建)
+- 基于 Claude API 定价计算的美元成本
+
+统计信息按模型、上下文窗口(200k 标准版 vs 1M 高级版)以及可选的用户(启用身份验证时)进行组织。
+
+统计文件每分钟自动保存一次,并在服务关闭时保存。
+
+#### users
+
+用于令牌身份验证的授权用户列表。
+
+如果为空,则不需要身份验证。
+
+Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。
+
+#### headers
+
+发送到 Claude API 的自定义 HTTP 头。
+
+这些头会覆盖同名的现有头。
+
+#### detour
+
+用于连接 Claude API 的出站标签。
+
+#### tls
+
+TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
+
+### 示例
+
+```json
+{
+  "services": [
+    {
+      "type": "ccm",
+      "listen": "127.0.0.1",
+      "listen_port": 8080
+    }
+  ]
+}
+```
+
+连接到 CCM 服务:
+
+```bash
+export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
+export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context"
+
+claude
+```

+ 1 - 0
docs/configuration/service/index.md

@@ -23,6 +23,7 @@ icon: material/new-box
 
 | Type       | Format                 |
 |------------|------------------------|
+| `ccm`      | [CCM](./ccm)           |
 | `derp`     | [DERP](./derp)         |
 | `resolved` | [Resolved](./resolved) |
 | `ssm-api`  | [SSM API](./ssm-api)   |

+ 1 - 0
docs/configuration/service/index.zh.md

@@ -23,6 +23,7 @@ icon: material/new-box
 
 | 类型       | 格式                   |
 |-----------|------------------------|
+| `ccm`     | [CCM](./ccm)           |
 | `derp`    | [DERP](./derp)         |
 | `resolved`| [Resolved](./resolved) |
 | `ssm-api` | [SSM API](./ssm-api)   |

+ 6 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/sagernet/sing-box
 go 1.24.7
 
 require (
+	github.com/anthropics/anthropic-sdk-go v1.14.0
 	github.com/anytls/sing-anytls v0.0.11
 	github.com/caddyserver/certmagic v0.23.0
 	github.com/coder/websocket v1.8.13
@@ -13,6 +14,7 @@ require (
 	github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
 	github.com/gofrs/uuid/v5 v5.3.2
 	github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f
+	github.com/keybase/go-keychain v0.0.1
 	github.com/libdns/alidns v1.0.5-libdns.v1.beta1
 	github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6
 	github.com/logrusorgru/aurora v2.0.3+incompatible
@@ -113,6 +115,10 @@ require (
 	github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
 	github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
 	github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
+	github.com/tidwall/gjson v1.18.0 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.1 // indirect
+	github.com/tidwall/sjson v1.2.5 // indirect
 	github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/zeebo/blake3 v0.2.4 // indirect

+ 14 - 0
go.sum

@@ -8,6 +8,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4=
+github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
 github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
 github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
 github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU=
@@ -95,6 +97,8 @@ github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBe
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
 github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
 github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
 github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
@@ -216,6 +220,16 @@ github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs
 github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
 github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
 github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
 github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=

+ 12 - 0
include/ccm.go

@@ -0,0 +1,12 @@
+//go:build with_ccm && (!darwin || cgo)
+
+package include
+
+import (
+	"github.com/sagernet/sing-box/adapter/service"
+	"github.com/sagernet/sing-box/service/ccm"
+)
+
+func registerCCMService(registry *service.Registry) {
+	ccm.RegisterService(registry)
+}

+ 20 - 0
include/ccm_stub.go

@@ -0,0 +1,20 @@
+//go:build !with_ccm
+
+package include
+
+import (
+	"context"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/adapter/service"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+func registerCCMService(registry *service.Registry) {
+	service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
+		return nil, E.New(`CCM is not included in this build, rebuild with -tags with_CCM`)
+	})
+}

+ 20 - 0
include/ccm_stub_darwin.go

@@ -0,0 +1,20 @@
+//go:build with_ccm && darwin && !cgo
+
+package include
+
+import (
+	"context"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/adapter/service"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+func registerCCMService(registry *service.Registry) {
+	service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
+		return nil, E.New(`CCM requires CGO on darwin, rebuild with CGO_ENABLED=1`)
+	})
+}

+ 1 - 0
include/registry.go

@@ -134,6 +134,7 @@ func ServiceRegistry() *service.Registry {
 	ssmapi.RegisterService(registry)
 
 	registerDERPService(registry)
+	registerCCMService(registry)
 
 	return registry
 }

+ 20 - 0
option/ccm.go

@@ -0,0 +1,20 @@
+package option
+
+import (
+	"github.com/sagernet/sing/common/json/badoption"
+)
+
+type CCMServiceOptions struct {
+	ListenOptions
+	InboundTLSOptionsContainer
+	CredentialPath string               `json:"credential_path,omitempty"`
+	Users          []CCMUser            `json:"users,omitempty"`
+	Headers        badoption.HTTPHeader `json:"headers,omitempty"`
+	Detour         string               `json:"detour,omitempty"`
+	UsagesPath     string               `json:"usages_path,omitempty"`
+}
+
+type CCMUser struct {
+	Name  string `json:"name,omitempty"`
+	Token string `json:"token,omitempty"`
+}

+ 100 - 0
release/local/common.sh

@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+
+set -e -o pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+BINARY_NAME="sing-box"
+
+INSTALL_BIN_PATH="/usr/local/bin"
+INSTALL_CONFIG_PATH="/usr/local/etc/sing-box"
+INSTALL_DATA_PATH="/var/lib/sing-box"
+SYSTEMD_SERVICE_PATH="/etc/systemd/system"
+
+DEFAULT_BUILD_TAGS="with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0"
+
+setup_environment() {
+    if [ -d /usr/local/go ]; then
+        export PATH="$PATH:/usr/local/go/bin"
+    fi
+
+    if ! command -v go &> /dev/null; then
+        echo "Error: Go is not installed or not in PATH"
+        echo "Run install_go.sh to install Go"
+        exit 1
+    fi
+}
+
+get_build_tags() {
+    local extra_tags="$1"
+    if [ -n "$extra_tags" ]; then
+        echo "${DEFAULT_BUILD_TAGS},${extra_tags}"
+    else
+        echo "${DEFAULT_BUILD_TAGS}"
+    fi
+}
+
+get_version() {
+    cd "$PROJECT_DIR"
+    GOHOSTOS=$(go env GOHOSTOS)
+    GOHOSTARCH=$(go env GOHOSTARCH)
+    CGO_ENABLED=0 GOOS=$GOHOSTOS GOARCH=$GOHOSTARCH go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest
+}
+
+get_ldflags() {
+    local version
+    version=$(get_version)
+    echo "-X 'github.com/sagernet/sing-box/constant.Version=${version}' -s -w -buildid= -checklinkname=0"
+}
+
+build_sing_box() {
+    local tags="$1"
+    local ldflags
+    ldflags=$(get_ldflags)
+
+    echo "Building sing-box with tags: $tags"
+    cd "$PROJECT_DIR"
+    export GOTOOLCHAIN=local
+    go install -v -trimpath -ldflags "$ldflags" -tags "$tags" ./cmd/sing-box
+}
+
+install_binary() {
+    local gopath
+    gopath=$(go env GOPATH)
+    echo "Installing binary to $INSTALL_BIN_PATH/$BINARY_NAME"
+    sudo cp "${gopath}/bin/${BINARY_NAME}" "${INSTALL_BIN_PATH}/"
+}
+
+setup_config() {
+    echo "Setting up configuration"
+    sudo mkdir -p "$INSTALL_CONFIG_PATH"
+    if [ ! -f "$INSTALL_CONFIG_PATH/config.json" ]; then
+        sudo cp "$PROJECT_DIR/release/config/config.json" "$INSTALL_CONFIG_PATH/config.json"
+        echo "Default config installed to $INSTALL_CONFIG_PATH/config.json"
+    else
+        echo "Config already exists at $INSTALL_CONFIG_PATH/config.json (not overwriting)"
+    fi
+}
+
+setup_systemd() {
+    echo "Setting up systemd service"
+    sudo cp "$SCRIPT_DIR/sing-box.service" "$SYSTEMD_SERVICE_PATH/"
+    sudo systemctl daemon-reload
+}
+
+stop_service() {
+    if systemctl is-active --quiet sing-box; then
+        echo "Stopping sing-box service"
+        sudo systemctl stop sing-box
+    fi
+}
+
+start_service() {
+    echo "Starting sing-box service"
+    sudo systemctl start sing-box
+}
+
+restart_service() {
+    echo "Restarting sing-box service"
+    sudo systemctl restart sing-box
+}

+ 15 - 11
release/local/debug.sh

@@ -2,21 +2,25 @@
 
 set -e -o pipefail
 
-if [ -d /usr/local/go ]; then
-  export PATH="$PATH:/usr/local/go/bin"
-fi
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
 
-DIR=$(dirname "$0")
-PROJECT=$DIR/../..
+setup_environment
 
-pushd $PROJECT
+echo "Updating sing-box from git repository..."
+cd "$PROJECT_DIR"
 git fetch
 git reset FETCH_HEAD --hard
 git clean -fdx
-go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_acme,debug ./cmd/sing-box
-popd
 
-sudo systemctl stop sing-box
-sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
-sudo systemctl start sing-box
+BUILD_TAGS=$(get_build_tags "debug")
+
+build_sing_box "$BUILD_TAGS"
+
+stop_service
+install_binary
+start_service
+
+echo ""
+echo "Following service logs (Ctrl+C to exit)..."
 sudo journalctl -u sing-box --output cat -f

+ 12 - 13
release/local/install.sh

@@ -2,19 +2,18 @@
 
 set -e -o pipefail
 
-if [ -d /usr/local/go ]; then
-  export PATH="$PATH:/usr/local/go/bin"
-fi
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
 
-DIR=$(dirname "$0")
-PROJECT=$DIR/../..
+setup_environment
 
-pushd $PROJECT
-go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
-popd
+BUILD_TAGS=$(get_build_tags)
 
-sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
-sudo mkdir -p /usr/local/etc/sing-box
-sudo cp $PROJECT/release/config/config.json /usr/local/etc/sing-box/config.json
-sudo cp $DIR/sing-box.service /etc/systemd/system
-sudo systemctl daemon-reload
+build_sing_box "$BUILD_TAGS"
+install_binary
+setup_config
+setup_systemd
+
+echo ""
+echo "Installation complete!"
+echo "To enable and start the service, run: $SCRIPT_DIR/enable.sh"

+ 12 - 11
release/local/reinstall.sh

@@ -2,17 +2,18 @@
 
 set -e -o pipefail
 
-if [ -d /usr/local/go ]; then
-  export PATH="$PATH:/usr/local/go/bin"
-fi
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
 
-DIR=$(dirname "$0")
-PROJECT=$DIR/../..
+setup_environment
 
-pushd $PROJECT
-go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
-popd
+BUILD_TAGS=$(get_build_tags)
 
-sudo systemctl stop sing-box
-sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
-sudo systemctl start sing-box
+build_sing_box "$BUILD_TAGS"
+
+stop_service
+install_binary
+start_service
+
+echo ""
+echo "Reinstallation complete!"

+ 27 - 5
release/local/uninstall.sh

@@ -1,8 +1,30 @@
 #!/usr/bin/env bash
 
-sudo systemctl stop sing-box
-sudo rm -rf /var/lib/sing-box
-sudo rm -rf /usr/local/bin/sing-box
-sudo rm -rf /usr/local/etc/sing-box
-sudo rm -rf /etc/systemd/system/sing-box.service
+set -e -o pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+echo "Uninstalling sing-box..."
+
+if systemctl is-active --quiet sing-box 2>/dev/null; then
+    echo "Stopping sing-box service..."
+    sudo systemctl stop sing-box
+fi
+
+if systemctl is-enabled --quiet sing-box 2>/dev/null; then
+    echo "Disabling sing-box service..."
+    sudo systemctl disable sing-box
+fi
+
+echo "Removing files..."
+sudo rm -rf "$INSTALL_DATA_PATH"
+sudo rm -rf "$INSTALL_BIN_PATH/$BINARY_NAME"
+sudo rm -rf "$INSTALL_CONFIG_PATH"
+sudo rm -rf "$SYSTEMD_SERVICE_PATH/sing-box.service"
+
+echo "Reloading systemd..."
 sudo systemctl daemon-reload
+
+echo ""
+echo "Uninstallation complete!"

+ 7 - 5
release/local/update.sh

@@ -2,13 +2,15 @@
 
 set -e -o pipefail
 
-DIR=$(dirname "$0")
-PROJECT=$DIR/../..
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
 
-pushd $PROJECT
+echo "Updating sing-box from git repository..."
+cd "$PROJECT_DIR"
 git fetch
 git reset FETCH_HEAD --hard
 git clean -fdx
-popd
 
-$DIR/reinstall.sh
+echo ""
+echo "Running reinstall..."
+exec "$SCRIPT_DIR/reinstall.sh"

+ 136 - 0
service/ccm/credential.go

@@ -0,0 +1,136 @@
+package ccm
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"os"
+	"os/user"
+	"path/filepath"
+	"time"
+
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+const (
+	oauth2ClientID          = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+	oauth2TokenURL          = "https://console.anthropic.com/v1/oauth/token"
+	claudeAPIBaseURL        = "https://api.anthropic.com"
+	tokenRefreshBufferMs    = 60000
+	anthropicBetaOAuthValue = "oauth-2025-04-20"
+)
+
+func getRealUser() (*user.User, error) {
+	if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
+		sudoUserInfo, err := user.Lookup(sudoUser)
+		if err == nil {
+			return sudoUserInfo, nil
+		}
+	}
+	return user.Current()
+}
+
+func getDefaultCredentialsPath() (string, error) {
+	userInfo, err := getRealUser()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil
+}
+
+func readCredentialsFromFile(path string) (*oauthCredentials, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var credentialsContainer struct {
+		ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
+	}
+	err = json.Unmarshal(data, &credentialsContainer)
+	if err != nil {
+		return nil, err
+	}
+	if credentialsContainer.ClaudeAIAuth == nil {
+		return nil, E.New("claudeAiOauth field not found in credentials")
+	}
+	return credentialsContainer.ClaudeAIAuth, nil
+}
+
+func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error {
+	data, err := json.MarshalIndent(map[string]any{
+		"claudeAiOauth": oauthCredentials,
+	}, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0o600)
+}
+
+type oauthCredentials struct {
+	AccessToken      string   `json:"accessToken"`
+	RefreshToken     string   `json:"refreshToken"`
+	ExpiresAt        int64    `json:"expiresAt"`
+	Scopes           []string `json:"scopes,omitempty"`
+	SubscriptionType string   `json:"subscriptionType,omitempty"`
+	IsMax            bool     `json:"isMax,omitempty"`
+}
+
+func (c *oauthCredentials) needsRefresh() bool {
+	if c.ExpiresAt == 0 {
+		return false
+	}
+	return time.Now().UnixMilli() >= c.ExpiresAt-tokenRefreshBufferMs
+}
+
+func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) {
+	if credentials.RefreshToken == "" {
+		return nil, E.New("refresh token is empty")
+	}
+
+	requestBody, err := json.Marshal(map[string]string{
+		"grant_type":    "refresh_token",
+		"refresh_token": credentials.RefreshToken,
+		"client_id":     oauth2ClientID,
+	})
+	if err != nil {
+		return nil, E.Cause(err, "marshal request")
+	}
+
+	request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody))
+	if err != nil {
+		return nil, err
+	}
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("Accept", "application/json")
+
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return nil, E.New("refresh failed: ", response.Status, " ", string(body))
+	}
+
+	var tokenResponse struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+	}
+	err = json.NewDecoder(response.Body).Decode(&tokenResponse)
+	if err != nil {
+		return nil, E.Cause(err, "decode response")
+	}
+
+	newCredentials := *credentials
+	newCredentials.AccessToken = tokenResponse.AccessToken
+	if tokenResponse.RefreshToken != "" {
+		newCredentials.RefreshToken = tokenResponse.RefreshToken
+	}
+	newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000
+
+	return &newCredentials, nil
+}

+ 116 - 0
service/ccm/credential_darwin.go

@@ -0,0 +1,116 @@
+//go:build darwin && cgo
+
+package ccm
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"path/filepath"
+
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/keybase/go-keychain"
+)
+
+func getKeychainServiceName() string {
+	configDirectory := os.Getenv("CLAUDE_CONFIG_DIR")
+	if configDirectory == "" {
+		return "Claude Code-credentials"
+	}
+
+	userInfo, err := getRealUser()
+	if err != nil {
+		return "Claude Code-credentials"
+	}
+	defaultConfigDirectory := filepath.Join(userInfo.HomeDir, ".claude")
+	if configDirectory == defaultConfigDirectory {
+		return "Claude Code-credentials"
+	}
+
+	hash := sha256.Sum256([]byte(configDirectory))
+	return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8]
+}
+
+func platformReadCredentials(customPath string) (*oauthCredentials, error) {
+	if customPath != "" {
+		return readCredentialsFromFile(customPath)
+	}
+
+	userInfo, err := getRealUser()
+	if err == nil {
+		query := keychain.NewItem()
+		query.SetSecClass(keychain.SecClassGenericPassword)
+		query.SetService(getKeychainServiceName())
+		query.SetAccount(userInfo.Username)
+		query.SetMatchLimit(keychain.MatchLimitOne)
+		query.SetReturnData(true)
+
+		results, err := keychain.QueryItem(query)
+		if err == nil && len(results) == 1 {
+			var container struct {
+				ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
+			}
+			unmarshalErr := json.Unmarshal(results[0].Data, &container)
+			if unmarshalErr == nil && container.ClaudeAIAuth != nil {
+				return container.ClaudeAIAuth, nil
+			}
+		}
+		if err != nil && err != keychain.ErrorItemNotFound {
+			return nil, E.Cause(err, "query keychain")
+		}
+	}
+
+	defaultPath, err := getDefaultCredentialsPath()
+	if err != nil {
+		return nil, err
+	}
+	return readCredentialsFromFile(defaultPath)
+}
+
+func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
+	if customPath != "" {
+		return writeCredentialsToFile(oauthCredentials, customPath)
+	}
+
+	userInfo, err := getRealUser()
+	if err == nil {
+		data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials})
+		if err == nil {
+			serviceName := getKeychainServiceName()
+			item := keychain.NewItem()
+			item.SetSecClass(keychain.SecClassGenericPassword)
+			item.SetService(serviceName)
+			item.SetAccount(userInfo.Username)
+			item.SetData(data)
+			item.SetAccessible(keychain.AccessibleWhenUnlocked)
+
+			err = keychain.AddItem(item)
+			if err == nil {
+				return nil
+			}
+
+			if err == keychain.ErrorDuplicateItem {
+				query := keychain.NewItem()
+				query.SetSecClass(keychain.SecClassGenericPassword)
+				query.SetService(serviceName)
+				query.SetAccount(userInfo.Username)
+
+				updateItem := keychain.NewItem()
+				updateItem.SetData(data)
+
+				updateErr := keychain.UpdateItem(query, updateItem)
+				if updateErr == nil {
+					return nil
+				}
+			}
+		}
+	}
+
+	defaultPath, err := getDefaultCredentialsPath()
+	if err != nil {
+		return err
+	}
+	return writeCredentialsToFile(oauthCredentials, defaultPath)
+}

+ 25 - 0
service/ccm/credential_other.go

@@ -0,0 +1,25 @@
+//go:build !darwin
+
+package ccm
+
+func platformReadCredentials(customPath string) (*oauthCredentials, error) {
+	if customPath == "" {
+		var err error
+		customPath, err = getDefaultCredentialsPath()
+		if err != nil {
+			return nil, err
+		}
+	}
+	return readCredentialsFromFile(customPath)
+}
+
+func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
+	if customPath == "" {
+		var err error
+		customPath, err = getDefaultCredentialsPath()
+		if err != nil {
+			return err
+		}
+	}
+	return writeCredentialsToFile(oauthCredentials, customPath)
+}

+ 541 - 0
service/ccm/service.go

@@ -0,0 +1,541 @@
+package ccm
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"mime"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	boxService "github.com/sagernet/sing-box/adapter/service"
+	"github.com/sagernet/sing-box/common/dialer"
+	"github.com/sagernet/sing-box/common/listener"
+	"github.com/sagernet/sing-box/common/tls"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/buf"
+	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+	aTLS "github.com/sagernet/sing/common/tls"
+
+	"github.com/anthropics/anthropic-sdk-go"
+	"github.com/go-chi/chi/v5"
+	"golang.org/x/net/http2"
+)
+
+const (
+	contextWindowStandard   = 200000
+	contextWindowPremium    = 1000000
+	premiumContextThreshold = 200000
+)
+
+func RegisterService(registry *boxService.Registry) {
+	boxService.Register[option.CCMServiceOptions](registry, C.TypeCCM, NewService)
+}
+
+type errorResponse struct {
+	Type      string       `json:"type"`
+	Error     errorDetails `json:"error"`
+	RequestID string       `json:"request_id,omitempty"`
+}
+
+type errorDetails struct {
+	Type    string `json:"type"`
+	Message string `json:"message"`
+}
+
+func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(statusCode)
+
+	json.NewEncoder(w).Encode(errorResponse{
+		Type: "error",
+		Error: errorDetails{
+			Type:    errorType,
+			Message: message,
+		},
+		RequestID: r.Header.Get("Request-Id"),
+	})
+}
+
+func isHopByHopHeader(header string) bool {
+	switch strings.ToLower(header) {
+	case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host":
+		return true
+	default:
+		return false
+	}
+}
+
+type Service struct {
+	boxService.Adapter
+	ctx            context.Context
+	logger         log.ContextLogger
+	credentialPath string
+	credentials    *oauthCredentials
+	users          []option.CCMUser
+	httpClient     *http.Client
+	httpHeaders    http.Header
+	listener       *listener.Listener
+	tlsConfig      tls.ServerConfig
+	httpServer     *http.Server
+	userManager    *UserManager
+	accessMutex    sync.RWMutex
+	usageTracker   *AggregatedUsage
+	trackingGroup  sync.WaitGroup
+	shuttingDown   bool
+}
+
+func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
+	serviceDialer, err := dialer.NewWithOptions(dialer.Options{
+		Context: ctx,
+		Options: option.DialerOptions{
+			Detour: options.Detour,
+		},
+		RemoteIsDomain: true,
+	})
+	if err != nil {
+		return nil, E.Cause(err, "create dialer")
+	}
+
+	httpClient := &http.Client{
+		Transport: &http.Transport{
+			ForceAttemptHTTP2: true,
+			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
+			},
+		},
+	}
+
+	userManager := &UserManager{
+		tokenMap: make(map[string]string),
+	}
+
+	var usageTracker *AggregatedUsage
+	if options.UsagesPath != "" {
+		usageTracker = &AggregatedUsage{
+			LastUpdated:  time.Now(),
+			Combinations: make([]CostCombination, 0),
+			filePath:     options.UsagesPath,
+			logger:       logger,
+		}
+	}
+
+	service := &Service{
+		Adapter:        boxService.NewAdapter(C.TypeCCM, tag),
+		ctx:            ctx,
+		logger:         logger,
+		credentialPath: options.CredentialPath,
+		users:          options.Users,
+		httpClient:     httpClient,
+		httpHeaders:    options.Headers.Build(),
+		listener: listener.New(listener.Options{
+			Context: ctx,
+			Logger:  logger,
+			Network: []string{N.NetworkTCP},
+			Listen:  options.ListenOptions,
+		}),
+		userManager:  userManager,
+		usageTracker: usageTracker,
+	}
+
+	if options.TLS != nil {
+		tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
+		if err != nil {
+			return nil, err
+		}
+		service.tlsConfig = tlsConfig
+	}
+
+	return service, nil
+}
+
+func (s *Service) Start(stage adapter.StartStage) error {
+	if stage != adapter.StartStateStart {
+		return nil
+	}
+
+	s.userManager.UpdateUsers(s.users)
+
+	credentials, err := platformReadCredentials(s.credentialPath)
+	if err != nil {
+		return E.Cause(err, "read credentials")
+	}
+	s.credentials = credentials
+
+	if s.usageTracker != nil {
+		err = s.usageTracker.Load()
+		if err != nil {
+			s.logger.Warn("load usage statistics: ", err)
+		}
+	}
+
+	router := chi.NewRouter()
+	router.Mount("/", s)
+
+	s.httpServer = &http.Server{Handler: router}
+
+	if s.tlsConfig != nil {
+		err = s.tlsConfig.Start()
+		if err != nil {
+			return E.Cause(err, "create TLS config")
+		}
+	}
+
+	tcpListener, err := s.listener.ListenTCP()
+	if err != nil {
+		return err
+	}
+
+	if s.tlsConfig != nil {
+		if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
+			s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
+		}
+		tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
+	}
+
+	go func() {
+		serveErr := s.httpServer.Serve(tcpListener)
+		if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+			s.logger.Error("serve error: ", serveErr)
+		}
+	}()
+
+	return nil
+}
+
+func (s *Service) getAccessToken() (string, error) {
+	s.accessMutex.RLock()
+	if !s.credentials.needsRefresh() {
+		token := s.credentials.AccessToken
+		s.accessMutex.RUnlock()
+		return token, nil
+	}
+	s.accessMutex.RUnlock()
+
+	s.accessMutex.Lock()
+	defer s.accessMutex.Unlock()
+
+	if !s.credentials.needsRefresh() {
+		return s.credentials.AccessToken, nil
+	}
+
+	newCredentials, err := refreshToken(s.httpClient, s.credentials)
+	if err != nil {
+		return "", err
+	}
+
+	s.credentials = newCredentials
+
+	err = platformWriteCredentials(newCredentials, s.credentialPath)
+	if err != nil {
+		s.logger.Warn("persist refreshed token: ", err)
+	}
+
+	return newCredentials.AccessToken, nil
+}
+
+func detectContextWindow(betaHeader string, inputTokens int64) int {
+	if inputTokens > premiumContextThreshold {
+		features := strings.Split(betaHeader, ",")
+		for _, feature := range features {
+			if strings.TrimSpace(feature) == "context-1m" {
+				return contextWindowPremium
+			}
+		}
+	}
+	return contextWindowStandard
+}
+
+func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if !strings.HasPrefix(r.URL.Path, "/v1/") {
+		writeJSONError(w, r, http.StatusNotFound, "not_found_error", "Not found")
+		return
+	}
+
+	var username string
+	if len(s.users) > 0 {
+		authHeader := r.Header.Get("Authorization")
+		if authHeader == "" {
+			s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header")
+			writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key")
+			return
+		}
+		clientToken := strings.TrimPrefix(authHeader, "Bearer ")
+		if clientToken == authHeader {
+			s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format")
+			writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format")
+			return
+		}
+		var ok bool
+		username, ok = s.userManager.Authenticate(clientToken)
+		if !ok {
+			s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken)
+			writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key")
+			return
+		}
+	}
+
+	var requestModel string
+	var messagesCount int
+
+	if s.usageTracker != nil && r.Body != nil {
+		bodyBytes, err := io.ReadAll(r.Body)
+		if err == nil {
+			var request struct {
+				Model    string                   `json:"model"`
+				Messages []anthropic.MessageParam `json:"messages"`
+			}
+			err := json.Unmarshal(bodyBytes, &request)
+			if err == nil {
+				requestModel = request.Model
+				messagesCount = len(request.Messages)
+			}
+			r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+		}
+	}
+
+	accessToken, err := s.getAccessToken()
+	if err != nil {
+		s.logger.Error("get access token: ", err)
+		writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed")
+		return
+	}
+
+	proxyURL := claudeAPIBaseURL + r.URL.RequestURI()
+	proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body)
+	if err != nil {
+		s.logger.Error("create proxy request: ", err)
+		writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error")
+		return
+	}
+
+	for key, values := range r.Header {
+		if !isHopByHopHeader(key) && key != "Authorization" {
+			proxyRequest.Header[key] = values
+		}
+	}
+
+	anthropicBetaHeader := proxyRequest.Header.Get("anthropic-beta")
+	if anthropicBetaHeader != "" {
+		proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue+","+anthropicBetaHeader)
+	} else {
+		proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue)
+	}
+
+	for key, values := range s.httpHeaders {
+		proxyRequest.Header.Del(key)
+		proxyRequest.Header[key] = values
+	}
+
+	proxyRequest.Header.Set("Authorization", "Bearer "+accessToken)
+
+	response, err := s.httpClient.Do(proxyRequest)
+	if err != nil {
+		writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error())
+		return
+	}
+	defer response.Body.Close()
+
+	for key, values := range response.Header {
+		if !isHopByHopHeader(key) {
+			w.Header()[key] = values
+		}
+	}
+	w.WriteHeader(response.StatusCode)
+
+	if s.usageTracker != nil && response.StatusCode == http.StatusOK {
+		s.handleResponseWithTracking(w, response, requestModel, anthropicBetaHeader, messagesCount, username)
+	} else {
+		mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
+		if err == nil && mediaType != "text/event-stream" {
+			_, _ = io.Copy(w, response.Body)
+			return
+		}
+		flusher, ok := w.(http.Flusher)
+		if !ok {
+			s.logger.Error("streaming not supported")
+			return
+		}
+		buffer := make([]byte, buf.BufferSize)
+		for {
+			n, err := response.Body.Read(buffer)
+			if n > 0 {
+				_, writeError := w.Write(buffer[:n])
+				if writeError != nil {
+					s.logger.Error("write streaming response: ", writeError)
+					return
+				}
+				flusher.Flush()
+			}
+			if err != nil {
+				return
+			}
+		}
+	}
+}
+
+func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, requestModel string, anthropicBetaHeader string, messagesCount int, username string) {
+	mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
+	isStreaming := err == nil && mediaType == "text/event-stream"
+
+	if !isStreaming {
+		bodyBytes, err := io.ReadAll(response.Body)
+		if err != nil {
+			s.logger.Error("read response body: ", err)
+			return
+		}
+
+		var message anthropic.Message
+		var usage anthropic.Usage
+		var responseModel string
+		err = json.Unmarshal(bodyBytes, &message)
+		if err == nil {
+			responseModel = string(message.Model)
+			usage = message.Usage
+		}
+		if responseModel == "" {
+			responseModel = requestModel
+		}
+
+		if usage.InputTokens > 0 || usage.OutputTokens > 0 {
+			if responseModel != "" {
+				contextWindow := detectContextWindow(anthropicBetaHeader, usage.InputTokens)
+				s.usageTracker.AddUsage(
+					responseModel,
+					contextWindow,
+					messagesCount,
+					usage.InputTokens,
+					usage.OutputTokens,
+					usage.CacheReadInputTokens,
+					usage.CacheCreationInputTokens,
+					username,
+				)
+			}
+		}
+
+		_, _ = writer.Write(bodyBytes)
+		return
+	}
+
+	flusher, ok := writer.(http.Flusher)
+	if !ok {
+		s.logger.Error("streaming not supported")
+		return
+	}
+
+	var accumulatedUsage anthropic.Usage
+	var responseModel string
+	buffer := make([]byte, buf.BufferSize)
+	var leftover []byte
+
+	for {
+		n, err := response.Body.Read(buffer)
+		if n > 0 {
+			data := append(leftover, buffer[:n]...)
+			lines := bytes.Split(data, []byte("\n"))
+
+			if err == nil {
+				leftover = lines[len(lines)-1]
+				lines = lines[:len(lines)-1]
+			} else {
+				leftover = nil
+			}
+
+			for _, line := range lines {
+				line = bytes.TrimSpace(line)
+				if len(line) == 0 {
+					continue
+				}
+
+				if bytes.HasPrefix(line, []byte("data: ")) {
+					eventData := bytes.TrimPrefix(line, []byte("data: "))
+					if bytes.Equal(eventData, []byte("[DONE]")) {
+						continue
+					}
+
+					var event anthropic.MessageStreamEventUnion
+					err := json.Unmarshal(eventData, &event)
+					if err != nil {
+						continue
+					}
+					switch event.Type {
+					case "message_start":
+						messageStart := event.AsMessageStart()
+						if messageStart.Message.Model != "" {
+							responseModel = string(messageStart.Message.Model)
+						}
+						if messageStart.Message.Usage.InputTokens > 0 {
+							accumulatedUsage.InputTokens = messageStart.Message.Usage.InputTokens
+							accumulatedUsage.CacheReadInputTokens = messageStart.Message.Usage.CacheReadInputTokens
+							accumulatedUsage.CacheCreationInputTokens = messageStart.Message.Usage.CacheCreationInputTokens
+						}
+					case "message_delta":
+						messageDelta := event.AsMessageDelta()
+						if messageDelta.Usage.OutputTokens > 0 {
+							accumulatedUsage.OutputTokens = messageDelta.Usage.OutputTokens
+						}
+					}
+				}
+			}
+
+			_, writeError := writer.Write(buffer[:n])
+			if writeError != nil {
+				s.logger.Error("write streaming response: ", writeError)
+				return
+			}
+			flusher.Flush()
+		}
+
+		if err != nil {
+			if responseModel == "" {
+				responseModel = requestModel
+			}
+
+			if accumulatedUsage.InputTokens > 0 || accumulatedUsage.OutputTokens > 0 {
+				if responseModel != "" {
+					contextWindow := detectContextWindow(anthropicBetaHeader, accumulatedUsage.InputTokens)
+					s.usageTracker.AddUsage(
+						responseModel,
+						contextWindow,
+						messagesCount,
+						accumulatedUsage.InputTokens,
+						accumulatedUsage.OutputTokens,
+						accumulatedUsage.CacheReadInputTokens,
+						accumulatedUsage.CacheCreationInputTokens,
+						username,
+					)
+				}
+			}
+			return
+		}
+	}
+}
+
+func (s *Service) Close() error {
+	err := common.Close(
+		common.PtrOrNil(s.httpServer),
+		common.PtrOrNil(s.listener),
+		s.tlsConfig,
+	)
+
+	if s.usageTracker != nil {
+		s.usageTracker.cancelPendingSave()
+		saveErr := s.usageTracker.Save()
+		if saveErr != nil {
+			s.logger.Error("save usage statistics: ", saveErr)
+		}
+	}
+
+	return err
+}

+ 407 - 0
service/ccm/service_usage.go

@@ -0,0 +1,407 @@
+package ccm
+
+import (
+	"encoding/json"
+	"math"
+	"os"
+	"regexp"
+	"sync"
+	"time"
+
+	"github.com/sagernet/sing-box/log"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+type UsageStats struct {
+	RequestCount             int   `json:"request_count"`
+	MessagesCount            int   `json:"messages_count"`
+	InputTokens              int64 `json:"input_tokens"`
+	OutputTokens             int64 `json:"output_tokens"`
+	CacheReadInputTokens     int64 `json:"cache_read_input_tokens"`
+	CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
+}
+
+type CostCombination struct {
+	Model         string                `json:"model"`
+	ContextWindow int                   `json:"context_window"`
+	Total         UsageStats            `json:"total"`
+	ByUser        map[string]UsageStats `json:"by_user"`
+}
+
+type AggregatedUsage struct {
+	LastUpdated  time.Time         `json:"last_updated"`
+	Combinations []CostCombination `json:"combinations"`
+	mutex        sync.Mutex
+	filePath     string
+	logger       log.ContextLogger
+	lastSaveTime time.Time
+	pendingSave  bool
+	saveTimer    *time.Timer
+	saveMutex    sync.Mutex
+}
+
+type UsageStatsJSON struct {
+	RequestCount             int     `json:"request_count"`
+	MessagesCount            int     `json:"messages_count"`
+	InputTokens              int64   `json:"input_tokens"`
+	OutputTokens             int64   `json:"output_tokens"`
+	CacheReadInputTokens     int64   `json:"cache_read_input_tokens"`
+	CacheCreationInputTokens int64   `json:"cache_creation_input_tokens"`
+	CostUSD                  float64 `json:"cost_usd"`
+}
+
+type CostCombinationJSON struct {
+	Model         string                    `json:"model"`
+	ContextWindow int                       `json:"context_window"`
+	Total         UsageStatsJSON            `json:"total"`
+	ByUser        map[string]UsageStatsJSON `json:"by_user"`
+}
+
+type CostsSummaryJSON struct {
+	TotalUSD float64            `json:"total_usd"`
+	ByUser   map[string]float64 `json:"by_user"`
+}
+
+type AggregatedUsageJSON struct {
+	LastUpdated  time.Time             `json:"last_updated"`
+	Costs        CostsSummaryJSON      `json:"costs"`
+	Combinations []CostCombinationJSON `json:"combinations"`
+}
+
+type ModelPricing struct {
+	InputPrice      float64
+	OutputPrice     float64
+	CacheReadPrice  float64
+	CacheWritePrice float64
+}
+
+type modelFamily struct {
+	pattern         *regexp.Regexp
+	standardPricing ModelPricing
+	premiumPricing  *ModelPricing
+}
+
+var (
+	opus4Pricing = ModelPricing{
+		InputPrice:      15.0,
+		OutputPrice:     75.0,
+		CacheReadPrice:  1.5,
+		CacheWritePrice: 18.75,
+	}
+
+	sonnet4StandardPricing = ModelPricing{
+		InputPrice:      3.0,
+		OutputPrice:     15.0,
+		CacheReadPrice:  0.3,
+		CacheWritePrice: 3.75,
+	}
+
+	sonnet4PremiumPricing = ModelPricing{
+		InputPrice:      6.0,
+		OutputPrice:     22.5,
+		CacheReadPrice:  0.6,
+		CacheWritePrice: 7.5,
+	}
+
+	haiku4Pricing = ModelPricing{
+		InputPrice:      1.0,
+		OutputPrice:     5.0,
+		CacheReadPrice:  0.1,
+		CacheWritePrice: 1.25,
+	}
+
+	haiku35Pricing = ModelPricing{
+		InputPrice:      0.8,
+		OutputPrice:     4.0,
+		CacheReadPrice:  0.08,
+		CacheWritePrice: 1.0,
+	}
+
+	sonnet35Pricing = ModelPricing{
+		InputPrice:      3.0,
+		OutputPrice:     15.0,
+		CacheReadPrice:  0.3,
+		CacheWritePrice: 3.75,
+	}
+
+	modelFamilies = []modelFamily{
+		{
+			pattern:         regexp.MustCompile(`^claude-(?:opus-4-|4-opus-|opus-4-1-)`),
+			standardPricing: opus4Pricing,
+			premiumPricing:  nil,
+		},
+		{
+			pattern:         regexp.MustCompile(`^claude-3-7-sonnet-`),
+			standardPricing: sonnet4StandardPricing,
+			premiumPricing:  &sonnet4PremiumPricing,
+		},
+		{
+			pattern:         regexp.MustCompile(`^claude-(?:sonnet-4-|4-sonnet-)`),
+			standardPricing: sonnet4StandardPricing,
+			premiumPricing:  &sonnet4PremiumPricing,
+		},
+		{
+			pattern:         regexp.MustCompile(`^claude-haiku-4-`),
+			standardPricing: haiku4Pricing,
+			premiumPricing:  nil,
+		},
+		{
+			pattern:         regexp.MustCompile(`^claude-3-5-haiku-`),
+			standardPricing: haiku35Pricing,
+			premiumPricing:  nil,
+		},
+		{
+			pattern:         regexp.MustCompile(`^claude-3-5-sonnet-`),
+			standardPricing: sonnet35Pricing,
+			premiumPricing:  nil,
+		},
+	}
+)
+
+func getPricing(model string, contextWindow int) ModelPricing {
+	isPremium := contextWindow >= contextWindowPremium
+
+	for _, family := range modelFamilies {
+		if family.pattern.MatchString(model) {
+			if isPremium && family.premiumPricing != nil {
+				return *family.premiumPricing
+			}
+			return family.standardPricing
+		}
+	}
+
+	return sonnet4StandardPricing
+}
+
+func calculateCost(stats UsageStats, model string, contextWindow int) float64 {
+	pricing := getPricing(model, contextWindow)
+
+	cost := (float64(stats.InputTokens)*pricing.InputPrice +
+		float64(stats.OutputTokens)*pricing.OutputPrice +
+		float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice +
+		float64(stats.CacheCreationInputTokens)*pricing.CacheWritePrice) / 1_000_000
+
+	return math.Round(cost*100) / 100
+}
+
+func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	result := &AggregatedUsageJSON{
+		LastUpdated:  u.LastUpdated,
+		Combinations: make([]CostCombinationJSON, len(u.Combinations)),
+		Costs: CostsSummaryJSON{
+			TotalUSD: 0,
+			ByUser:   make(map[string]float64),
+		},
+	}
+
+	for i, combo := range u.Combinations {
+		totalCost := calculateCost(combo.Total, combo.Model, combo.ContextWindow)
+
+		result.Costs.TotalUSD += totalCost
+
+		comboJSON := CostCombinationJSON{
+			Model:         combo.Model,
+			ContextWindow: combo.ContextWindow,
+			Total: UsageStatsJSON{
+				RequestCount:             combo.Total.RequestCount,
+				MessagesCount:            combo.Total.MessagesCount,
+				InputTokens:              combo.Total.InputTokens,
+				OutputTokens:             combo.Total.OutputTokens,
+				CacheReadInputTokens:     combo.Total.CacheReadInputTokens,
+				CacheCreationInputTokens: combo.Total.CacheCreationInputTokens,
+				CostUSD:                  totalCost,
+			},
+			ByUser: make(map[string]UsageStatsJSON),
+		}
+
+		for user, userStats := range combo.ByUser {
+			userCost := calculateCost(userStats, combo.Model, combo.ContextWindow)
+			result.Costs.ByUser[user] += userCost
+
+			comboJSON.ByUser[user] = UsageStatsJSON{
+				RequestCount:             userStats.RequestCount,
+				MessagesCount:            userStats.MessagesCount,
+				InputTokens:              userStats.InputTokens,
+				OutputTokens:             userStats.OutputTokens,
+				CacheReadInputTokens:     userStats.CacheReadInputTokens,
+				CacheCreationInputTokens: userStats.CacheCreationInputTokens,
+				CostUSD:                  userCost,
+			}
+		}
+
+		result.Combinations[i] = comboJSON
+	}
+
+	result.Costs.TotalUSD = math.Round(result.Costs.TotalUSD*100) / 100
+	for user, cost := range result.Costs.ByUser {
+		result.Costs.ByUser[user] = math.Round(cost*100) / 100
+	}
+
+	return result
+}
+
+func (u *AggregatedUsage) Load() error {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	data, err := os.ReadFile(u.filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+
+	var temp struct {
+		LastUpdated  time.Time         `json:"last_updated"`
+		Combinations []CostCombination `json:"combinations"`
+	}
+
+	err = json.Unmarshal(data, &temp)
+	if err != nil {
+		return err
+	}
+
+	u.LastUpdated = temp.LastUpdated
+	u.Combinations = temp.Combinations
+
+	for i := range u.Combinations {
+		if u.Combinations[i].ByUser == nil {
+			u.Combinations[i].ByUser = make(map[string]UsageStats)
+		}
+	}
+
+	return nil
+}
+
+func (u *AggregatedUsage) Save() error {
+	jsonData := u.ToJSON()
+
+	data, err := json.MarshalIndent(jsonData, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	tmpFile := u.filePath + ".tmp"
+	err = os.WriteFile(tmpFile, data, 0o644)
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tmpFile)
+	err = os.Rename(tmpFile, u.filePath)
+	if err == nil {
+		u.saveMutex.Lock()
+		u.lastSaveTime = time.Now()
+		u.saveMutex.Unlock()
+	}
+	return err
+}
+
+func (u *AggregatedUsage) AddUsage(model string, contextWindow int, messagesCount int, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64, user string) error {
+	if model == "" {
+		return E.New("model cannot be empty")
+	}
+	if contextWindow <= 0 {
+		return E.New("contextWindow must be positive")
+	}
+
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	u.LastUpdated = time.Now()
+
+	// Find or create combination
+	var combo *CostCombination
+	for i := range u.Combinations {
+		if u.Combinations[i].Model == model && u.Combinations[i].ContextWindow == contextWindow {
+			combo = &u.Combinations[i]
+			break
+		}
+	}
+
+	if combo == nil {
+		newCombo := CostCombination{
+			Model:         model,
+			ContextWindow: contextWindow,
+			Total:         UsageStats{},
+			ByUser:        make(map[string]UsageStats),
+		}
+		u.Combinations = append(u.Combinations, newCombo)
+		combo = &u.Combinations[len(u.Combinations)-1]
+	}
+
+	// Update total stats
+	combo.Total.RequestCount++
+	combo.Total.MessagesCount += messagesCount
+	combo.Total.InputTokens += inputTokens
+	combo.Total.OutputTokens += outputTokens
+	combo.Total.CacheReadInputTokens += cacheReadTokens
+	combo.Total.CacheCreationInputTokens += cacheCreationTokens
+
+	// Update per-user stats if user is specified
+	if user != "" {
+		userStats := combo.ByUser[user]
+		userStats.RequestCount++
+		userStats.MessagesCount += messagesCount
+		userStats.InputTokens += inputTokens
+		userStats.OutputTokens += outputTokens
+		userStats.CacheReadInputTokens += cacheReadTokens
+		userStats.CacheCreationInputTokens += cacheCreationTokens
+		combo.ByUser[user] = userStats
+	}
+
+	go u.scheduleSave()
+
+	return nil
+}
+
+func (u *AggregatedUsage) scheduleSave() {
+	const saveInterval = time.Minute
+
+	u.saveMutex.Lock()
+	defer u.saveMutex.Unlock()
+
+	timeSinceLastSave := time.Since(u.lastSaveTime)
+
+	if timeSinceLastSave >= saveInterval {
+		go u.saveAsync()
+		return
+	}
+
+	if u.pendingSave {
+		return
+	}
+
+	u.pendingSave = true
+	remainingTime := saveInterval - timeSinceLastSave
+
+	u.saveTimer = time.AfterFunc(remainingTime, func() {
+		u.saveMutex.Lock()
+		u.pendingSave = false
+		u.saveMutex.Unlock()
+		u.saveAsync()
+	})
+}
+
+func (u *AggregatedUsage) saveAsync() {
+	err := u.Save()
+	if err != nil {
+		if u.logger != nil {
+			u.logger.Error("save usage statistics: ", err)
+		}
+	}
+}
+
+func (u *AggregatedUsage) cancelPendingSave() {
+	u.saveMutex.Lock()
+	defer u.saveMutex.Unlock()
+
+	if u.saveTimer != nil {
+		u.saveTimer.Stop()
+		u.saveTimer = nil
+	}
+	u.pendingSave = false
+}

+ 29 - 0
service/ccm/service_user.go

@@ -0,0 +1,29 @@
+package ccm
+
+import (
+	"sync"
+
+	"github.com/sagernet/sing-box/option"
+)
+
+type UserManager struct {
+	accessMutex sync.RWMutex
+	tokenMap    map[string]string
+}
+
+func (m *UserManager) UpdateUsers(users []option.CCMUser) {
+	m.accessMutex.Lock()
+	defer m.accessMutex.Unlock()
+	tokenMap := make(map[string]string, len(users))
+	for _, user := range users {
+		tokenMap[user.Token] = user.Name
+	}
+	m.tokenMap = tokenMap
+}
+
+func (m *UserManager) Authenticate(token string) (string, bool) {
+	m.accessMutex.RLock()
+	username, found := m.tokenMap[token]
+	m.accessMutex.RUnlock()
+	return username, found
+}