mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-30 01:06:39 +00:00
Compare commits
383 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d1bc06ea | ||
|
|
d5930f4e44 | ||
|
|
9b7d7021af | ||
|
|
e41c22ef44 | ||
|
|
55271403fb | ||
|
|
36fba66619 | ||
|
|
b9b127a7ea | ||
|
|
2741e7b7b3 | ||
|
|
1767a56d4f | ||
|
|
779e6c2d2f | ||
|
|
73c831747b | ||
|
|
b8b89f34f4 | ||
|
|
1fa094dac6 | ||
|
|
f55754621f | ||
|
|
ac26e7db43 | ||
|
|
10b824fcac | ||
|
|
e5d3541b5a | ||
|
|
79755e76ea | ||
|
|
35f158d526 | ||
|
|
6962e09dd9 | ||
|
|
4c4cbd44da | ||
|
|
26eca8b6ba | ||
|
|
62b17f40a1 | ||
|
|
511b8a992e | ||
|
|
7dccc7ba2f | ||
|
|
70c90687fd | ||
|
|
8144ffd5c8 | ||
|
|
0ab977c236 | ||
|
|
224f0de353 | ||
|
|
6b45d311ec | ||
|
|
d54de441d3 | ||
|
|
7386a70724 | ||
|
|
1821bf7051 | ||
|
|
d42b5d4e78 | ||
|
|
1b7447b682 | ||
|
|
40dee4453a | ||
|
|
8902e1cccb | ||
|
|
de5fe71478 | ||
|
|
dcfbec2990 | ||
|
|
c95620f90e | ||
|
|
754f3bcbc3 | ||
|
|
36973d4a6f | ||
|
|
9613f0b3f9 | ||
|
|
274f29e26b | ||
|
|
c8e79c3787 | ||
|
|
8afef43887 | ||
|
|
c1083cbfc6 | ||
|
|
c89d19b300 | ||
|
|
1e6bc81cfd | ||
|
|
1a149475e0 | ||
|
|
e5166841db | ||
|
|
19c52bcb60 | ||
|
|
bb9b2d1758 | ||
|
|
7fa527193c | ||
|
|
ed0eb51b4d | ||
|
|
0e4f669c8b | ||
|
|
76c064c729 | ||
|
|
d2f652f436 | ||
|
|
6a452a54d5 | ||
|
|
9e5693e74f | ||
|
|
528b1a2307 | ||
|
|
0cc978ec1d | ||
|
|
d312422ab4 | ||
|
|
fee736933b | ||
|
|
09c92aa0b5 | ||
|
|
8c67b3ae64 | ||
|
|
000e4ceb4e | ||
|
|
5c99846ecf | ||
|
|
cc32f5ff61 | ||
|
|
fbff68b9e0 | ||
|
|
7e1a543b79 | ||
|
|
d475aaba96 | ||
|
|
1dc4ecb1b8 | ||
|
|
1315f710f5 | ||
|
|
96f55570f7 | ||
|
|
0906aeca87 | ||
|
|
7333619f15 | ||
|
|
97c0487add | ||
|
|
74b862d8b8 | ||
|
|
2db8df8e38 | ||
|
|
a576088d5f | ||
|
|
66ff916838 | ||
|
|
7b0453074e | ||
|
|
a000eb523d | ||
|
|
18a4fedc7f | ||
|
|
5d6cdccda0 | ||
|
|
1b7f4ac3e1 | ||
|
|
afc1a5b814 | ||
|
|
7ed38db54f | ||
|
|
28c10f4e69 | ||
|
|
6e12441a3b | ||
|
|
65c439c18d | ||
|
|
0ed2d16596 | ||
|
|
db335ac616 | ||
|
|
f3c59165d7 | ||
|
|
e6690cb447 | ||
|
|
35907416b8 | ||
|
|
e8bb350467 | ||
|
|
5331d51f27 | ||
|
|
755ca75879 | ||
|
|
2398ebad55 | ||
|
|
c1bf298216 | ||
|
|
e005208d76 | ||
|
|
d1df70d02f | ||
|
|
f81acd0760 | ||
|
|
636da4c932 | ||
|
|
cccb77b552 | ||
|
|
2bd646ad70 | ||
|
|
52c1fa025e | ||
|
|
680105f84d | ||
|
|
f7069e9548 | ||
|
|
7275e99b41 | ||
|
|
c28b65f849 | ||
|
|
793840cdb4 | ||
|
|
8f421de532 | ||
|
|
be2dd60ee7 | ||
|
|
ea3e0b713e | ||
|
|
8179d5a8a4 | ||
|
|
6fa7abe434 | ||
|
|
5135c22cd6 | ||
|
|
1e27990561 | ||
|
|
e1e9fc43c1 | ||
|
|
b2921518ac | ||
|
|
dd64adbeeb | ||
|
|
616d41c06a | ||
|
|
e0e337aeb9 | ||
|
|
d52839fced | ||
|
|
4022e69651 | ||
|
|
56073ded69 | ||
|
|
9738a53f49 | ||
|
|
be3f8dbf7e | ||
|
|
9c6c3612a8 | ||
|
|
19e1a4447a | ||
|
|
7c2ad4cda2 | ||
|
|
fb95813fbf | ||
|
|
db63f9b5d6 | ||
|
|
25f6c4a250 | ||
|
|
b24ae74216 | ||
|
|
59ad8f40dc | ||
|
|
ff03dc6a2c | ||
|
|
dc7187ca5b | ||
|
|
b1dcff778c | ||
|
|
cef2aeeb08 | ||
|
|
bcd1e8cc34 | ||
|
|
198b3f4a40 | ||
|
|
9fee7f488e | ||
|
|
1b46d39b8b | ||
|
|
c1241a98e2 | ||
|
|
8d8f5970ee | ||
|
|
f90120f846 | ||
|
|
0b94d36c4a | ||
|
|
152c310bb7 | ||
|
|
f6bbca35ab | ||
|
|
c8cee6a209 | ||
|
|
b5701f416b | ||
|
|
4b1a404fcb | ||
|
|
b93cce5412 | ||
|
|
c6cb24039d | ||
|
|
5382408489 | ||
|
|
67669196ed | ||
|
|
5c817a9b42 | ||
|
|
58fd9bf964 | ||
|
|
7b3dfc67bc | ||
|
|
cdd24052d3 | ||
|
|
5da0decef6 | ||
|
|
733fd8edab | ||
|
|
af27f2b8bc | ||
|
|
2e1925d762 | ||
|
|
77254bd074 | ||
|
|
5b6342e6ac | ||
|
|
3960c93d51 | ||
|
|
339a81b650 | ||
|
|
560c020477 | ||
|
|
aec65e3be3 | ||
|
|
f44f0702f8 | ||
|
|
b76b79068f | ||
|
|
34c8ccb961 | ||
|
|
d08e164af3 | ||
|
|
8178efaeda | ||
|
|
86d5db472a | ||
|
|
020d36f6e8 | ||
|
|
1db23979e8 | ||
|
|
c3d5dbe96f | ||
|
|
5484489406 | ||
|
|
0ac52da460 | ||
|
|
817cebb321 | ||
|
|
683f3709d6 | ||
|
|
dbd42a42b2 | ||
|
|
ec24baf757 | ||
|
|
dea3e74d35 | ||
|
|
a6c3042e34 | ||
|
|
861537c9bd | ||
|
|
8c92cb0883 | ||
|
|
89d7be9525 | ||
|
|
2b79d7f22f | ||
|
|
2bb686f594 | ||
|
|
163fe287ce | ||
|
|
70988d387b | ||
|
|
52058a1659 | ||
|
|
df5595a0c9 | ||
|
|
ddaa9d2436 | ||
|
|
7b7b258c38 | ||
|
|
a00f774f5a | ||
|
|
9daf1ba8b5 | ||
|
|
76f2359637 | ||
|
|
dcb1c9be8a | ||
|
|
a24f4ace78 | ||
|
|
c631df8c3b | ||
|
|
54c3eb1b1e | ||
|
|
bb28cd26ad | ||
|
|
046865461e | ||
|
|
cf74ed2f0c | ||
|
|
c3762328a5 | ||
|
|
e333fbea3d | ||
|
|
efbe36d1d4 | ||
|
|
8553cfa40e | ||
|
|
30d5c95b26 | ||
|
|
d1e3195e6f | ||
|
|
05a35662ae | ||
|
|
ce53d3a287 | ||
|
|
4cc99e7449 | ||
|
|
71773fe032 | ||
|
|
a1e0fa0f39 | ||
|
|
fc2f0b6983 | ||
|
|
5c9997cdac | ||
|
|
6f81046730 | ||
|
|
0687472d01 | ||
|
|
7739738fb3 | ||
|
|
99d1ce247b | ||
|
|
f5941a411c | ||
|
|
ba672bbd07 | ||
|
|
d9c6627a53 | ||
|
|
2e9907c3ac | ||
|
|
90afb9cb73 | ||
|
|
d0cc0cd9a5 | ||
|
|
338321e553 | ||
|
|
182b31963a | ||
|
|
4f48e5254a | ||
|
|
15dd5db1d7 | ||
|
|
424711b718 | ||
|
|
91a2b1f0b4 | ||
|
|
2b134fc378 | ||
|
|
b9153719b0 | ||
|
|
631e5c8331 | ||
|
|
e9c60a0a67 | ||
|
|
98a1bb5a7f | ||
|
|
ca90487a8c | ||
|
|
1042489f85 | ||
|
|
38277c1ea6 | ||
|
|
ee0c24628f | ||
|
|
3a18f6fcca | ||
|
|
099e734a02 | ||
|
|
a52da26b5d | ||
|
|
522a68a4ea | ||
|
|
a02eda54d0 | ||
|
|
97ef633c57 | ||
|
|
dae8463ba1 | ||
|
|
7c1299922e | ||
|
|
ddcf1f279d | ||
|
|
7e6bb8fdc5 | ||
|
|
9cee8ef87b | ||
|
|
93fb841bcb | ||
|
|
0c05131aeb | ||
|
|
5ebc58fab4 | ||
|
|
2b609dd891 | ||
|
|
a8cbc68c3e | ||
|
|
11a795a01c | ||
|
|
89c428216e | ||
|
|
2695a99623 | ||
|
|
242aecd924 | ||
|
|
ce8cc1ba33 | ||
|
|
ad5253bd2b | ||
|
|
97fdd2e088 | ||
|
|
9397f7049f | ||
|
|
a14d19b92c | ||
|
|
8ae0c05ea6 | ||
|
|
8822f20d17 | ||
|
|
553d6f50ea | ||
|
|
f0e5a5a367 | ||
|
|
f6dfea9357 | ||
|
|
cc8dc7f62c | ||
|
|
a3846ea513 | ||
|
|
8d44be858e | ||
|
|
0e6bb076e9 | ||
|
|
ac135fc7cb | ||
|
|
4e1d09809d | ||
|
|
9e855f8100 | ||
|
|
25680a8259 | ||
|
|
13c93e8cfd | ||
|
|
88aa1b9fd1 | ||
|
|
352cb98ff0 | ||
|
|
ac95e92829 | ||
|
|
8526c2da25 | ||
|
|
68a6cabf8b | ||
|
|
ac0e387da1 | ||
|
|
7fe1d102cb | ||
|
|
5850492a93 | ||
|
|
fdbd4041ca | ||
|
|
ebef1fae2a | ||
|
|
c51851689b | ||
|
|
419bf784ab | ||
|
|
4bbeb92e9a | ||
|
|
b436dad8bc | ||
|
|
6ae15d6c44 | ||
|
|
0468bde0d6 | ||
|
|
1d7329e797 | ||
|
|
48ffc4dee7 | ||
|
|
7ebd8f0c44 | ||
|
|
b680c146c1 | ||
|
|
7d6660d181 | ||
|
|
d8e3d4e2b6 | ||
|
|
d26ad8224d | ||
|
|
5c84d69d42 | ||
|
|
527e4b7f26 | ||
|
|
b48485b42b | ||
|
|
79009bb3d4 | ||
|
|
26fc611f86 | ||
|
|
b43743d4f1 | ||
|
|
179e5434b1 | ||
|
|
9f95b31158 | ||
|
|
5da07eae4c | ||
|
|
835ae178d4 | ||
|
|
c80ab8bf0d | ||
|
|
ce87714ef1 | ||
|
|
0452b869e8 | ||
|
|
d2e5857b82 | ||
|
|
f9b005f21f | ||
|
|
532107b4fa | ||
|
|
c44793789b | ||
|
|
4e99525279 | ||
|
|
7547d1d0b3 | ||
|
|
68934942d0 | ||
|
|
09fec34e1c | ||
|
|
9229708b6c | ||
|
|
914db94e79 | ||
|
|
660bd7eff5 | ||
|
|
b907d21851 | ||
|
|
dd44413ba5 | ||
|
|
10fa0f2062 | ||
|
|
d6cc976d1f | ||
|
|
8aa2cce8c5 | ||
|
|
bf9b2c49df | ||
|
|
77b42c6165 | ||
|
|
446150a747 | ||
|
|
1cbc4834e1 | ||
|
|
30338ecec4 | ||
|
|
9a37defed3 | ||
|
|
c83a057996 | ||
|
|
a8a5d03c33 | ||
|
|
76aa917882 | ||
|
|
6ac9b31e4e | ||
|
|
0ad3e8457f | ||
|
|
444a47ae63 | ||
|
|
725f4fdff4 | ||
|
|
c23e46f45d | ||
|
|
b148820c35 | ||
|
|
134f41496d | ||
|
|
c5838dd58d | ||
|
|
b6ca5ef7ce | ||
|
|
1ae994b4aa | ||
|
|
84e9793e61 | ||
|
|
32e64dacfd | ||
|
|
cc1d8f6629 | ||
|
|
5446cd2b02 | ||
|
|
8de0885b7d | ||
|
|
68dd2bfe82 | ||
|
|
2baf35b3ef | ||
|
|
846e75b893 | ||
|
|
fc0257d6d9 | ||
|
|
f3c164d345 | ||
|
|
4040b1e766 | ||
|
|
b7588428c5 | ||
|
|
8f97a5f77c | ||
|
|
2a4d3e60f3 | ||
|
|
8b5af2ab84 | ||
|
|
d887716ebd | ||
|
|
5dc1848466 | ||
|
|
9491517b26 | ||
|
|
9370b5bd04 | ||
|
|
abb51a0d93 | ||
|
|
c8d809131b | ||
|
|
dd71c73a9f | ||
|
|
2615f489d6 |
@@ -31,6 +31,7 @@ bin/*
|
|||||||
.agent/*
|
.agent/*
|
||||||
.agents/*
|
.agents/*
|
||||||
.opencode/*
|
.opencode/*
|
||||||
|
.idea/*
|
||||||
.bmad/*
|
.bmad/*
|
||||||
_bmad/*
|
_bmad/*
|
||||||
_bmad-output/*
|
_bmad-output/*
|
||||||
|
|||||||
14
.github/workflows/docker-image.yml
vendored
14
.github/workflows/docker-image.yml
vendored
@@ -16,6 +16,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Refresh models catalog
|
||||||
|
run: |
|
||||||
|
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||||
|
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
@@ -25,7 +29,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Generate Build Metadata
|
- name: Generate Build Metadata
|
||||||
run: |
|
run: |
|
||||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
- name: Build and push (amd64)
|
- name: Build and push (amd64)
|
||||||
@@ -47,6 +51,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Refresh models catalog
|
||||||
|
run: |
|
||||||
|
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||||
|
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
@@ -56,7 +64,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Generate Build Metadata
|
- name: Generate Build Metadata
|
||||||
run: |
|
run: |
|
||||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
- name: Build and push (arm64)
|
- name: Build and push (arm64)
|
||||||
@@ -90,7 +98,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Generate Build Metadata
|
- name: Generate Build Metadata
|
||||||
run: |
|
run: |
|
||||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
- name: Create and push multi-arch manifests
|
- name: Create and push multi-arch manifests
|
||||||
|
|||||||
4
.github/workflows/pr-test-build.yml
vendored
4
.github/workflows/pr-test-build.yml
vendored
@@ -12,6 +12,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Refresh models catalog
|
||||||
|
run: |
|
||||||
|
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||||
|
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
9
.github/workflows/release.yaml
vendored
9
.github/workflows/release.yaml
vendored
@@ -16,6 +16,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Refresh models catalog
|
||||||
|
run: |
|
||||||
|
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||||
|
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||||
- run: git fetch --force --tags
|
- run: git fetch --force --tags
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
@@ -23,15 +27,14 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
- name: Generate Build Metadata
|
- name: Generate Build Metadata
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(git describe --tags --always --dirty)
|
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
|
||||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||||
- uses: goreleaser/goreleaser-action@v4
|
- uses: goreleaser/goreleaser-action@v4
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean --skip=validate
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
VERSION: ${{ env.VERSION }}
|
VERSION: ${{ env.VERSION }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
cli-proxy-api
|
cli-proxy-api
|
||||||
cliproxy
|
cliproxy
|
||||||
|
/server
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ GEMINI.md
|
|||||||
.agents/*
|
.agents/*
|
||||||
.agents/*
|
.agents/*
|
||||||
.opencode/*
|
.opencode/*
|
||||||
|
.idea/*
|
||||||
.bmad/*
|
.bmad/*
|
||||||
_bmad/*
|
_bmad/*
|
||||||
_bmad-output/*
|
_bmad-output/*
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: "cli-proxy-api-plus"
|
- id: "cli-proxy-api-plus"
|
||||||
env:
|
env:
|
||||||
@@ -6,6 +8,7 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
|
- freebsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|||||||
126
README.md
126
README.md
@@ -8,132 +8,6 @@ All third-party provider support is maintained by community contributors; CLIPro
|
|||||||
|
|
||||||
The Plus release stays in lockstep with the mainline features.
|
The Plus release stays in lockstep with the mainline features.
|
||||||
|
|
||||||
## Differences from the Mainline
|
|
||||||
|
|
||||||
- Added GitHub Copilot support (OAuth login), provided by [em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)
|
|
||||||
- Added Kiro (AWS CodeWhisperer) support (OAuth login), provided by [fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration), [Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)
|
|
||||||
|
|
||||||
## New Features (Plus Enhanced)
|
|
||||||
|
|
||||||
- **OAuth Web Authentication**: Browser-based OAuth login for Kiro with beautiful web UI
|
|
||||||
- **Rate Limiter**: Built-in request rate limiting to prevent API abuse
|
|
||||||
- **Background Token Refresh**: Automatic token refresh 10 minutes before expiration
|
|
||||||
- **Metrics & Monitoring**: Request metrics collection for monitoring and debugging
|
|
||||||
- **Device Fingerprint**: Device fingerprint generation for enhanced security
|
|
||||||
- **Cooldown Management**: Smart cooldown mechanism for API rate limits
|
|
||||||
- **Usage Checker**: Real-time usage monitoring and quota management
|
|
||||||
- **Model Converter**: Unified model name conversion across providers
|
|
||||||
- **UTF-8 Stream Processing**: Improved streaming response handling
|
|
||||||
|
|
||||||
## Kiro Authentication
|
|
||||||
|
|
||||||
### CLI Login
|
|
||||||
|
|
||||||
> **Note:** Google/GitHub login is not available for third-party applications due to AWS Cognito restrictions.
|
|
||||||
|
|
||||||
**AWS Builder ID** (recommended):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Device code flow
|
|
||||||
./CLIProxyAPI --kiro-aws-login
|
|
||||||
|
|
||||||
# Authorization code flow
|
|
||||||
./CLIProxyAPI --kiro-aws-authcode
|
|
||||||
```
|
|
||||||
|
|
||||||
**Import token from Kiro IDE:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./CLIProxyAPI --kiro-import
|
|
||||||
```
|
|
||||||
|
|
||||||
To get a token from Kiro IDE:
|
|
||||||
|
|
||||||
1. Open Kiro IDE and login with Google (or GitHub)
|
|
||||||
2. Find the token file: `~/.kiro/kiro-auth-token.json`
|
|
||||||
3. Run: `./CLIProxyAPI --kiro-import`
|
|
||||||
|
|
||||||
**AWS IAM Identity Center (IDC):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./CLIProxyAPI --kiro-idc-login --kiro-idc-start-url https://d-xxxxxxxxxx.awsapps.com/start
|
|
||||||
|
|
||||||
# Specify region
|
|
||||||
./CLIProxyAPI --kiro-idc-login --kiro-idc-start-url https://d-xxxxxxxxxx.awsapps.com/start --kiro-idc-region us-west-2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Additional flags:**
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `--no-browser` | Don't open browser automatically, print URL instead |
|
|
||||||
| `--no-incognito` | Use existing browser session (Kiro defaults to incognito). Useful for corporate SSO that requires an authenticated browser session |
|
|
||||||
| `--kiro-idc-start-url` | IDC Start URL (required with `--kiro-idc-login`) |
|
|
||||||
| `--kiro-idc-region` | IDC region (default: `us-east-1`) |
|
|
||||||
| `--kiro-idc-flow` | IDC flow type: `authcode` (default) or `device` |
|
|
||||||
|
|
||||||
### Web-based OAuth Login
|
|
||||||
|
|
||||||
Access the Kiro OAuth web interface at:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://your-server:8080/v0/oauth/kiro
|
|
||||||
```
|
|
||||||
|
|
||||||
This provides a browser-based OAuth flow for Kiro (AWS CodeWhisperer) authentication with:
|
|
||||||
- AWS Builder ID login
|
|
||||||
- AWS Identity Center (IDC) login
|
|
||||||
- Token import from Kiro IDE
|
|
||||||
|
|
||||||
## Quick Deployment with Docker
|
|
||||||
|
|
||||||
### One-Command Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create deployment directory
|
|
||||||
mkdir -p ~/cli-proxy && cd ~/cli-proxy
|
|
||||||
|
|
||||||
# Create docker-compose.yml
|
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
services:
|
|
||||||
cli-proxy-api:
|
|
||||||
image: eceasy/cli-proxy-api-plus:latest
|
|
||||||
container_name: cli-proxy-api-plus
|
|
||||||
ports:
|
|
||||||
- "8317:8317"
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
|
||||||
- ./auths:/root/.cli-proxy-api
|
|
||||||
- ./logs:/CLIProxyAPI/logs
|
|
||||||
restart: unless-stopped
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Download example config
|
|
||||||
curl -o config.yaml https://raw.githubusercontent.com/router-for-me/CLIProxyAPIPlus/main/config.example.yaml
|
|
||||||
|
|
||||||
# Pull and start
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Edit `config.yaml` before starting:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Basic configuration example
|
|
||||||
server:
|
|
||||||
port: 8317
|
|
||||||
|
|
||||||
# Add your provider configurations here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update to Latest Version
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/cli-proxy
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
|
This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
|
||||||
|
|||||||
130
README_CN.md
130
README_CN.md
@@ -1,139 +1,11 @@
|
|||||||
# CLIProxyAPI Plus
|
# CLIProxyAPI Plus
|
||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||||
|
|
||||||
这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。
|
这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。
|
||||||
|
|
||||||
所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
|
所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
|
||||||
|
|
||||||
该 Plus 版本的主线功能与主线功能强制同步。
|
|
||||||
|
|
||||||
## 与主线版本版本差异
|
|
||||||
|
|
||||||
- 新增 GitHub Copilot 支持(OAuth 登录),由[em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
|
|
||||||
- 新增 Kiro (AWS CodeWhisperer) 支持 (OAuth 登录), 由[fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration)、[Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)提供
|
|
||||||
|
|
||||||
## 新增功能 (Plus 增强版)
|
|
||||||
|
|
||||||
- **OAuth Web 认证**: 基于浏览器的 Kiro OAuth 登录,提供美观的 Web UI
|
|
||||||
- **请求限流器**: 内置请求限流,防止 API 滥用
|
|
||||||
- **后台令牌刷新**: 过期前 10 分钟自动刷新令牌
|
|
||||||
- **监控指标**: 请求指标收集,用于监控和调试
|
|
||||||
- **设备指纹**: 设备指纹生成,增强安全性
|
|
||||||
- **冷却管理**: 智能冷却机制,应对 API 速率限制
|
|
||||||
- **用量检查器**: 实时用量监控和配额管理
|
|
||||||
- **模型转换器**: 跨供应商的统一模型名称转换
|
|
||||||
- **UTF-8 流处理**: 改进的流式响应处理
|
|
||||||
|
|
||||||
## Kiro 认证
|
|
||||||
|
|
||||||
### 命令行登录
|
|
||||||
|
|
||||||
> **注意:** 由于 AWS Cognito 限制,Google/GitHub 登录不可用于第三方应用。
|
|
||||||
|
|
||||||
**AWS Builder ID**(推荐):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 设备码流程
|
|
||||||
./CLIProxyAPI --kiro-aws-login
|
|
||||||
|
|
||||||
# 授权码流程
|
|
||||||
./CLIProxyAPI --kiro-aws-authcode
|
|
||||||
```
|
|
||||||
|
|
||||||
**从 Kiro IDE 导入令牌:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./CLIProxyAPI --kiro-import
|
|
||||||
```
|
|
||||||
|
|
||||||
获取令牌步骤:
|
|
||||||
|
|
||||||
1. 打开 Kiro IDE,使用 Google(或 GitHub)登录
|
|
||||||
2. 找到令牌文件:`~/.kiro/kiro-auth-token.json`
|
|
||||||
3. 运行:`./CLIProxyAPI --kiro-import`
|
|
||||||
|
|
||||||
**AWS IAM Identity Center (IDC):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./CLIProxyAPI --kiro-idc-login --kiro-idc-start-url https://d-xxxxxxxxxx.awsapps.com/start
|
|
||||||
|
|
||||||
# 指定区域
|
|
||||||
./CLIProxyAPI --kiro-idc-login --kiro-idc-start-url https://d-xxxxxxxxxx.awsapps.com/start --kiro-idc-region us-west-2
|
|
||||||
```
|
|
||||||
|
|
||||||
**附加参数:**
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `--no-browser` | 不自动打开浏览器,打印 URL |
|
|
||||||
| `--no-incognito` | 使用已有浏览器会话(Kiro 默认使用无痕模式),适用于需要已登录浏览器会话的企业 SSO 场景 |
|
|
||||||
| `--kiro-idc-start-url` | IDC Start URL(`--kiro-idc-login` 必需) |
|
|
||||||
| `--kiro-idc-region` | IDC 区域(默认:`us-east-1`) |
|
|
||||||
| `--kiro-idc-flow` | IDC 流程类型:`authcode`(默认)或 `device` |
|
|
||||||
|
|
||||||
### 网页端 OAuth 登录
|
|
||||||
|
|
||||||
访问 Kiro OAuth 网页认证界面:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://your-server:8080/v0/oauth/kiro
|
|
||||||
```
|
|
||||||
|
|
||||||
提供基于浏览器的 Kiro (AWS CodeWhisperer) OAuth 认证流程,支持:
|
|
||||||
- AWS Builder ID 登录
|
|
||||||
- AWS Identity Center (IDC) 登录
|
|
||||||
- 从 Kiro IDE 导入令牌
|
|
||||||
|
|
||||||
## Docker 快速部署
|
|
||||||
|
|
||||||
### 一键部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建部署目录
|
|
||||||
mkdir -p ~/cli-proxy && cd ~/cli-proxy
|
|
||||||
|
|
||||||
# 创建 docker-compose.yml
|
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
services:
|
|
||||||
cli-proxy-api:
|
|
||||||
image: eceasy/cli-proxy-api-plus:latest
|
|
||||||
container_name: cli-proxy-api-plus
|
|
||||||
ports:
|
|
||||||
- "8317:8317"
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
|
||||||
- ./auths:/root/.cli-proxy-api
|
|
||||||
- ./logs:/CLIProxyAPI/logs
|
|
||||||
restart: unless-stopped
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 下载示例配置
|
|
||||||
curl -o config.yaml https://raw.githubusercontent.com/router-for-me/CLIProxyAPIPlus/main/config.example.yaml
|
|
||||||
|
|
||||||
# 拉取并启动
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置说明
|
|
||||||
|
|
||||||
启动前请编辑 `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# 基本配置示例
|
|
||||||
server:
|
|
||||||
port: 8317
|
|
||||||
|
|
||||||
# 在此添加你的供应商配置
|
|
||||||
```
|
|
||||||
|
|
||||||
### 更新到最新版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/cli-proxy
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
|
该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
|
||||||
|
|||||||
199
README_JA.md
Normal file
199
README_JA.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# CLI Proxy API
|
||||||
|
|
||||||
|
[English](README.md) | [中文](README_CN.md) | 日本語
|
||||||
|
|
||||||
|
CLI向けのOpenAI/Gemini/Claude/Codex互換APIインターフェースを提供するプロキシサーバーです。
|
||||||
|
|
||||||
|
OAuth経由でOpenAI Codex(GPTモデル)およびClaude Codeもサポートしています。
|
||||||
|
|
||||||
|
ローカルまたはマルチアカウントのCLIアクセスを、OpenAI(Responses含む)/Gemini/Claude互換のクライアントやSDKで利用できます。
|
||||||
|
|
||||||
|
## スポンサー
|
||||||
|
|
||||||
|
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
本プロジェクトはZ.aiにスポンサーされており、GLM CODING PLANの提供を受けています。
|
||||||
|
|
||||||
|
GLM CODING PLANはAIコーディング向けに設計されたサブスクリプションサービスで、月額わずか$10から利用可能です。フラッグシップのGLM-4.7および(GLM-5はProユーザーのみ利用可能)モデルを10以上の人気AIコーディングツール(Claude Code、Cline、Roo Codeなど)で利用でき、開発者にトップクラスの高速かつ安定したコーディング体験を提供します。
|
||||||
|
|
||||||
|
GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||||
|
<td>PackyCodeのスポンサーシップに感謝します!PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:<a href="https://www.packyapi.com/register?aff=cliproxyapi">こちらのリンク</a>から登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||||
|
<td>AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">こちらのリンク</a>から登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
|
||||||
|
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを <b>公式サイト価格の約1割(90% OFF)</b> という驚異的な価格でご利用いただけます!</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.lingtrue.com/register"><img src="./assets/lingtrue.png" alt="LingtrueAPI" width="150"></a></td>
|
||||||
|
<td>LingtrueAPIのスポンサーシップに感謝します!LingtrueAPIはグローバルな大規模モデルAPIリレーサービスプラットフォームで、Claude Code、Codex、GeminiなどのトップモデルAPI呼び出しサービスを提供し、ユーザーが低コストかつ高い安定性で世界中のAI能力に接続できるよう支援しています。LingtrueAPIは本ソフトウェアのユーザーに特別割引を提供しています:<a href="https://www.lingtrue.com/register">こちらのリンク</a>から登録し、初回チャージ時にプロモーションコード「LingtrueAPI」を入力すると10%割引になります。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
- CLIモデル向けのOpenAI/Gemini/Claude互換APIエンドポイント
|
||||||
|
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
|
||||||
|
- OAuthログインによるClaude Codeサポート
|
||||||
|
- OAuthログインによるQwen Codeサポート
|
||||||
|
- OAuthログインによるiFlowサポート
|
||||||
|
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
|
||||||
|
- ストリーミングおよび非ストリーミングレスポンス
|
||||||
|
- 関数呼び出し/ツールのサポート
|
||||||
|
- マルチモーダル入力サポート(テキストと画像)
|
||||||
|
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude、QwenおよびiFlow)
|
||||||
|
- シンプルなCLI認証フロー(Gemini、OpenAI、Claude、QwenおよびiFlow)
|
||||||
|
- Generative Language APIキーのサポート
|
||||||
|
- AI Studioビルドのマルチアカウント負荷分散
|
||||||
|
- Gemini CLIのマルチアカウント負荷分散
|
||||||
|
- Claude Codeのマルチアカウント負荷分散
|
||||||
|
- Qwen Codeのマルチアカウント負荷分散
|
||||||
|
- iFlowのマルチアカウント負荷分散
|
||||||
|
- OpenAI Codexのマルチアカウント負荷分散
|
||||||
|
- 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter)
|
||||||
|
- プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照)
|
||||||
|
|
||||||
|
## はじめに
|
||||||
|
|
||||||
|
CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
|
||||||
|
|
||||||
|
## 管理API
|
||||||
|
|
||||||
|
[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
|
||||||
|
|
||||||
|
## Amp CLIサポート
|
||||||
|
|
||||||
|
CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます:
|
||||||
|
|
||||||
|
- Ampの APIパターン用のプロバイダールートエイリアス(`/api/provider/{provider}/v1...`)
|
||||||
|
- OAuth認証およびアカウント機能用の管理プロキシ
|
||||||
|
- 自動ルーティングによるスマートモデルフォールバック
|
||||||
|
- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
|
||||||
|
- localhostのみの管理エンドポイントによるセキュリティファーストの設計
|
||||||
|
|
||||||
|
特定のバックエンド系統のリクエスト/レスポンス形状が必要な場合は、統合された `/v1/...` エンドポイントよりも provider-specific のパスを優先してください。
|
||||||
|
|
||||||
|
- messages 系のバックエンドには `/api/provider/{provider}/v1/messages`
|
||||||
|
- モデル単位の generate 系エンドポイントには `/api/provider/{provider}/v1beta/models/...`
|
||||||
|
- chat-completions 系のバックエンドには `/api/provider/{provider}/v1/chat/completions`
|
||||||
|
|
||||||
|
これらのパスはプロトコル面の選択には役立ちますが、同じクライアント向けモデル名が複数バックエンドで再利用されている場合、それだけで推論実行系が一意に固定されるわけではありません。実際の推論ルーティングは、引き続きリクエスト内の model/alias 解決に従います。厳密にバックエンドを固定したい場合は、一意な alias や prefix を使うか、クライアント向けモデル名の重複自体を避けてください。
|
||||||
|
|
||||||
|
**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/agent-client/amp-cli.html)**
|
||||||
|
|
||||||
|
## SDKドキュメント
|
||||||
|
|
||||||
|
- 使い方:[docs/sdk-usage.md](docs/sdk-usage.md)
|
||||||
|
- 上級(エグゼキューターとトランスレーター):[docs/sdk-advanced.md](docs/sdk-advanced.md)
|
||||||
|
- アクセス:[docs/sdk-access.md](docs/sdk-access.md)
|
||||||
|
- ウォッチャー:[docs/sdk-watcher.md](docs/sdk-watcher.md)
|
||||||
|
- カスタムプロバイダーの例:`examples/custom-provider`
|
||||||
|
|
||||||
|
## コントリビューション
|
||||||
|
|
||||||
|
コントリビューションを歓迎します!お気軽にPull Requestを送ってください。
|
||||||
|
|
||||||
|
1. リポジトリをフォーク
|
||||||
|
2. フィーチャーブランチを作成(`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 変更をコミット(`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. ブランチにプッシュ(`git push origin feature/amazing-feature`)
|
||||||
|
5. Pull Requestを作成
|
||||||
|
|
||||||
|
## 関連プロジェクト
|
||||||
|
|
||||||
|
CLIProxyAPIをベースにした以下のプロジェクトがあります:
|
||||||
|
|
||||||
|
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
||||||
|
|
||||||
|
macOSネイティブのメニューバーアプリで、Claude CodeとChatGPTのサブスクリプションをAIコーディングツールで使用可能 - APIキー不要
|
||||||
|
|
||||||
|
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
||||||
|
|
||||||
|
CLIProxyAPI経由でGeminiサブスクリプションを使用してSRT字幕を翻訳するブラウザベースのツール。自動検証/エラー修正機能付き - APIキー不要
|
||||||
|
|
||||||
|
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||||
|
|
||||||
|
CLIProxyAPI OAuthを使用して複数のClaudeアカウントや代替モデル(Gemini、Codex、Antigravity)を即座に切り替えるCLIラッパー - APIキー不要
|
||||||
|
|
||||||
|
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
||||||
|
|
||||||
|
CLIProxyAPI管理用のmacOSネイティブGUI:OAuth経由でプロバイダー、モデルマッピング、エンドポイントを設定 - APIキー不要
|
||||||
|
|
||||||
|
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
||||||
|
|
||||||
|
Claude、Gemini、OpenAI、Qwen、Antigravityのサブスクリプションを統合し、リアルタイムのクォータ追跡とスマート自動フェイルオーバーを備えたmacOSネイティブのメニューバーアプリ。Claude Code、OpenCode、Droidなどのコーディングツール向け - APIキー不要
|
||||||
|
|
||||||
|
### [CodMate](https://github.com/loocor/CodMate)
|
||||||
|
|
||||||
|
CLI AIセッション(Codex、Claude Code、Gemini CLI)を管理するmacOS SwiftUIネイティブアプリ。統合プロバイダー管理、Gitレビュー、プロジェクト整理、グローバル検索、ターミナル統合機能を搭載。CLIProxyAPIと統合し、Codex、Claude、Gemini、Antigravity、Qwen CodeのOAuth認証を提供。単一のプロキシエンドポイントを通じた組み込みおよびサードパーティプロバイダーの再ルーティングに対応 - OAuthプロバイダーではAPIキー不要
|
||||||
|
|
||||||
|
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
||||||
|
|
||||||
|
TUI、システムトレイ、マルチプロバイダーOAuthを備えたWindows向けCLIProxyAPIフォーク - AIコーディングツール用、APIキー不要
|
||||||
|
|
||||||
|
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
||||||
|
|
||||||
|
Claude Codeモデルを素早く切り替えるVSCode拡張機能。バックエンドとしてCLIProxyAPIを統合し、バックグラウンドでの自動ライフサイクル管理を搭載
|
||||||
|
|
||||||
|
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
||||||
|
|
||||||
|
CLIProxyAPIを使用してAIコーディングアシスタントのクォータを監視するTauri + React製のWindowsデスクトップアプリ。Gemini、Claude、OpenAI Codex、Antigravityアカウントの使用量をリアルタイムダッシュボード、システムトレイ統合、ワンクリックプロキシコントロールで追跡 - APIキー不要
|
||||||
|
|
||||||
|
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
||||||
|
|
||||||
|
CLIProxyAPI向けの軽量Web管理パネル。ヘルスチェック、リソース監視、リアルタイムログ、自動更新、リクエスト統計、料金表示機能を搭載。ワンクリックインストールとsystemdサービスに対応
|
||||||
|
|
||||||
|
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
|
||||||
|
|
||||||
|
PowerShellスクリプトで実装されたWindowsトレイアプリケーション。サードパーティライブラリに依存せず、ショートカットの自動作成、サイレント実行、パスワード管理、チャネル切り替え(Main / Plus)、自動ダウンロードおよび自動更新に対応
|
||||||
|
|
||||||
|
### [霖君](https://github.com/wangdabaoqq/LinJun)
|
||||||
|
|
||||||
|
霖君はAIプログラミングアシスタントを管理するクロスプラットフォームデスクトップアプリケーションで、macOS、Windows、Linuxシステムに対応。Claude Code、Gemini CLI、OpenAI Codex、Qwen Codeなどのコーディングツールを統合管理し、ローカルプロキシによるマルチアカウントクォータ追跡とワンクリック設定が可能
|
||||||
|
|
||||||
|
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
|
||||||
|
|
||||||
|
Next.js、React、PostgreSQLで構築されたCLIProxyAPI用のモダンなWebベース管理ダッシュボード。リアルタイムログストリーミング、構造化された設定編集、APIキー管理、Claude/Gemini/Codex向けOAuthプロバイダー統合、使用量分析、コンテナ管理、コンパニオンプラグインによるOpenCodeとの設定同期機能を搭載 - 手動でのYAML編集は不要
|
||||||
|
|
||||||
|
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
|
||||||
|
|
||||||
|
New API互換リレーサイトアカウントをワンストップで管理するブラウザ拡張機能。残高と使用量のダッシュボード、自動チェックイン、一般的なアプリへのワンクリックキーエクスポート、ページ内API可用性テスト、チャネル/モデルの同期とリダイレクト機能を搭載。Management APIを通じてCLIProxyAPIと統合し、ワンクリックでプロバイダーのインポートと設定同期が可能
|
||||||
|
|
||||||
|
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
|
||||||
|
|
||||||
|
Shadow AIは制限された環境向けに特別に設計されたAIアシスタントツールです。ウィンドウや痕跡のないステルス動作モードを提供し、LAN(ローカルエリアネットワーク)を介したクロスデバイスAI質疑応答のインタラクションと制御を可能にします。本質的には「画面/音声キャプチャ + AI推論 + 低摩擦デリバリー」の自動化コラボレーションレイヤーであり、制御されたデバイスや制限された環境でアプリケーション横断的にAIアシスタントを没入的に使用できるようユーザーを支援します。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
|
||||||
|
|
||||||
|
## その他の選択肢
|
||||||
|
|
||||||
|
以下のプロジェクトはCLIProxyAPIの移植版またはそれに触発されたものです:
|
||||||
|
|
||||||
|
### [9Router](https://github.com/decolua/9router)
|
||||||
|
|
||||||
|
CLIProxyAPIに触発されたNext.js実装。インストールと使用が簡単で、フォーマット変換(OpenAI/Claude/Gemini/Ollama)、自動フォールバック付きコンボシステム、指数バックオフ付きマルチアカウント管理、Next.js Webダッシュボード、CLIツール(Cursor、Claude Code、Cline、RooCode)のサポートをゼロから構築 - APIキー不要
|
||||||
|
|
||||||
|
### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)
|
||||||
|
|
||||||
|
コーディングを止めない。無料および低コストのAIモデルへのスマートルーティングと自動フォールバック。
|
||||||
|
|
||||||
|
OmniRouteはマルチプロバイダーLLM向けのAIゲートウェイです:スマートルーティング、負荷分散、リトライ、フォールバックを備えたOpenAI互換エンドポイント。ポリシー、レート制限、キャッシュ、可観測性を追加して、信頼性が高くコストを意識した推論を実現します。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> CLIProxyAPIの移植版またはそれに触発されたプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
本プロジェクトはMITライセンスの下でライセンスされています - 詳細は[LICENSE](LICENSE)ファイルを参照してください。
|
||||||
BIN
assets/bmoplus.png
Normal file
BIN
assets/bmoplus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/lingtrue.png
Normal file
BIN
assets/lingtrue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
275
cmd/fetch_antigravity_models/main.go
Normal file
275
cmd/fetch_antigravity_models/main.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
// Command fetch_antigravity_models connects to the Antigravity API using the
|
||||||
|
// stored auth credentials and saves the dynamically fetched model list to a
|
||||||
|
// JSON file for inspection or offline use.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/fetch_antigravity_models [flags]
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
//
|
||||||
|
// --auths-dir <path> Directory containing auth JSON files (default: "auths")
|
||||||
|
// --output <path> Output JSON file path (default: "antigravity_models.json")
|
||||||
|
// --pretty Pretty-print the output JSON (default: true)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
|
sdkauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
|
||||||
|
antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||||
|
antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
|
||||||
|
antigravityModelsPath = "/v1internal:fetchAvailableModels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
logging.SetupBaseLogger()
|
||||||
|
log.SetLevel(log.InfoLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelOutput wraps the fetched model list with fetch metadata.
|
||||||
|
type modelOutput struct {
|
||||||
|
Models []modelEntry `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelEntry contains only the fields we want to keep for static model definitions.
|
||||||
|
type modelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ContextLength int `json:"context_length,omitempty"`
|
||||||
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var authsDir string
|
||||||
|
var outputPath string
|
||||||
|
var pretty bool
|
||||||
|
|
||||||
|
flag.StringVar(&authsDir, "auths-dir", "auths", "Directory containing auth JSON files")
|
||||||
|
flag.StringVar(&outputPath, "output", "antigravity_models.json", "Output JSON file path")
|
||||||
|
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Resolve relative paths against the working directory.
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(authsDir) {
|
||||||
|
authsDir = filepath.Join(wd, authsDir)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(outputPath) {
|
||||||
|
outputPath = filepath.Join(wd, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Scanning auth files in: %s\n", authsDir)
|
||||||
|
|
||||||
|
// Load all auth records from the directory.
|
||||||
|
fileStore := sdkauth.NewFileTokenStore()
|
||||||
|
fileStore.SetBaseDir(authsDir)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
auths, err := fileStore.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(auths) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first enabled antigravity auth.
|
||||||
|
var chosen *coreauth.Auth
|
||||||
|
for _, a := range auths {
|
||||||
|
if a == nil || a.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") {
|
||||||
|
chosen = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: no enabled antigravity auth found in %s\n", authsDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
|
||||||
|
|
||||||
|
// Fetch models from the upstream Antigravity API.
|
||||||
|
fmt.Println("Fetching Antigravity model list from upstream...")
|
||||||
|
|
||||||
|
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
models := fetchModels(fetchCtx, chosen)
|
||||||
|
if len(models) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning: no models returned (API may be unavailable or token expired)")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Fetched %d models.\n", len(models))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the output payload.
|
||||||
|
out := modelOutput{
|
||||||
|
Models: models,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON.
|
||||||
|
var raw []byte
|
||||||
|
if pretty {
|
||||||
|
raw, err = json.MarshalIndent(out, "", " ")
|
||||||
|
} else {
|
||||||
|
raw, err = json.Marshal(out)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to marshal JSON: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Model list saved to: %s\n", outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchModels(ctx context.Context, auth *coreauth.Auth) []modelEntry {
|
||||||
|
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||||
|
if accessToken == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: no access token found in auth")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURLs := []string{antigravityBaseURLProd, antigravityBaseURLDaily, antigravitySandboxBaseURLDaily}
|
||||||
|
|
||||||
|
for _, baseURL := range baseURLs {
|
||||||
|
modelsURL := baseURL + antigravityModelsPath
|
||||||
|
|
||||||
|
var payload []byte
|
||||||
|
if auth != nil && auth.Metadata != nil {
|
||||||
|
if pid, ok := auth.Metadata["project_id"].(string); ok && strings.TrimSpace(pid) != "" {
|
||||||
|
payload = []byte(fmt.Sprintf(`{"project": "%s"}`, strings.TrimSpace(pid)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, strings.NewReader(string(payload)))
|
||||||
|
if errReq != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Close = true
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
httpReq.Header.Set("User-Agent", "antigravity/1.19.6 darwin/arm64")
|
||||||
|
|
||||||
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
|
||||||
|
httpClient.Transport = transport
|
||||||
|
}
|
||||||
|
httpResp, errDo := httpClient.Do(httpReq)
|
||||||
|
if errDo != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, errRead := io.ReadAll(httpResp.Body)
|
||||||
|
httpResp.Body.Close()
|
||||||
|
if errRead != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gjson.GetBytes(bodyBytes, "models")
|
||||||
|
if !result.Exists() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var models []modelEntry
|
||||||
|
|
||||||
|
for originalName, modelData := range result.Map() {
|
||||||
|
modelID := strings.TrimSpace(originalName)
|
||||||
|
if modelID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip internal/experimental models
|
||||||
|
switch modelID {
|
||||||
|
case "chat_20706", "chat_23310", "tab_flash_lite_preview", "tab_jump_flash_lite_preview", "gemini-2.5-flash-thinking", "gemini-2.5-pro":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := modelData.Get("displayName").String()
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := modelEntry{
|
||||||
|
ID: modelID,
|
||||||
|
Object: "model",
|
||||||
|
OwnedBy: "antigravity",
|
||||||
|
Type: "antigravity",
|
||||||
|
DisplayName: displayName,
|
||||||
|
Name: modelID,
|
||||||
|
Description: displayName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxTok := modelData.Get("maxTokens").Int(); maxTok > 0 {
|
||||||
|
entry.ContextLength = int(maxTok)
|
||||||
|
}
|
||||||
|
if maxOut := modelData.Get("maxOutputTokens").Int(); maxOut > 0 {
|
||||||
|
entry.MaxCompletionTokens = int(maxOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
models = append(models, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metaStringValue(m map[string]interface{}, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
return val
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
20
cmd/mcpdebug/main.go
Normal file
20
cmd/mcpdebug/main.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
cursorproto "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Encode MCP result with empty execId
|
||||||
|
resultBytes := cursorproto.EncodeExecMcpResult(1, "", `{"test": "data"}`, false)
|
||||||
|
fmt.Printf("Result protobuf hex: %s\n", hex.EncodeToString(resultBytes))
|
||||||
|
fmt.Printf("Result length: %d bytes\n", len(resultBytes))
|
||||||
|
|
||||||
|
// Write to file for analysis
|
||||||
|
os.WriteFile("mcp_result.bin", resultBytes)
|
||||||
|
fmt.Println("Wrote mcp_result.bin")
|
||||||
|
}
|
||||||
32
cmd/protocheck/main.go
Normal file
32
cmd/protocheck/main.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
cursorproto "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ecm := cursorproto.NewMsg("ExecClientMessage")
|
||||||
|
|
||||||
|
// Try different field names
|
||||||
|
names := []string{
|
||||||
|
"mcp_result", "mcpResult", "McpResult", "MCP_RESULT",
|
||||||
|
"shell_result", "shellResult",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
fd := ecm.Descriptor().Fields().ByName(name)
|
||||||
|
if fd != nil {
|
||||||
|
fmt.Printf("Found field %q: number=%d, kind=%s\n", name, fd.Number(), fd.Kind())
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Field %q NOT FOUND\n", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all fields
|
||||||
|
fmt.Println("\nAll fields in ExecClientMessage:")
|
||||||
|
for i := 0; i < ecm.Descriptor().Fields().Len(); i++ {
|
||||||
|
f := ecm.Descriptor().Fields().Get(i)
|
||||||
|
fmt.Printf(" %d: %q (number=%d)\n", i, f.Name(), f.Number())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
|
||||||
@@ -78,10 +79,13 @@ func main() {
|
|||||||
var kiloLogin bool
|
var kiloLogin bool
|
||||||
var iflowLogin bool
|
var iflowLogin bool
|
||||||
var iflowCookie bool
|
var iflowCookie bool
|
||||||
|
var gitlabLogin bool
|
||||||
|
var gitlabTokenLogin bool
|
||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
var oauthCallbackPort int
|
var oauthCallbackPort int
|
||||||
var antigravityLogin bool
|
var antigravityLogin bool
|
||||||
var kimiLogin bool
|
var kimiLogin bool
|
||||||
|
var cursorLogin bool
|
||||||
var kiroLogin bool
|
var kiroLogin bool
|
||||||
var kiroGoogleLogin bool
|
var kiroGoogleLogin bool
|
||||||
var kiroAWSLogin bool
|
var kiroAWSLogin bool
|
||||||
@@ -92,6 +96,7 @@ func main() {
|
|||||||
var kiroIDCRegion string
|
var kiroIDCRegion string
|
||||||
var kiroIDCFlow string
|
var kiroIDCFlow string
|
||||||
var githubCopilotLogin bool
|
var githubCopilotLogin bool
|
||||||
|
var codeBuddyLogin bool
|
||||||
var projectID string
|
var projectID string
|
||||||
var vertexImport string
|
var vertexImport string
|
||||||
var configPath string
|
var configPath string
|
||||||
@@ -100,6 +105,7 @@ func main() {
|
|||||||
var standalone bool
|
var standalone bool
|
||||||
var noIncognito bool
|
var noIncognito bool
|
||||||
var useIncognito bool
|
var useIncognito bool
|
||||||
|
var localModel bool
|
||||||
|
|
||||||
// Define command-line flags for different operation modes.
|
// Define command-line flags for different operation modes.
|
||||||
flag.BoolVar(&login, "login", false, "Login Google Account")
|
flag.BoolVar(&login, "login", false, "Login Google Account")
|
||||||
@@ -110,12 +116,15 @@ func main() {
|
|||||||
flag.BoolVar(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow")
|
flag.BoolVar(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow")
|
||||||
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||||
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
||||||
|
flag.BoolVar(&gitlabLogin, "gitlab-login", false, "Login to GitLab Duo using OAuth")
|
||||||
|
flag.BoolVar(&gitlabTokenLogin, "gitlab-token-login", false, "Login to GitLab Duo using a personal access token")
|
||||||
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||||
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
||||||
flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
|
flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
|
||||||
flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
|
flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
|
||||||
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
||||||
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
|
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
|
||||||
|
flag.BoolVar(&cursorLogin, "cursor-login", false, "Login to Cursor using OAuth")
|
||||||
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
|
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
|
||||||
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
|
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
|
||||||
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
|
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
|
||||||
@@ -126,12 +135,14 @@ func main() {
|
|||||||
flag.StringVar(&kiroIDCRegion, "kiro-idc-region", "", "IDC region (default: us-east-1)")
|
flag.StringVar(&kiroIDCRegion, "kiro-idc-region", "", "IDC region (default: us-east-1)")
|
||||||
flag.StringVar(&kiroIDCFlow, "kiro-idc-flow", "", "IDC flow type: authcode (default) or device")
|
flag.StringVar(&kiroIDCFlow, "kiro-idc-flow", "", "IDC flow type: authcode (default) or device")
|
||||||
flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
|
flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
|
||||||
|
flag.BoolVar(&codeBuddyLogin, "codebuddy-login", false, "Login to CodeBuddy using browser OAuth flow")
|
||||||
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||||
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
||||||
flag.StringVar(&password, "password", "", "")
|
flag.StringVar(&password, "password", "", "")
|
||||||
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
|
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
|
||||||
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
|
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
|
||||||
|
flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching")
|
||||||
|
|
||||||
flag.CommandLine.Usage = func() {
|
flag.CommandLine.Usage = func() {
|
||||||
out := flag.CommandLine.Output()
|
out := flag.CommandLine.Output()
|
||||||
@@ -509,6 +520,9 @@ func main() {
|
|||||||
} else if githubCopilotLogin {
|
} else if githubCopilotLogin {
|
||||||
// Handle GitHub Copilot login
|
// Handle GitHub Copilot login
|
||||||
cmd.DoGitHubCopilotLogin(cfg, options)
|
cmd.DoGitHubCopilotLogin(cfg, options)
|
||||||
|
} else if codeBuddyLogin {
|
||||||
|
// Handle CodeBuddy login
|
||||||
|
cmd.DoCodeBuddyLogin(cfg, options)
|
||||||
} else if codexLogin {
|
} else if codexLogin {
|
||||||
// Handle Codex login
|
// Handle Codex login
|
||||||
cmd.DoCodexLogin(cfg, options)
|
cmd.DoCodexLogin(cfg, options)
|
||||||
@@ -526,8 +540,14 @@ func main() {
|
|||||||
cmd.DoIFlowLogin(cfg, options)
|
cmd.DoIFlowLogin(cfg, options)
|
||||||
} else if iflowCookie {
|
} else if iflowCookie {
|
||||||
cmd.DoIFlowCookieAuth(cfg, options)
|
cmd.DoIFlowCookieAuth(cfg, options)
|
||||||
|
} else if gitlabLogin {
|
||||||
|
cmd.DoGitLabLogin(cfg, options)
|
||||||
|
} else if gitlabTokenLogin {
|
||||||
|
cmd.DoGitLabTokenLogin(cfg, options)
|
||||||
} else if kimiLogin {
|
} else if kimiLogin {
|
||||||
cmd.DoKimiLogin(cfg, options)
|
cmd.DoKimiLogin(cfg, options)
|
||||||
|
} else if cursorLogin {
|
||||||
|
cmd.DoCursorLogin(cfg, options)
|
||||||
} else if kiroLogin {
|
} else if kiroLogin {
|
||||||
// For Kiro auth, default to incognito mode for multi-account support
|
// For Kiro auth, default to incognito mode for multi-account support
|
||||||
// Users can explicitly override with --no-incognito
|
// Users can explicitly override with --no-incognito
|
||||||
@@ -569,10 +589,16 @@ func main() {
|
|||||||
cmd.WaitForCloudDeploy()
|
cmd.WaitForCloudDeploy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if localModel && (!tuiMode || standalone) {
|
||||||
|
log.Info("Local model mode: using embedded model catalog, remote model updates disabled")
|
||||||
|
}
|
||||||
if tuiMode {
|
if tuiMode {
|
||||||
if standalone {
|
if standalone {
|
||||||
// Standalone mode: start an embedded local server and connect TUI client to it.
|
// Standalone mode: start an embedded local server and connect TUI client to it.
|
||||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||||
|
if !localModel {
|
||||||
|
registry.StartModelsUpdater(context.Background())
|
||||||
|
}
|
||||||
hook := tui.NewLogHook(2000)
|
hook := tui.NewLogHook(2000)
|
||||||
hook.SetFormatter(&logging.LogFormatter{})
|
hook.SetFormatter(&logging.LogFormatter{})
|
||||||
log.AddHook(hook)
|
log.AddHook(hook)
|
||||||
@@ -645,6 +671,9 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
// Start the main proxy service
|
// Start the main proxy service
|
||||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||||
|
if !localModel {
|
||||||
|
registry.StartModelsUpdater(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.AuthDir != "" {
|
if cfg.AuthDir != "" {
|
||||||
kiro.InitializeAndStart(cfg.AuthDir, cfg)
|
kiro.InitializeAndStart(cfg.AuthDir, cfg)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ remote-management:
|
|||||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||||
disable-control-panel: false
|
disable-control-panel: false
|
||||||
|
|
||||||
|
# Disable automatic periodic background updates of the management panel from GitHub (default: false).
|
||||||
|
# When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward.
|
||||||
|
# disable-auto-update-panel: false
|
||||||
|
|
||||||
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
||||||
panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'
|
panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'
|
||||||
|
|
||||||
@@ -68,7 +72,8 @@ error-logs-max-files: 10
|
|||||||
usage-statistics-enabled: false
|
usage-statistics-enabled: false
|
||||||
|
|
||||||
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||||
proxy-url: ''
|
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
|
||||||
|
proxy-url: ""
|
||||||
|
|
||||||
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
|
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
|
||||||
force-model-prefix: false
|
force-model-prefix: false
|
||||||
@@ -80,6 +85,10 @@ passthrough-headers: false
|
|||||||
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
||||||
request-retry: 3
|
request-retry: 3
|
||||||
|
|
||||||
|
# Maximum number of different credentials to try for one failed request.
|
||||||
|
# Set to 0 to keep legacy behavior (try all available credentials).
|
||||||
|
max-retry-credentials: 0
|
||||||
|
|
||||||
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
|
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
|
||||||
max-retry-interval: 30
|
max-retry-interval: 30
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080"
|
# proxy-url: "socks5://proxy.example.com:1080"
|
||||||
|
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||||
# models:
|
# models:
|
||||||
# - name: "gemini-2.5-flash" # upstream model name
|
# - name: "gemini-2.5-flash" # upstream model name
|
||||||
# alias: "gemini-flash" # client alias mapped to the upstream model
|
# alias: "gemini-flash" # client alias mapped to the upstream model
|
||||||
@@ -129,6 +139,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
|
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||||
# models:
|
# models:
|
||||||
# - name: "gpt-5-codex" # upstream model name
|
# - name: "gpt-5-codex" # upstream model name
|
||||||
# alias: "codex-latest" # client alias mapped to the upstream model
|
# alias: "codex-latest" # client alias mapped to the upstream model
|
||||||
@@ -147,6 +158,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
|
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||||
# models:
|
# models:
|
||||||
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
||||||
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
||||||
@@ -167,12 +179,27 @@ nonstream-keepalive-interval: 0
|
|||||||
# 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
|
# 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
|
||||||
|
|
||||||
# Default headers for Claude API requests. Update when Claude Code releases new versions.
|
# Default headers for Claude API requests. Update when Claude Code releases new versions.
|
||||||
# These are used as fallbacks when the client does not send its own headers.
|
# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
|
||||||
|
# when the client omits them, while OS/arch remain runtime-derived. When
|
||||||
|
# stabilize-device-profile is enabled, OS/arch stay pinned to the baseline values below,
|
||||||
|
# while user-agent/package-version/runtime-version seed a software fingerprint that can
|
||||||
|
# still upgrade to newer official Claude client versions.
|
||||||
# claude-header-defaults:
|
# claude-header-defaults:
|
||||||
# user-agent: "claude-cli/2.1.44 (external, sdk-cli)"
|
# user-agent: "claude-cli/2.1.44 (external, sdk-cli)"
|
||||||
# package-version: "0.74.0"
|
# package-version: "0.74.0"
|
||||||
# runtime-version: "v24.3.0"
|
# runtime-version: "v24.3.0"
|
||||||
|
# os: "MacOS"
|
||||||
|
# arch: "arm64"
|
||||||
# timeout: "600"
|
# timeout: "600"
|
||||||
|
# stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning
|
||||||
|
|
||||||
|
# Default headers for Codex OAuth model requests.
|
||||||
|
# These are used only for file-backed/OAuth Codex requests when the client
|
||||||
|
# does not send the header. `user-agent` applies to HTTP and websocket requests;
|
||||||
|
# `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries.
|
||||||
|
# codex-header-defaults:
|
||||||
|
# user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
|
||||||
|
# beta-features: "multi_agent"
|
||||||
|
|
||||||
# Kiro (AWS CodeWhisperer) configuration
|
# Kiro (AWS CodeWhisperer) configuration
|
||||||
# Note: Kiro API currently only operates in us-east-1 region
|
# Note: Kiro API currently only operates in us-east-1 region
|
||||||
@@ -211,17 +238,32 @@ nonstream-keepalive-interval: 0
|
|||||||
# api-key-entries:
|
# api-key-entries:
|
||||||
# - api-key: "sk-or-v1-...b780"
|
# - api-key: "sk-or-v1-...b780"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
|
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||||
# models: # The models supported by the provider.
|
# models: # The models supported by the provider.
|
||||||
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||||
# alias: "kimi-k2" # The alias used in the API.
|
# alias: "kimi-k2" # The alias used in the API.
|
||||||
|
# thinking: # optional: omit to default to levels ["low","medium","high"]
|
||||||
|
# levels: ["low", "medium", "high"]
|
||||||
|
# # You may repeat the same alias to build an internal model pool.
|
||||||
|
# # The client still sees only one alias in the model list.
|
||||||
|
# # Requests to that alias will round-robin across the upstream names below,
|
||||||
|
# # and if the chosen upstream fails before producing output, the request will
|
||||||
|
# # continue with the next upstream model in the same alias pool.
|
||||||
|
# - name: "qwen3.5-plus"
|
||||||
|
# alias: "claude-opus-4.66"
|
||||||
|
# - name: "glm-5"
|
||||||
|
# alias: "claude-opus-4.66"
|
||||||
|
# - name: "kimi-k2.5"
|
||||||
|
# alias: "claude-opus-4.66"
|
||||||
|
|
||||||
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
|
# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
|
||||||
# vertex-api-key:
|
# vertex-api-key:
|
||||||
# - api-key: "vk-123..." # x-goog-api-key header
|
# - api-key: "vk-123..." # x-goog-api-key header
|
||||||
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
|
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
|
||||||
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
|
# base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
||||||
|
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# models: # optional: map aliases to upstream model names
|
# models: # optional: map aliases to upstream model names
|
||||||
@@ -229,6 +271,9 @@ nonstream-keepalive-interval: 0
|
|||||||
# alias: "vertex-flash" # client-visible alias
|
# alias: "vertex-flash" # client-visible alias
|
||||||
# - name: "gemini-2.5-pro"
|
# - name: "gemini-2.5-pro"
|
||||||
# alias: "vertex-pro"
|
# alias: "vertex-pro"
|
||||||
|
# excluded-models: # optional: models to exclude from listing
|
||||||
|
# - "imagen-3.0-generate-002"
|
||||||
|
# - "imagen-*"
|
||||||
|
|
||||||
# Amp Integration
|
# Amp Integration
|
||||||
# ampcode:
|
# ampcode:
|
||||||
@@ -268,6 +313,10 @@ nonstream-keepalive-interval: 0
|
|||||||
# These aliases rename model IDs for both model listing and request routing.
|
# These aliases rename model IDs for both model listing and request routing.
|
||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
||||||
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
||||||
|
# NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping
|
||||||
|
# client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps
|
||||||
|
# you select the protocol surface, but inference backend selection can still follow the resolved
|
||||||
|
# model/alias. For strict backend pinning, use unique aliases/prefixes or avoid overlapping names.
|
||||||
# You can repeat the same name with different aliases to expose multiple client model names.
|
# You can repeat the same name with different aliases to expose multiple client model names.
|
||||||
# oauth-model-alias:
|
# oauth-model-alias:
|
||||||
# antigravity:
|
# antigravity:
|
||||||
|
|||||||
115
docs/gitlab-duo.md
Normal file
115
docs/gitlab-duo.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# GitLab Duo guide
|
||||||
|
|
||||||
|
CLIProxyAPI can now use GitLab Duo as a first-class provider instead of treating it as a plain text wrapper.
|
||||||
|
|
||||||
|
It supports:
|
||||||
|
|
||||||
|
- OAuth login
|
||||||
|
- personal access token login
|
||||||
|
- automatic refresh of GitLab `direct_access` metadata
|
||||||
|
- dynamic model discovery from GitLab metadata
|
||||||
|
- native GitLab AI gateway routing for Anthropic and OpenAI/Codex managed models
|
||||||
|
- Claude-compatible and OpenAI-compatible downstream APIs
|
||||||
|
|
||||||
|
## What this means
|
||||||
|
|
||||||
|
If GitLab Duo returns an Anthropic-managed model, CLIProxyAPI routes requests through the GitLab AI gateway Anthropic proxy and uses the existing Claude executor path.
|
||||||
|
|
||||||
|
If GitLab Duo returns an OpenAI-managed model, CLIProxyAPI routes requests through the GitLab AI gateway OpenAI proxy and uses the existing Codex/OpenAI executor path.
|
||||||
|
|
||||||
|
That gives GitLab Duo much closer runtime behavior to the built-in `codex` provider:
|
||||||
|
|
||||||
|
- Claude-compatible clients can use GitLab Duo models through `/v1/messages`
|
||||||
|
- OpenAI-compatible clients can use GitLab Duo models through `/v1/chat/completions`
|
||||||
|
- OpenAI Responses clients can use GitLab Duo models through `/v1/responses`
|
||||||
|
|
||||||
|
The model list is not hardcoded. CLIProxyAPI reads the current model metadata from GitLab `direct_access` and registers:
|
||||||
|
|
||||||
|
- a stable alias: `gitlab-duo`
|
||||||
|
- any discovered managed model names, such as `claude-sonnet-4-5` or `gpt-5-codex`
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
OAuth login:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./CLIProxyAPI -gitlab-login
|
||||||
|
```
|
||||||
|
|
||||||
|
PAT login:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./CLIProxyAPI -gitlab-token-login
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also provide inputs through environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITLAB_BASE_URL=https://gitlab.com
|
||||||
|
export GITLAB_OAUTH_CLIENT_ID=your-client-id
|
||||||
|
export GITLAB_OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
export GITLAB_PERSONAL_ACCESS_TOKEN=glpat-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- OAuth requires a GitLab OAuth application.
|
||||||
|
- PAT login requires a personal access token that can call the GitLab APIs used by Duo. In practice, `api` scope is the safe baseline.
|
||||||
|
- Self-managed GitLab instances are supported through `GITLAB_BASE_URL`.
|
||||||
|
|
||||||
|
## Using the models
|
||||||
|
|
||||||
|
After login, start CLIProxyAPI normally and point your client at the local proxy.
|
||||||
|
|
||||||
|
You can select:
|
||||||
|
|
||||||
|
- `gitlab-duo` to use the current Duo-managed model for that account
|
||||||
|
- the discovered provider model name if you want to pin it explicitly
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/v1/chat/completions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"model": "gitlab-duo",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Write a Go HTTP middleware for request IDs."}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If the GitLab account is currently mapped to an Anthropic model, Claude-compatible clients can use the same account through the Claude handler path. If the account is currently mapped to an OpenAI/Codex model, OpenAI-compatible clients can use `/v1/chat/completions` or `/v1/responses`.
|
||||||
|
|
||||||
|
## How model freshness works
|
||||||
|
|
||||||
|
CLIProxyAPI does not ship a fixed GitLab Duo model catalog.
|
||||||
|
|
||||||
|
Instead, it refreshes GitLab `direct_access` metadata and uses the returned `model_details` and any discovered model list entries to keep the local registry aligned with the current GitLab-managed model assignment.
|
||||||
|
|
||||||
|
This matches GitLab's current public contract better than hardcoding model names.
|
||||||
|
|
||||||
|
## Current scope
|
||||||
|
|
||||||
|
The GitLab Duo provider now has:
|
||||||
|
|
||||||
|
- OAuth and PAT auth flows
|
||||||
|
- runtime refresh of Duo gateway credentials
|
||||||
|
- native Anthropic gateway routing
|
||||||
|
- native OpenAI/Codex gateway routing
|
||||||
|
- handler-level smoke tests for Claude-compatible and OpenAI-compatible paths
|
||||||
|
|
||||||
|
Still out of scope today:
|
||||||
|
|
||||||
|
- websocket or session-specific parity beyond the current HTTP APIs
|
||||||
|
- GitLab-specific IDE features that are not exposed through the public gateway contract
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- GitLab Code Suggestions API: https://docs.gitlab.com/api/code_suggestions/
|
||||||
|
- GitLab Agent Assistant and managed credentials: https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/
|
||||||
|
- GitLab Duo model selection: https://docs.gitlab.com/user/gitlab_duo/model_selection/
|
||||||
115
docs/gitlab-duo_CN.md
Normal file
115
docs/gitlab-duo_CN.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# GitLab Duo 使用说明
|
||||||
|
|
||||||
|
CLIProxyAPI 现在可以把 GitLab Duo 当作一等 Provider 来使用,而不是仅仅把它当成简单的文本补全封装。
|
||||||
|
|
||||||
|
当前支持:
|
||||||
|
|
||||||
|
- OAuth 登录
|
||||||
|
- personal access token 登录
|
||||||
|
- 自动刷新 GitLab `direct_access` 元数据
|
||||||
|
- 根据 GitLab 返回的元数据动态发现模型
|
||||||
|
- 针对 Anthropic 和 OpenAI/Codex 托管模型的 GitLab AI gateway 原生路由
|
||||||
|
- Claude 兼容与 OpenAI 兼容下游 API
|
||||||
|
|
||||||
|
## 这意味着什么
|
||||||
|
|
||||||
|
如果 GitLab Duo 返回的是 Anthropic 托管模型,CLIProxyAPI 会通过 GitLab AI gateway 的 Anthropic 代理转发,并复用现有的 Claude executor 路径。
|
||||||
|
|
||||||
|
如果 GitLab Duo 返回的是 OpenAI 托管模型,CLIProxyAPI 会通过 GitLab AI gateway 的 OpenAI 代理转发,并复用现有的 Codex/OpenAI executor 路径。
|
||||||
|
|
||||||
|
这让 GitLab Duo 的运行时行为更接近内置的 `codex` Provider:
|
||||||
|
|
||||||
|
- Claude 兼容客户端可以通过 `/v1/messages` 使用 GitLab Duo 模型
|
||||||
|
- OpenAI 兼容客户端可以通过 `/v1/chat/completions` 使用 GitLab Duo 模型
|
||||||
|
- OpenAI Responses 客户端可以通过 `/v1/responses` 使用 GitLab Duo 模型
|
||||||
|
|
||||||
|
模型列表不是硬编码的。CLIProxyAPI 会从 GitLab `direct_access` 中读取当前模型元数据,并注册:
|
||||||
|
|
||||||
|
- 一个稳定别名:`gitlab-duo`
|
||||||
|
- GitLab 当前发现到的托管模型名,例如 `claude-sonnet-4-5` 或 `gpt-5-codex`
|
||||||
|
|
||||||
|
## 登录
|
||||||
|
|
||||||
|
OAuth 登录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./CLIProxyAPI -gitlab-login
|
||||||
|
```
|
||||||
|
|
||||||
|
PAT 登录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./CLIProxyAPI -gitlab-token-login
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以通过环境变量提供输入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITLAB_BASE_URL=https://gitlab.com
|
||||||
|
export GITLAB_OAUTH_CLIENT_ID=your-client-id
|
||||||
|
export GITLAB_OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
export GITLAB_PERSONAL_ACCESS_TOKEN=glpat-...
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- OAuth 方式需要一个 GitLab OAuth application。
|
||||||
|
- PAT 登录需要一个能够调用 GitLab Duo 相关 API 的 personal access token。实践上,`api` scope 是最稳妥的基线。
|
||||||
|
- 自建 GitLab 实例可以通过 `GITLAB_BASE_URL` 接入。
|
||||||
|
|
||||||
|
## 如何使用模型
|
||||||
|
|
||||||
|
登录完成后,正常启动 CLIProxyAPI,并让客户端连接到本地代理。
|
||||||
|
|
||||||
|
你可以选择:
|
||||||
|
|
||||||
|
- `gitlab-duo`,始终使用该账号当前的 Duo 托管模型
|
||||||
|
- GitLab 当前发现到的 provider 模型名,如果你想显式固定模型
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/v1/chat/completions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"model": "gitlab-duo",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Write a Go HTTP middleware for request IDs."}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
如果该 GitLab 账号当前绑定的是 Anthropic 模型,Claude 兼容客户端可以通过 Claude handler 路径直接使用它。如果当前绑定的是 OpenAI/Codex 模型,OpenAI 兼容客户端可以通过 `/v1/chat/completions` 或 `/v1/responses` 使用它。
|
||||||
|
|
||||||
|
## 模型如何保持最新
|
||||||
|
|
||||||
|
CLIProxyAPI 不内置固定的 GitLab Duo 模型清单。
|
||||||
|
|
||||||
|
它会刷新 GitLab `direct_access` 元数据,并使用返回的 `model_details` 以及可能存在的模型列表字段,让本地 registry 尽量与 GitLab 当前分配的托管模型保持一致。
|
||||||
|
|
||||||
|
这比硬编码模型名更符合 GitLab 当前公开 API 的实际契约。
|
||||||
|
|
||||||
|
## 当前覆盖范围
|
||||||
|
|
||||||
|
GitLab Duo Provider 目前已经具备:
|
||||||
|
|
||||||
|
- OAuth 和 PAT 登录流程
|
||||||
|
- Duo gateway 凭据的运行时刷新
|
||||||
|
- Anthropic gateway 原生路由
|
||||||
|
- OpenAI/Codex gateway 原生路由
|
||||||
|
- Claude 兼容和 OpenAI 兼容路径的 handler 级 smoke 测试
|
||||||
|
|
||||||
|
当前仍未覆盖:
|
||||||
|
|
||||||
|
- websocket 或 session 级别的完全对齐
|
||||||
|
- GitLab 公开 gateway 契约之外的 IDE 专有能力
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- GitLab Code Suggestions API: https://docs.gitlab.com/api/code_suggestions/
|
||||||
|
- GitLab Agent Assistant 与 managed credentials: https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/
|
||||||
|
- GitLab Duo 模型选择: https://docs.gitlab.com/user/gitlab_duo/model_selection/
|
||||||
@@ -52,11 +52,11 @@ func init() {
|
|||||||
sdktr.Register(fOpenAI, fMyProv,
|
sdktr.Register(fOpenAI, fMyProv,
|
||||||
func(model string, raw []byte, stream bool) []byte { return raw },
|
func(model string, raw []byte, stream bool) []byte { return raw },
|
||||||
sdktr.ResponseTransform{
|
sdktr.ResponseTransform{
|
||||||
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) [][]byte {
|
||||||
return []string{string(raw)}
|
return [][]byte{raw}
|
||||||
},
|
},
|
||||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []byte {
|
||||||
return string(raw)
|
return raw
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
278
gitlab-duo-codex-parity-plan.md
Normal file
278
gitlab-duo-codex-parity-plan.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# Plan: GitLab Duo Codex Parity
|
||||||
|
|
||||||
|
**Generated**: 2026-03-10
|
||||||
|
**Estimated Complexity**: High
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Bring GitLab Duo support from the current "auth + basic executor" stage to the same practical level as `codex` inside `CLIProxyAPI`: a user logs in once, points external clients such as Claude Code at `CLIProxyAPI`, selects GitLab Duo-backed models, and gets stable streaming, multi-turn behavior, tool calling compatibility, and predictable model routing without manual provider-specific workarounds.
|
||||||
|
|
||||||
|
The core architectural shift is to stop treating GitLab Duo as only two REST wrappers (`/api/v4/chat/completions` and `/api/v4/code_suggestions/completions`) and instead use GitLab's `direct_access` contract as the primary runtime entrypoint wherever possible. Official GitLab docs confirm that `direct_access` returns AI gateway connection details, headers, token, and expiry; that contract is the closest path to codex-like provider behavior.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Official GitLab Duo API references confirmed during implementation:
|
||||||
|
- `POST /api/v4/code_suggestions/direct_access`
|
||||||
|
- `POST /api/v4/code_suggestions/completions`
|
||||||
|
- `POST /api/v4/chat/completions`
|
||||||
|
- Access to at least one real GitLab Duo account for manual verification.
|
||||||
|
- One downstream client target for acceptance testing:
|
||||||
|
- Claude Code against Claude-compatible endpoint
|
||||||
|
- OpenAI-compatible client against `/v1/chat/completions` and `/v1/responses`
|
||||||
|
- Existing PR branch as starting point:
|
||||||
|
- `feat/gitlab-duo-auth`
|
||||||
|
- PR [#2028](https://github.com/router-for-me/CLIProxyAPI/pull/2028)
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
- GitLab Duo models can be used via `CLIProxyAPI` from the same client surfaces that already work for `codex`.
|
||||||
|
- Upstream streaming is real passthrough or faithful chunked forwarding, not synthetic whole-response replay.
|
||||||
|
- Tool/function calling survives translation layers without dropping fields or corrupting names.
|
||||||
|
- Multi-turn and session semantics are stable across `chat/completions`, `responses`, and Claude-compatible routes.
|
||||||
|
- Model exposure stays current from GitLab metadata or gateway discovery without hardcoded stale model tables.
|
||||||
|
- `go test ./...` stays green and at least one real manual end-to-end client flow is documented.
|
||||||
|
|
||||||
|
## Sprint 1: Contract And Gap Closure
|
||||||
|
**Goal**: Replace assumptions with a hard compatibility contract between current `codex` behavior and what GitLab Duo can actually support.
|
||||||
|
|
||||||
|
**Demo/Validation**:
|
||||||
|
- Written matrix showing `codex` features vs current GitLab Duo behavior.
|
||||||
|
- One checked-in developer note or test fixture for real GitLab Duo payload examples.
|
||||||
|
|
||||||
|
### Task 1.1: Freeze Codex Parity Checklist
|
||||||
|
- **Location**: [internal/runtime/executor/codex_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/codex_executor.go), [internal/runtime/executor/codex_websockets_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/codex_websockets_executor.go), [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go), [sdk/api/handlers/openai/openai_responses_websocket.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_websocket.go)
|
||||||
|
- **Description**: Produce a concrete feature matrix for `codex`: HTTP execute, SSE execute, `/v1/responses`, websocket downstream path, tool calling, request IDs, session close semantics, and model registration behavior.
|
||||||
|
- **Dependencies**: None
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- A checklist exists in repo docs or issue notes.
|
||||||
|
- Each capability is marked `required`, `optional`, or `not possible` for GitLab Duo.
|
||||||
|
- **Validation**:
|
||||||
|
- Review against current `codex` code paths.
|
||||||
|
|
||||||
|
### Task 1.2: Lock GitLab Duo Runtime Contract
|
||||||
|
- **Location**: [internal/auth/gitlab/gitlab.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/auth/gitlab/gitlab.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go)
|
||||||
|
- **Description**: Validate the exact upstream contract we can rely on:
|
||||||
|
- `direct_access` fields and refresh cadence
|
||||||
|
- whether AI gateway path is usable directly
|
||||||
|
- when `chat/completions` is available vs when fallback is required
|
||||||
|
- what streaming shape is returned by `code_suggestions/completions?stream=true`
|
||||||
|
- **Dependencies**: Task 1.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- GitLab transport decision is explicit: `gateway-first`, `REST-first`, or `hybrid`.
|
||||||
|
- Unknown areas are isolated behind feature flags, not spread across executor logic.
|
||||||
|
- **Validation**:
|
||||||
|
- Official docs + captured real responses from a Duo account.
|
||||||
|
|
||||||
|
### Task 1.3: Define Client-Facing Compatibility Targets
|
||||||
|
- **Location**: [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md), [gitlab-duo-codex-parity-plan.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/gitlab-duo-codex-parity-plan.md)
|
||||||
|
- **Description**: Define exactly which external flows must work to call GitLab Duo support "like codex".
|
||||||
|
- **Dependencies**: Task 1.2
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- Required surfaces are listed:
|
||||||
|
- Claude-compatible route
|
||||||
|
- OpenAI `chat/completions`
|
||||||
|
- OpenAI `responses`
|
||||||
|
- optional downstream websocket path
|
||||||
|
- Non-goals are explicit if GitLab upstream cannot support them.
|
||||||
|
- **Validation**:
|
||||||
|
- Maintainer review of stated scope.
|
||||||
|
|
||||||
|
## Sprint 2: Primary Transport Parity
|
||||||
|
**Goal**: Move GitLab Duo execution onto a transport that supports codex-like runtime behavior.
|
||||||
|
|
||||||
|
**Demo/Validation**:
|
||||||
|
- A GitLab Duo model works over real streaming through `/v1/chat/completions`.
|
||||||
|
- No synthetic "collect full body then fake stream" path remains on the primary flow.
|
||||||
|
|
||||||
|
### Task 2.1: Refactor GitLab Executor Into Strategy Layers
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go)
|
||||||
|
- **Description**: Split current executor into explicit strategies:
|
||||||
|
- auth refresh/direct access refresh
|
||||||
|
- gateway transport
|
||||||
|
- GitLab REST fallback transport
|
||||||
|
- downstream translation helpers
|
||||||
|
- **Dependencies**: Sprint 1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- Executor no longer mixes discovery, refresh, fallback selection, and response synthesis in one path.
|
||||||
|
- Transport choice is testable in isolation.
|
||||||
|
- **Validation**:
|
||||||
|
- Unit tests for strategy selection and fallback boundaries.
|
||||||
|
|
||||||
|
### Task 2.2: Implement Real Streaming Path
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/runtime/executor/gitlab_executor_test.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor_test.go)
|
||||||
|
- **Description**: Replace synthetic streaming with true upstream incremental forwarding:
|
||||||
|
- use gateway stream if available
|
||||||
|
- otherwise consume GitLab Code Suggestions streaming response and map chunks incrementally
|
||||||
|
- **Dependencies**: Task 2.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- `ExecuteStream` emits chunks before upstream completion.
|
||||||
|
- error handling preserves status and early failure semantics.
|
||||||
|
- **Validation**:
|
||||||
|
- tests with chunked upstream server
|
||||||
|
- manual curl check against `/v1/chat/completions` with `stream=true`
|
||||||
|
|
||||||
|
### Task 2.3: Preserve Upstream Auth And Headers Correctly
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/auth/gitlab/gitlab.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/auth/gitlab/gitlab.go)
|
||||||
|
- **Description**: Use `direct_access` connection details as first-class transport state:
|
||||||
|
- gateway token
|
||||||
|
- expiry
|
||||||
|
- mandatory forwarded headers
|
||||||
|
- model metadata
|
||||||
|
- **Dependencies**: Task 2.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- executor stops ignoring gateway headers/token when transport requires them
|
||||||
|
- refresh logic never over-fetches `direct_access`
|
||||||
|
- **Validation**:
|
||||||
|
- tests verifying propagated headers and refresh interval behavior
|
||||||
|
|
||||||
|
## Sprint 3: Request/Response Semantics Parity
|
||||||
|
**Goal**: Make GitLab Duo behave correctly under the same request shapes that current `codex` consumers send.
|
||||||
|
|
||||||
|
**Demo/Validation**:
|
||||||
|
- OpenAI and Claude-compatible clients can do non-streaming and streaming conversations without losing structure.
|
||||||
|
|
||||||
|
### Task 3.1: Normalize Multi-Turn Message Mapping
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/translator](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/translator)
|
||||||
|
- **Description**: Replace the current "flatten prompt into one instruction" behavior with stable multi-turn mapping:
|
||||||
|
- preserve system context
|
||||||
|
- preserve user/assistant ordering
|
||||||
|
- maintain bounded context truncation
|
||||||
|
- **Dependencies**: Sprint 2
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- multi-turn requests are not collapsed into a lossy single string unless fallback mode explicitly requires it
|
||||||
|
- truncation policy is deterministic and tested
|
||||||
|
- **Validation**:
|
||||||
|
- golden tests for request mapping
|
||||||
|
|
||||||
|
### Task 3.2: Tool Calling Compatibility Layer
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go)
|
||||||
|
- **Description**: Decide and implement one of two paths:
|
||||||
|
- native pass-through if GitLab gateway supports tool/function structures
|
||||||
|
- strict downgrade path with explicit unsupported errors instead of silent field loss
|
||||||
|
- **Dependencies**: Task 3.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- tool-related fields are either preserved correctly or rejected explicitly
|
||||||
|
- no silent corruption of tool names, tool calls, or tool results
|
||||||
|
- **Validation**:
|
||||||
|
- table-driven tests for tool payloads
|
||||||
|
- one manual client scenario using tools
|
||||||
|
|
||||||
|
### Task 3.3: Token Counting And Usage Reporting Fidelity
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [internal/runtime/executor/usage_helpers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/usage_helpers.go)
|
||||||
|
- **Description**: Improve token/usage reporting so GitLab models behave like first-class providers in logs and scheduling.
|
||||||
|
- **Dependencies**: Sprint 2
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- `CountTokens` uses the closest supported estimation path
|
||||||
|
- usage logging distinguishes prompt vs completion when possible
|
||||||
|
- **Validation**:
|
||||||
|
- unit tests for token estimation outputs
|
||||||
|
|
||||||
|
## Sprint 4: Responses And Session Parity
|
||||||
|
**Goal**: Reach codex-level support for OpenAI Responses clients and long-lived sessions where GitLab upstream permits it.
|
||||||
|
|
||||||
|
**Demo/Validation**:
|
||||||
|
- `/v1/responses` works with GitLab Duo in a realistic client flow.
|
||||||
|
- If websocket parity is not possible, the code explicitly declines it and keeps HTTP paths stable.
|
||||||
|
|
||||||
|
### Task 4.1: Make GitLab Compatible With `/v1/responses`
|
||||||
|
- **Location**: [sdk/api/handlers/openai/openai_responses_handlers.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_handlers.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go)
|
||||||
|
- **Description**: Ensure GitLab transport can safely back the Responses API path, including compact responses if applicable.
|
||||||
|
- **Dependencies**: Sprint 3
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- GitLab Duo can be selected behind `/v1/responses`
|
||||||
|
- response IDs and follow-up semantics are defined
|
||||||
|
- **Validation**:
|
||||||
|
- handler tests analogous to codex/openai responses tests
|
||||||
|
|
||||||
|
### Task 4.2: Evaluate Downstream Websocket Parity
|
||||||
|
- **Location**: [sdk/api/handlers/openai/openai_responses_websocket.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai/openai_responses_websocket.go), [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go)
|
||||||
|
- **Description**: Decide whether GitLab Duo can support downstream websocket sessions like codex:
|
||||||
|
- if yes, add session-aware execution path
|
||||||
|
- if no, mark GitLab auth as websocket-ineligible and keep HTTP routes first-class
|
||||||
|
- **Dependencies**: Task 4.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- websocket behavior is explicit, not accidental
|
||||||
|
- no route claims websocket support when the upstream cannot honor it
|
||||||
|
- **Validation**:
|
||||||
|
- websocket handler tests or explicit capability tests
|
||||||
|
|
||||||
|
### Task 4.3: Add Session Cleanup And Failure Recovery Semantics
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor.go), [sdk/cliproxy/auth/conductor.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/cliproxy/auth/conductor.go)
|
||||||
|
- **Description**: Add codex-like session cleanup, retry boundaries, and model suspension/resume behavior for GitLab failures and quota events.
|
||||||
|
- **Dependencies**: Sprint 2
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- auth/model cooldown behavior is predictable on GitLab 4xx/5xx/quota responses
|
||||||
|
- executor cleans up per-session resources if any are introduced
|
||||||
|
- **Validation**:
|
||||||
|
- tests for quota and retry behavior
|
||||||
|
|
||||||
|
## Sprint 5: Client UX, Model UX, And Manual E2E
|
||||||
|
**Goal**: Make GitLab Duo feel like a normal built-in provider to operators and downstream clients.
|
||||||
|
|
||||||
|
**Demo/Validation**:
|
||||||
|
- A documented setup exists for "login once, point Claude Code at CLIProxyAPI, use GitLab Duo-backed model".
|
||||||
|
|
||||||
|
### Task 5.1: Model Alias And Provider UX Cleanup
|
||||||
|
- **Location**: [sdk/cliproxy/service.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/cliproxy/service.go), [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md)
|
||||||
|
- **Description**: Normalize what users see:
|
||||||
|
- stable alias such as `gitlab-duo`
|
||||||
|
- discovered upstream model names
|
||||||
|
- optional prefix behavior
|
||||||
|
- account labels that clearly distinguish OAuth vs PAT
|
||||||
|
- **Dependencies**: Sprint 3
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- users can select a stable GitLab alias even when upstream model changes
|
||||||
|
- dynamic model discovery does not cause confusing model churn
|
||||||
|
- **Validation**:
|
||||||
|
- registry tests and manual `/v1/models` inspection
|
||||||
|
|
||||||
|
### Task 5.2: Add Real End-To-End Acceptance Tests
|
||||||
|
- **Location**: [internal/runtime/executor/gitlab_executor_test.go](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/internal/runtime/executor/gitlab_executor_test.go), [sdk/api/handlers/openai](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/sdk/api/handlers/openai)
|
||||||
|
- **Description**: Add higher-level tests covering the actual proxy surfaces:
|
||||||
|
- OpenAI `chat/completions`
|
||||||
|
- OpenAI `responses`
|
||||||
|
- Claude-compatible request path if GitLab is routed there
|
||||||
|
- **Dependencies**: Sprint 4
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- tests fail if streaming regresses into synthetic buffering again
|
||||||
|
- tests cover at least one tool-related request and one multi-turn request
|
||||||
|
- **Validation**:
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
### Task 5.3: Publish Operator Documentation
|
||||||
|
- **Location**: [README.md](/home/luxvtz/projects/cliproxyapi/CLIProxyAPI/README.md)
|
||||||
|
- **Description**: Document:
|
||||||
|
- OAuth setup requirements
|
||||||
|
- PAT requirements
|
||||||
|
- current capability matrix
|
||||||
|
- known limitations if websocket/tool parity is partial
|
||||||
|
- **Dependencies**: Sprint 5.1
|
||||||
|
- **Acceptance Criteria**:
|
||||||
|
- setup instructions are enough for a new user to reproduce the GitLab Duo flow
|
||||||
|
- limitations are explicit
|
||||||
|
- **Validation**:
|
||||||
|
- dry-run docs review from a clean environment
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- Keep `go test ./...` green after every committable task.
|
||||||
|
- Add table-driven tests first for request mapping, refresh behavior, and dynamic model registration.
|
||||||
|
- Add transport tests with `httptest.Server` for:
|
||||||
|
- real chunked streaming
|
||||||
|
- header propagation from `direct_access`
|
||||||
|
- upstream fallback rules
|
||||||
|
- Add at least one manual acceptance checklist:
|
||||||
|
- login via OAuth
|
||||||
|
- login via PAT
|
||||||
|
- list models
|
||||||
|
- run one streaming prompt via OpenAI route
|
||||||
|
- run one prompt from the target downstream client
|
||||||
|
|
||||||
|
## Potential Risks & Gotchas
|
||||||
|
- GitLab public docs expose `direct_access`, but do not fully document every possible AI gateway path. We should isolate any empirically discovered gateway assumptions behind one transport layer and feature flags.
|
||||||
|
- `chat/completions` availability differs by GitLab offering and version. The executor must not assume it always exists.
|
||||||
|
- Code Suggestions is completion-oriented; lossy mapping from rich chat/tool payloads will make GitLab Duo feel worse than codex unless explicitly handled.
|
||||||
|
- Synthetic streaming is not good enough for codex parity and will cause regressions in interactive clients.
|
||||||
|
- Dynamic model discovery can create unstable UX if the stable alias and discovered model IDs are not separated cleanly.
|
||||||
|
- PAT auth may validate successfully while still lacking effective Duo permissions. Error reporting must surface this explicitly.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
- Keep the current basic GitLab executor behind a fallback mode until the new transport path is stable.
|
||||||
|
- If parity work destabilizes existing providers, revert only GitLab-specific executor changes and leave auth support intact.
|
||||||
|
- Preserve the stable `gitlab-duo` alias so rollback does not break client configuration.
|
||||||
2
go.mod
2
go.mod
@@ -91,8 +91,8 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,13 +13,12 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAPICallTimeout = 60 * time.Second
|
const defaultAPICallTimeout = 60 * time.Second
|
||||||
@@ -725,47 +723,12 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildProxyTransport(proxyStr string) *http.Transport {
|
func buildProxyTransport(proxyStr string) *http.Transport {
|
||||||
proxyStr = strings.TrimSpace(proxyStr)
|
transport, _, errBuild := proxyutil.BuildHTTPTransport(proxyStr)
|
||||||
if proxyStr == "" {
|
if errBuild != nil {
|
||||||
|
log.WithError(errBuild).Debug("build proxy transport failed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return transport
|
||||||
proxyURL, errParse := url.Parse(proxyStr)
|
|
||||||
if errParse != nil {
|
|
||||||
log.WithError(errParse).Debug("parse proxy URL failed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if proxyURL.Scheme == "" || proxyURL.Host == "" {
|
|
||||||
log.Debug("proxy URL missing scheme/host")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxyURL.Scheme == "socks5" {
|
|
||||||
var proxyAuth *proxy.Auth
|
|
||||||
if proxyURL.User != nil {
|
|
||||||
username := proxyURL.User.Username()
|
|
||||||
password, _ := proxyURL.User.Password()
|
|
||||||
proxyAuth = &proxy.Auth{User: username, Password: password}
|
|
||||||
}
|
|
||||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
|
||||||
if errSOCKS5 != nil {
|
|
||||||
log.WithError(errSOCKS5).Debug("create SOCKS5 dialer failed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &http.Transport{
|
|
||||||
Proxy: nil,
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return dialer.Dial(network, addr)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
|
||||||
return &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerContainsValue checks whether a header map contains a target value (case-insensitive key and value).
|
// headerContainsValue checks whether a header map contains a target value (case-insensitive key and value).
|
||||||
|
|||||||
@@ -2,172 +2,112 @@ package management
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryAuthStore struct {
|
func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) {
|
||||||
mu sync.Mutex
|
t.Parallel()
|
||||||
items map[string]*coreauth.Auth
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memoryAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
|
h := &Handler{
|
||||||
_ = ctx
|
cfg: &config.Config{
|
||||||
s.mu.Lock()
|
SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"},
|
||||||
defer s.mu.Unlock()
|
|
||||||
out := make([]*coreauth.Auth, 0, len(s.items))
|
|
||||||
for _, a := range s.items {
|
|
||||||
out = append(out, a.Clone())
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memoryAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
|
||||||
_ = ctx
|
|
||||||
if auth == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.items == nil {
|
|
||||||
s.items = make(map[string]*coreauth.Auth)
|
|
||||||
}
|
|
||||||
s.items[auth.ID] = auth.Clone()
|
|
||||||
s.mu.Unlock()
|
|
||||||
return auth.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memoryAuthStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_ = ctx
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.items, id)
|
|
||||||
s.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveTokenForAuth_Antigravity_RefreshesExpiredToken(t *testing.T) {
|
|
||||||
var callCount int
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Fatalf("expected POST, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
|
||||||
t.Fatalf("unexpected content-type: %s", ct)
|
|
||||||
}
|
|
||||||
bodyBytes, _ := io.ReadAll(r.Body)
|
|
||||||
_ = r.Body.Close()
|
|
||||||
values, err := url.ParseQuery(string(bodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse form: %v", err)
|
|
||||||
}
|
|
||||||
if values.Get("grant_type") != "refresh_token" {
|
|
||||||
t.Fatalf("unexpected grant_type: %s", values.Get("grant_type"))
|
|
||||||
}
|
|
||||||
if values.Get("refresh_token") != "rt" {
|
|
||||||
t.Fatalf("unexpected refresh_token: %s", values.Get("refresh_token"))
|
|
||||||
}
|
|
||||||
if values.Get("client_id") != antigravityOAuthClientID {
|
|
||||||
t.Fatalf("unexpected client_id: %s", values.Get("client_id"))
|
|
||||||
}
|
|
||||||
if values.Get("client_secret") != antigravityOAuthClientSecret {
|
|
||||||
t.Fatalf("unexpected client_secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"access_token": "new-token",
|
|
||||||
"refresh_token": "rt2",
|
|
||||||
"expires_in": int64(3600),
|
|
||||||
"token_type": "Bearer",
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
originalURL := antigravityOAuthTokenURL
|
|
||||||
antigravityOAuthTokenURL = srv.URL
|
|
||||||
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
|
||||||
|
|
||||||
store := &memoryAuthStore{}
|
|
||||||
manager := coreauth.NewManager(store, nil, nil)
|
|
||||||
|
|
||||||
auth := &coreauth.Auth{
|
|
||||||
ID: "antigravity-test.json",
|
|
||||||
FileName: "antigravity-test.json",
|
|
||||||
Provider: "antigravity",
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"type": "antigravity",
|
|
||||||
"access_token": "old-token",
|
|
||||||
"refresh_token": "rt",
|
|
||||||
"expires_in": int64(3600),
|
|
||||||
"timestamp": time.Now().Add(-2 * time.Hour).UnixMilli(),
|
|
||||||
"expired": time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if _, err := manager.Register(context.Background(), auth); err != nil {
|
|
||||||
t.Fatalf("register auth: %v", err)
|
transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "direct"})
|
||||||
|
httpTransport, ok := transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport type = %T, want *http.Transport", transport)
|
||||||
|
}
|
||||||
|
if httpTransport.Proxy != nil {
|
||||||
|
t.Fatal("expected direct transport to disable proxy function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handler{
|
||||||
|
cfg: &config.Config{
|
||||||
|
SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "bad-value"})
|
||||||
|
httpTransport, ok := transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport type = %T, want *http.Transport", transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, errRequest := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||||
|
if errRequest != nil {
|
||||||
|
t.Fatalf("http.NewRequest returned error: %v", errRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL, errProxy := httpTransport.Proxy(req)
|
||||||
|
if errProxy != nil {
|
||||||
|
t.Fatalf("httpTransport.Proxy returned error: %v", errProxy)
|
||||||
|
}
|
||||||
|
if proxyURL == nil || proxyURL.String() != "http://global-proxy.example.com:8080" {
|
||||||
|
t.Fatalf("proxy URL = %v, want http://global-proxy.example.com:8080", proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
geminiAuth := &coreauth.Auth{
|
||||||
|
ID: "gemini:apikey:123",
|
||||||
|
Provider: "gemini",
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"api_key": "shared-key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
compatAuth := &coreauth.Auth{
|
||||||
|
ID: "openai-compatibility:bohe:456",
|
||||||
|
Provider: "bohe",
|
||||||
|
Label: "bohe",
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"api_key": "shared-key",
|
||||||
|
"compat_name": "bohe",
|
||||||
|
"provider_key": "bohe",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil {
|
||||||
|
t.Fatalf("register gemini auth: %v", errRegister)
|
||||||
|
}
|
||||||
|
if _, errRegister := manager.Register(context.Background(), compatAuth); errRegister != nil {
|
||||||
|
t.Fatalf("register compat auth: %v", errRegister)
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiIndex := geminiAuth.EnsureIndex()
|
||||||
|
compatIndex := compatAuth.EnsureIndex()
|
||||||
|
if geminiIndex == compatIndex {
|
||||||
|
t.Fatalf("shared api key produced duplicate auth_index %q", geminiIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handler{authManager: manager}
|
h := &Handler{authManager: manager}
|
||||||
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
|
||||||
if err != nil {
|
gotGemini := h.authByIndex(geminiIndex)
|
||||||
t.Fatalf("resolveTokenForAuth: %v", err)
|
if gotGemini == nil {
|
||||||
|
t.Fatal("expected gemini auth by index")
|
||||||
}
|
}
|
||||||
if token != "new-token" {
|
if gotGemini.ID != geminiAuth.ID {
|
||||||
t.Fatalf("expected refreshed token, got %q", token)
|
t.Fatalf("authByIndex(gemini) returned %q, want %q", gotGemini.ID, geminiAuth.ID)
|
||||||
}
|
|
||||||
if callCount != 1 {
|
|
||||||
t.Fatalf("expected 1 refresh call, got %d", callCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, ok := manager.GetByID(auth.ID)
|
gotCompat := h.authByIndex(compatIndex)
|
||||||
if !ok || updated == nil {
|
if gotCompat == nil {
|
||||||
t.Fatalf("expected auth in manager after update")
|
t.Fatal("expected compat auth by index")
|
||||||
}
|
}
|
||||||
if got := tokenValueFromMetadata(updated.Metadata); got != "new-token" {
|
if gotCompat.ID != compatAuth.ID {
|
||||||
t.Fatalf("expected manager metadata updated, got %q", got)
|
t.Fatalf("authByIndex(compat) returned %q, want %q", gotCompat.ID, compatAuth.ID)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveTokenForAuth_Antigravity_SkipsRefreshWhenTokenValid(t *testing.T) {
|
|
||||||
var callCount int
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
originalURL := antigravityOAuthTokenURL
|
|
||||||
antigravityOAuthTokenURL = srv.URL
|
|
||||||
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
|
||||||
|
|
||||||
auth := &coreauth.Auth{
|
|
||||||
ID: "antigravity-valid.json",
|
|
||||||
FileName: "antigravity-valid.json",
|
|
||||||
Provider: "antigravity",
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"type": "antigravity",
|
|
||||||
"access_token": "ok-token",
|
|
||||||
"expired": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
h := &Handler{}
|
|
||||||
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolveTokenForAuth: %v", err)
|
|
||||||
}
|
|
||||||
if token != "ok-token" {
|
|
||||||
t.Fatalf("expected existing token, got %q", token)
|
|
||||||
}
|
|
||||||
if callCount != 0 {
|
|
||||||
t.Fatalf("expected no refresh calls, got %d", callCount)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
197
internal/api/handlers/management/auth_files_batch_test.go
Normal file
197
internal/api/handlers/management/auth_files_batch_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadAuthFile_BatchMultipart(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: "alpha.json", content: `{"type":"codex","email":"alpha@example.com"}`},
|
||||||
|
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
for _, file := range files {
|
||||||
|
part, err := writer.CreateFormFile("file", file.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||||
|
t.Fatalf("failed to write multipart content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.UploadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got, ok := payload["uploaded"].(float64); !ok || int(got) != len(files) {
|
||||||
|
t.Fatalf("expected uploaded=%d, got %#v", len(files), payload["uploaded"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
fullPath := filepath.Join(authDir, file.name)
|
||||||
|
data, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected uploaded file %s to exist: %v", file.name, err)
|
||||||
|
}
|
||||||
|
if string(data) != file.content {
|
||||||
|
t.Fatalf("expected file %s content %q, got %q", file.name, file.content, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auths := manager.List()
|
||||||
|
if len(auths) != len(files) {
|
||||||
|
t.Fatalf("expected %d auth entries, got %d", len(files), len(auths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
|
||||||
|
existingName := "alpha.json"
|
||||||
|
existingContent := `{"type":"codex","email":"alpha@example.com"}`
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, existingName), []byte(existingContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to seed existing auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: existingName, content: `{"type":"codex"`},
|
||||||
|
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
for _, file := range files {
|
||||||
|
part, err := writer.CreateFormFile("file", file.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||||
|
t.Fatalf("failed to write multipart content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.UploadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMultiStatus {
|
||||||
|
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusMultiStatus, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(authDir, existingName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected existing auth file to remain readable: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != existingContent {
|
||||||
|
t.Fatalf("expected existing auth file to remain %q, got %q", existingContent, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
betaData, err := os.ReadFile(filepath.Join(authDir, "beta.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected valid auth file to be created: %v", err)
|
||||||
|
}
|
||||||
|
if string(betaData) != files[1].content {
|
||||||
|
t.Fatalf("expected beta auth file content %q, got %q", files[1].content, string(betaData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAuthFile_BatchQuery(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
files := []string{"alpha.json", "beta.json"}
|
||||||
|
for _, name := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, name), []byte(`{"type":"codex"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
h.tokenStore = &memoryAuthStore{}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodDelete,
|
||||||
|
"/v0/management/auth-files?name="+url.QueryEscape(files[0])+"&name="+url.QueryEscape(files[1]),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.DeleteAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got, ok := payload["deleted"].(float64); !ok || int(got) != len(files) {
|
||||||
|
t.Fatalf("expected deleted=%d, got %#v", len(files), payload["deleted"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range files {
|
||||||
|
if _, err := os.Stat(filepath.Join(authDir, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected auth file %s to be removed, stat err: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/api/handlers/management/auth_files_delete_test.go
Normal file
129
internal/api/handlers/management/auth_files_delete_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tempDir, "auth")
|
||||||
|
externalDir := filepath.Join(tempDir, "external")
|
||||||
|
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
|
||||||
|
}
|
||||||
|
if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {
|
||||||
|
t.Fatalf("failed to create external dir: %v", errMkdirExternal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := "codex-user@example.com-plus.json"
|
||||||
|
shadowPath := filepath.Join(authDir, fileName)
|
||||||
|
realPath := filepath.Join(externalDir, fileName)
|
||||||
|
if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil {
|
||||||
|
t.Fatalf("failed to write shadow file: %v", errWriteShadow)
|
||||||
|
}
|
||||||
|
if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil {
|
||||||
|
t.Fatalf("failed to write real file: %v", errWriteReal)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
record := &coreauth.Auth{
|
||||||
|
ID: "legacy/" + fileName,
|
||||||
|
FileName: fileName,
|
||||||
|
Provider: "codex",
|
||||||
|
Status: coreauth.StatusError,
|
||||||
|
Unavailable: true,
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"path": realPath,
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"type": "codex",
|
||||||
|
"email": "real@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||||
|
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
h.tokenStore = &memoryAuthStore{}
|
||||||
|
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||||
|
deleteCtx.Request = deleteReq
|
||||||
|
h.DeleteAuthFile(deleteCtx)
|
||||||
|
|
||||||
|
if deleteRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||||
|
}
|
||||||
|
if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {
|
||||||
|
t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal)
|
||||||
|
}
|
||||||
|
if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {
|
||||||
|
t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
listRec := httptest.NewRecorder()
|
||||||
|
listCtx, _ := gin.CreateTestContext(listRec)
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
|
||||||
|
listCtx.Request = listReq
|
||||||
|
h.ListAuthFiles(listCtx)
|
||||||
|
|
||||||
|
if listRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String())
|
||||||
|
}
|
||||||
|
var listPayload map[string]any
|
||||||
|
if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {
|
||||||
|
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
|
||||||
|
}
|
||||||
|
filesRaw, ok := listPayload["files"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected files array, payload: %#v", listPayload)
|
||||||
|
}
|
||||||
|
if len(filesRaw) != 0 {
|
||||||
|
t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
fileName := "fallback-user.json"
|
||||||
|
filePath := filepath.Join(authDir, fileName)
|
||||||
|
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
h.tokenStore = &memoryAuthStore{}
|
||||||
|
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||||
|
deleteCtx.Request = deleteReq
|
||||||
|
h.DeleteAuthFile(deleteCtx)
|
||||||
|
|
||||||
|
if deleteRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||||
|
}
|
||||||
|
if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {
|
||||||
|
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/api/handlers/management/auth_files_download_test.go
Normal file
62
internal/api/handlers/management/auth_files_download_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_ReturnsFile(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
fileName := "download-user.json"
|
||||||
|
expected := []byte(`{"type":"codex"}`)
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, fileName), expected, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(fileName), nil)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected download status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Body.Bytes(); string(got) != string(expected) {
|
||||||
|
t.Fatalf("unexpected download content: %q", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"../external/secret.json",
|
||||||
|
`..\\external\\secret.json`,
|
||||||
|
"nested/secret.json",
|
||||||
|
`nested\\secret.json`,
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(name), nil)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected %d for name %q, got %d with body %s", http.StatusBadRequest, name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tempDir, "auth")
|
||||||
|
externalDir := filepath.Join(tempDir, "external")
|
||||||
|
if err := os.MkdirAll(authDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(externalDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("failed to create external dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretName := "secret.json"
|
||||||
|
secretPath := filepath.Join(externalDir, secretName)
|
||||||
|
if err := os.WriteFile(secretPath, []byte(`{"secret":true}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write external file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/v0/management/auth-files/download?name="+url.QueryEscape("../external/"+secretName),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status %d, got %d with body %s", http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
164
internal/api/handlers/management/auth_files_gitlab_test.go
Normal file
164
internal/api/handlers/management/auth_files_gitlab_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestGitLabPATToken_SavesAuthRecord(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer glpat-test-token" {
|
||||||
|
t.Fatalf("authorization header = %q, want Bearer glpat-test-token", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v4/user":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 42,
|
||||||
|
"username": "gitlab-user",
|
||||||
|
"name": "GitLab User",
|
||||||
|
"email": "gitlab@example.com",
|
||||||
|
})
|
||||||
|
case "/api/v4/personal_access_tokens/self":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 7,
|
||||||
|
"name": "management-center",
|
||||||
|
"scopes": []string{"api", "read_user"},
|
||||||
|
"user_id": 42,
|
||||||
|
})
|
||||||
|
case "/api/v4/code_suggestions/direct_access":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"base_url": "https://cloud.gitlab.example.com",
|
||||||
|
"token": "gateway-token",
|
||||||
|
"expires_at": 1893456000,
|
||||||
|
"headers": map[string]string{
|
||||||
|
"X-Gitlab-Realm": "saas",
|
||||||
|
},
|
||||||
|
"model_details": map[string]any{
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
store := &memoryAuthStore{}
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, coreauth.NewManager(nil, nil, nil))
|
||||||
|
h.tokenStore = store
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/v0/management/gitlab-auth-url", strings.NewReader(`{"base_url":"`+upstream.URL+`","personal_access_token":"glpat-test-token"}`))
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
h.RequestGitLabPATToken(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got := resp["status"]; got != "ok" {
|
||||||
|
t.Fatalf("status = %#v, want ok", got)
|
||||||
|
}
|
||||||
|
if got := resp["model_provider"]; got != "anthropic" {
|
||||||
|
t.Fatalf("model_provider = %#v, want anthropic", got)
|
||||||
|
}
|
||||||
|
if got := resp["model_name"]; got != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("model_name = %#v, want claude-sonnet-4-5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
if len(store.items) != 1 {
|
||||||
|
t.Fatalf("expected 1 saved auth record, got %d", len(store.items))
|
||||||
|
}
|
||||||
|
var saved *coreauth.Auth
|
||||||
|
for _, item := range store.items {
|
||||||
|
saved = item
|
||||||
|
}
|
||||||
|
if saved == nil {
|
||||||
|
t.Fatal("expected saved auth record")
|
||||||
|
}
|
||||||
|
if saved.Provider != "gitlab" {
|
||||||
|
t.Fatalf("provider = %q, want gitlab", saved.Provider)
|
||||||
|
}
|
||||||
|
if got := saved.Metadata["auth_kind"]; got != "personal_access_token" {
|
||||||
|
t.Fatalf("auth_kind = %#v, want personal_access_token", got)
|
||||||
|
}
|
||||||
|
if got := saved.Metadata["model_provider"]; got != "anthropic" {
|
||||||
|
t.Fatalf("saved model_provider = %#v, want anthropic", got)
|
||||||
|
}
|
||||||
|
if got := saved.Metadata["duo_gateway_token"]; got != "gateway-token" {
|
||||||
|
t.Fatalf("saved duo_gateway_token = %#v, want gateway-token", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostOAuthCallback_GitLabWritesPendingCallbackFile(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
state := "gitlab-state-123"
|
||||||
|
RegisterOAuthSession(state, "gitlab")
|
||||||
|
t.Cleanup(func() { CompleteOAuthSession(state) })
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, coreauth.NewManager(nil, nil, nil))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/v0/management/oauth-callback", strings.NewReader(`{"provider":"gitlab","redirect_url":"http://localhost:17171/auth/callback?code=test-code&state=`+state+`"}`))
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
h.PostOAuthCallback(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(authDir, ".oauth-gitlab-"+state+".oauth")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read callback file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
t.Fatalf("decode callback payload: %v", err)
|
||||||
|
}
|
||||||
|
if got := payload["code"]; got != "test-code" {
|
||||||
|
t.Fatalf("callback code = %q, want test-code", got)
|
||||||
|
}
|
||||||
|
if got := payload["state"]; got != state {
|
||||||
|
t.Fatalf("callback state = %q, want %q", got, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOAuthProvider_GitLab(t *testing.T) {
|
||||||
|
provider, err := NormalizeOAuthProvider("gitlab")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeOAuthProvider returned error: %v", err)
|
||||||
|
}
|
||||||
|
if provider != "gitlab" {
|
||||||
|
t.Fatalf("provider = %q, want gitlab", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -509,8 +509,12 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
for i := range arr {
|
for i := range arr {
|
||||||
normalizeVertexCompatKey(&arr[i])
|
normalizeVertexCompatKey(&arr[i])
|
||||||
|
if arr[i].APIKey == "" {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("vertex-api-key[%d].api-key is required", i)})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
h.cfg.VertexCompatAPIKey = arr
|
}
|
||||||
|
h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)
|
||||||
h.cfg.SanitizeVertexCompatKeys()
|
h.cfg.SanitizeVertexCompatKeys()
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
@@ -522,6 +526,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
|||||||
ProxyURL *string `json:"proxy-url"`
|
ProxyURL *string `json:"proxy-url"`
|
||||||
Headers *map[string]string `json:"headers"`
|
Headers *map[string]string `json:"headers"`
|
||||||
Models *[]config.VertexCompatModel `json:"models"`
|
Models *[]config.VertexCompatModel `json:"models"`
|
||||||
|
ExcludedModels *[]string `json:"excluded-models"`
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Index *int `json:"index"`
|
Index *int `json:"index"`
|
||||||
@@ -585,6 +590,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
|||||||
if body.Value.Models != nil {
|
if body.Value.Models != nil {
|
||||||
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
||||||
}
|
}
|
||||||
|
if body.Value.ExcludedModels != nil {
|
||||||
|
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
||||||
|
}
|
||||||
normalizeVertexCompatKey(&entry)
|
normalizeVertexCompatKey(&entry)
|
||||||
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
||||||
h.cfg.SanitizeVertexCompatKeys()
|
h.cfg.SanitizeVertexCompatKeys()
|
||||||
@@ -1029,6 +1037,7 @@ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
|||||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
if len(entry.Models) == 0 {
|
if len(entry.Models) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ func NormalizeOAuthProvider(provider string) (string, error) {
|
|||||||
return "anthropic", nil
|
return "anthropic", nil
|
||||||
case "codex", "openai":
|
case "codex", "openai":
|
||||||
return "codex", nil
|
return "codex", nil
|
||||||
|
case "gitlab":
|
||||||
|
return "gitlab", nil
|
||||||
case "gemini", "google":
|
case "gemini", "google":
|
||||||
return "gemini", nil
|
return "gemini", nil
|
||||||
case "iflow", "i-flow":
|
case "iflow", "i-flow":
|
||||||
|
|||||||
49
internal/api/handlers/management/test_store_test.go
Normal file
49
internal/api/handlers/management/test_store_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryAuthStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]*coreauth.Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuthStore) List(_ context.Context) ([]*coreauth.Auth, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
out := make([]*coreauth.Auth, 0, len(s.items))
|
||||||
|
for _, item := range s.items {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {
|
||||||
|
if auth == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.items == nil {
|
||||||
|
s.items = make(map[string]*coreauth.Auth)
|
||||||
|
}
|
||||||
|
s.items[auth.ID] = auth
|
||||||
|
return auth.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuthStore) Delete(_ context.Context, id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
delete(s.items, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuthStore) SetBaseDir(string) {}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,6 +78,9 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
|
|||||||
req.Header.Del("X-Api-Key")
|
req.Header.Del("X-Api-Key")
|
||||||
req.Header.Del("X-Goog-Api-Key")
|
req.Header.Del("X-Goog-Api-Key")
|
||||||
|
|
||||||
|
// Remove proxy, client identity, and browser fingerprint headers
|
||||||
|
misc.ScrubProxyAndFingerprintHeaders(req)
|
||||||
|
|
||||||
// Remove query-based credentials if they match the authenticated client API key.
|
// Remove query-based credentials if they match the authenticated client API key.
|
||||||
// This prevents leaking client auth material to the Amp upstream while avoiding
|
// This prevents leaking client auth material to the Amp upstream while avoiding
|
||||||
// breaking unrelated upstream query parameters.
|
// breaking unrelated upstream query parameters.
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
|||||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||||
s.applyAccessConfig(nil, cfg)
|
s.applyAccessConfig(nil, cfg)
|
||||||
if authManager != nil {
|
if authManager != nil {
|
||||||
authManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second)
|
authManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||||
}
|
}
|
||||||
managementasset.SetCurrentConfig(cfg)
|
managementasset.SetCurrentConfig(cfg)
|
||||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||||
@@ -403,6 +403,20 @@ func (s *Server) setupRoutes() {
|
|||||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
s.engine.GET("/gitlab/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
if errStr == "" {
|
||||||
|
errStr = c.Query("error_description")
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "gitlab", state, code, errStr)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||||
|
})
|
||||||
|
|
||||||
s.engine.GET("/google/callback", func(c *gin.Context) {
|
s.engine.GET("/google/callback", func(c *gin.Context) {
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
@@ -658,6 +672,8 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
|
|
||||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||||
|
mgmt.GET("/gitlab-auth-url", s.mgmt.RequestGitLabToken)
|
||||||
|
mgmt.POST("/gitlab-auth-url", s.mgmt.RequestGitLabPATToken)
|
||||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
||||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||||
@@ -666,6 +682,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||||
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
||||||
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
|
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
|
||||||
|
mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken)
|
||||||
mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken)
|
mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken)
|
||||||
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
|
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
|
||||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||||
@@ -944,7 +961,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.handlers != nil && s.handlers.AuthManager != nil {
|
if s.handlers != nil && s.handlers.AuthManager != nil {
|
||||||
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second)
|
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update log level dynamically when debug flag changes
|
// Update log level dynamically when debug flag changes
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ package claude
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tls "github.com/refraction-networking/utls"
|
tls "github.com/refraction-networking/utls"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
@@ -31,17 +31,12 @@ type utlsRoundTripper struct {
|
|||||||
// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support
|
// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support
|
||||||
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
|
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
|
||||||
var dialer proxy.Dialer = proxy.Direct
|
var dialer proxy.Dialer = proxy.Direct
|
||||||
if cfg != nil && cfg.ProxyURL != "" {
|
if cfg != nil {
|
||||||
proxyURL, err := url.Parse(cfg.ProxyURL)
|
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
|
||||||
if err != nil {
|
if errBuild != nil {
|
||||||
log.Errorf("failed to parse proxy URL %q: %v", cfg.ProxyURL, err)
|
log.Errorf("failed to configure proxy dialer for %q: %v", cfg.ProxyURL, errBuild)
|
||||||
} else {
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
||||||
pDialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
dialer = proxyDialer
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to create proxy dialer for %q: %v", cfg.ProxyURL, err)
|
|
||||||
} else {
|
|
||||||
dialer = pDialer
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
335
internal/auth/codebuddy/codebuddy_auth.go
Normal file
335
internal/auth/codebuddy/codebuddy_auth.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package codebuddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BaseURL = "https://copilot.tencent.com"
|
||||||
|
DefaultDomain = "www.codebuddy.cn"
|
||||||
|
UserAgent = "CLI/2.63.2 CodeBuddy/2.63.2"
|
||||||
|
|
||||||
|
codeBuddyStatePath = "/v2/plugin/auth/state"
|
||||||
|
codeBuddyTokenPath = "/v2/plugin/auth/token"
|
||||||
|
codeBuddyRefreshPath = "/v2/plugin/auth/token/refresh"
|
||||||
|
pollInterval = 5 * time.Second
|
||||||
|
maxPollDuration = 5 * time.Minute
|
||||||
|
codeLoginPending = 11217
|
||||||
|
codeSuccess = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodeBuddyAuth struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
cfg *config.Config
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCodeBuddyAuth(cfg *config.Config) *CodeBuddyAuth {
|
||||||
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
if cfg != nil {
|
||||||
|
httpClient = util.SetProxy(&cfg.SDKConfig, httpClient)
|
||||||
|
}
|
||||||
|
return &CodeBuddyAuth{httpClient: httpClient, cfg: cfg, baseURL: BaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthState holds the state and auth URL returned by the auth state API.
|
||||||
|
type AuthState struct {
|
||||||
|
State string
|
||||||
|
AuthURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAuthState calls POST /v2/plugin/auth/state?platform=CLI to get the state and login URL.
|
||||||
|
func (a *CodeBuddyAuth) FetchAuthState(ctx context.Context) (*AuthState, error) {
|
||||||
|
stateURL := fmt.Sprintf("%s%s?platform=CLI", a.baseURL, codeBuddyStatePath)
|
||||||
|
body := []byte("{}")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, stateURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to create auth state request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := uuid.NewString()
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
req.Header.Set("X-Domain", "copilot.tencent.com")
|
||||||
|
req.Header.Set("X-No-Authorization", "true")
|
||||||
|
req.Header.Set("X-No-User-Id", "true")
|
||||||
|
req.Header.Set("X-No-Enterprise-Id", "true")
|
||||||
|
req.Header.Set("X-No-Department-Info", "true")
|
||||||
|
req.Header.Set("X-Product", "SaaS")
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
req.Header.Set("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: auth state request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("codebuddy auth state: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to read auth state response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("codebuddy: auth state request returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data *struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
AuthURL string `json:"authUrl"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to parse auth state response: %w", err)
|
||||||
|
}
|
||||||
|
if result.Code != codeSuccess {
|
||||||
|
return nil, fmt.Errorf("codebuddy: auth state request failed with code %d: %s", result.Code, result.Msg)
|
||||||
|
}
|
||||||
|
if result.Data == nil || result.Data.State == "" || result.Data.AuthURL == "" {
|
||||||
|
return nil, fmt.Errorf("codebuddy: auth state response missing state or authUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthState{
|
||||||
|
State: result.Data.State,
|
||||||
|
AuthURL: result.Data.AuthURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type pollResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
Data *struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// doPollRequest performs a single polling request, safely reading and closing the response body
|
||||||
|
func (a *CodeBuddyAuth) doPollRequest(ctx context.Context, pollURL string) ([]byte, int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("%w: %v", ErrTokenFetchFailed, err)
|
||||||
|
}
|
||||||
|
a.applyPollHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("codebuddy poll: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("codebuddy poll: failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollForToken polls until the user completes browser authorization and returns auth data.
|
||||||
|
func (a *CodeBuddyAuth) PollForToken(ctx context.Context, state string) (*CodeBuddyTokenStorage, error) {
|
||||||
|
deadline := time.Now().Add(maxPollDuration)
|
||||||
|
pollURL := fmt.Sprintf("%s%s?state=%s", a.baseURL, codeBuddyTokenPath, url.QueryEscape(state))
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
body, statusCode, err := a.doPollRequest(ctx, pollURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("codebuddy poll: request error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
log.Debugf("codebuddy poll: unexpected status %d", statusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var result pollResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result.Code {
|
||||||
|
case codeSuccess:
|
||||||
|
if result.Data == nil {
|
||||||
|
return nil, fmt.Errorf("%w: empty data in response", ErrTokenFetchFailed)
|
||||||
|
}
|
||||||
|
userID, _ := a.DecodeUserID(result.Data.AccessToken)
|
||||||
|
return &CodeBuddyTokenStorage{
|
||||||
|
AccessToken: result.Data.AccessToken,
|
||||||
|
RefreshToken: result.Data.RefreshToken,
|
||||||
|
ExpiresIn: result.Data.ExpiresIn,
|
||||||
|
TokenType: result.Data.TokenType,
|
||||||
|
Domain: result.Data.Domain,
|
||||||
|
UserID: userID,
|
||||||
|
Type: "codebuddy",
|
||||||
|
}, nil
|
||||||
|
case codeLoginPending:
|
||||||
|
// continue polling
|
||||||
|
default:
|
||||||
|
// TODO: when the CodeBuddy API error code for user denial is known,
|
||||||
|
// return ErrAccessDenied here instead of ErrTokenFetchFailed.
|
||||||
|
return nil, fmt.Errorf("%w: server returned code %d: %s", ErrTokenFetchFailed, result.Code, result.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrPollingTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeUserID decodes the sub field from a JWT access token as the user ID.
|
||||||
|
func (a *CodeBuddyAuth) DecodeUserID(accessToken string) (string, error) {
|
||||||
|
parts := strings.Split(accessToken, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", ErrJWTDecodeFailed
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrJWTDecodeFailed, err)
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrJWTDecodeFailed, err)
|
||||||
|
}
|
||||||
|
if claims.Sub == "" {
|
||||||
|
return "", fmt.Errorf("%w: sub claim is empty", ErrJWTDecodeFailed)
|
||||||
|
}
|
||||||
|
return claims.Sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken exchanges a refresh token for a new access token.
|
||||||
|
// It calls POST /v2/plugin/auth/token/refresh with the required headers.
|
||||||
|
func (a *CodeBuddyAuth) RefreshToken(ctx context.Context, accessToken, refreshToken, userID, domain string) (*CodeBuddyTokenStorage, error) {
|
||||||
|
if domain == "" {
|
||||||
|
domain = DefaultDomain
|
||||||
|
}
|
||||||
|
refreshURL := fmt.Sprintf("%s%s", a.baseURL, codeBuddyRefreshPath)
|
||||||
|
body := []byte("{}")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to create refresh request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
req.Header.Set("X-Domain", domain)
|
||||||
|
req.Header.Set("X-Refresh-Token", refreshToken)
|
||||||
|
req.Header.Set("X-Auth-Refresh-Source", "plugin")
|
||||||
|
req.Header.Set("X-Request-ID", requestID)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("X-User-Id", userID)
|
||||||
|
req.Header.Set("X-Product", "SaaS")
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("codebuddy refresh: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to read refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("codebuddy: refresh token rejected (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("codebuddy: refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data *struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
RefreshExpiresIn int64 `json:"refreshExpiresIn"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: failed to parse refresh response: %w", err)
|
||||||
|
}
|
||||||
|
if result.Code != codeSuccess {
|
||||||
|
return nil, fmt.Errorf("codebuddy: refresh failed with code %d: %s", result.Code, result.Msg)
|
||||||
|
}
|
||||||
|
if result.Data == nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: empty data in refresh response")
|
||||||
|
}
|
||||||
|
|
||||||
|
newUserID, _ := a.DecodeUserID(result.Data.AccessToken)
|
||||||
|
if newUserID == "" {
|
||||||
|
newUserID = userID
|
||||||
|
}
|
||||||
|
tokenDomain := result.Data.Domain
|
||||||
|
if tokenDomain == "" {
|
||||||
|
tokenDomain = domain
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CodeBuddyTokenStorage{
|
||||||
|
AccessToken: result.Data.AccessToken,
|
||||||
|
RefreshToken: result.Data.RefreshToken,
|
||||||
|
ExpiresIn: result.Data.ExpiresIn,
|
||||||
|
RefreshExpiresIn: result.Data.RefreshExpiresIn,
|
||||||
|
TokenType: result.Data.TokenType,
|
||||||
|
Domain: tokenDomain,
|
||||||
|
UserID: newUserID,
|
||||||
|
Type: "codebuddy",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CodeBuddyAuth) applyPollHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
req.Header.Set("X-No-Authorization", "true")
|
||||||
|
req.Header.Set("X-No-User-Id", "true")
|
||||||
|
req.Header.Set("X-No-Enterprise-Id", "true")
|
||||||
|
req.Header.Set("X-No-Department-Info", "true")
|
||||||
|
req.Header.Set("X-Product", "SaaS")
|
||||||
|
}
|
||||||
285
internal/auth/codebuddy/codebuddy_auth_http_test.go
Normal file
285
internal/auth/codebuddy/codebuddy_auth_http_test.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
package codebuddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestAuth creates a CodeBuddyAuth pointing at the given test server.
|
||||||
|
func newTestAuth(serverURL string) *CodeBuddyAuth {
|
||||||
|
return &CodeBuddyAuth{
|
||||||
|
httpClient: http.DefaultClient,
|
||||||
|
baseURL: serverURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeJWT builds a minimal JWT with the given sub claim for testing.
|
||||||
|
func fakeJWT(sub string) string {
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`))
|
||||||
|
payload, _ := json.Marshal(map[string]any{"sub": sub, "iat": 1234567890})
|
||||||
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
return header + "." + encodedPayload + ".sig"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FetchAuthState tests ---
|
||||||
|
|
||||||
|
func TestFetchAuthState_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.URL.Path; got != codeBuddyStatePath {
|
||||||
|
t.Errorf("expected path %s, got %s", codeBuddyStatePath, got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("platform"); got != "CLI" {
|
||||||
|
t.Errorf("expected platform=CLI, got %s", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("User-Agent"); got != UserAgent {
|
||||||
|
t.Errorf("expected User-Agent %s, got %s", UserAgent, got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"state": "test-state-abc",
|
||||||
|
"authUrl": "https://example.com/login?state=test-state-abc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
result, err := auth.FetchAuthState(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.State != "test-state-abc" {
|
||||||
|
t.Errorf("expected state 'test-state-abc', got '%s'", result.State)
|
||||||
|
}
|
||||||
|
if result.AuthURL != "https://example.com/login?state=test-state-abc" {
|
||||||
|
t.Errorf("unexpected authURL: %s", result.AuthURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAuthState_NonOKStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("internal error"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.FetchAuthState(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-200 status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAuthState_APIErrorCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 10001,
|
||||||
|
"msg": "rate limited",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.FetchAuthState(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAuthState_MissingData(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"state": "",
|
||||||
|
"authUrl": "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.FetchAuthState(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty state/authUrl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RefreshToken tests ---
|
||||||
|
|
||||||
|
func TestRefreshToken_Success(t *testing.T) {
|
||||||
|
newAccessToken := fakeJWT("refreshed-user-456")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.URL.Path; got != codeBuddyRefreshPath {
|
||||||
|
t.Errorf("expected path %s, got %s", codeBuddyRefreshPath, got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-Refresh-Token"); got != "old-refresh-token" {
|
||||||
|
t.Errorf("expected X-Refresh-Token 'old-refresh-token', got '%s'", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer old-access-token" {
|
||||||
|
t.Errorf("expected Authorization 'Bearer old-access-token', got '%s'", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-User-Id"); got != "user-123" {
|
||||||
|
t.Errorf("expected X-User-Id 'user-123', got '%s'", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-Domain"); got != "custom.domain.com" {
|
||||||
|
t.Errorf("expected X-Domain 'custom.domain.com', got '%s'", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"accessToken": newAccessToken,
|
||||||
|
"refreshToken": "new-refresh-token",
|
||||||
|
"expiresIn": 3600,
|
||||||
|
"refreshExpiresIn": 86400,
|
||||||
|
"tokenType": "bearer",
|
||||||
|
"domain": "custom.domain.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
storage, err := auth.RefreshToken(context.Background(), "old-access-token", "old-refresh-token", "user-123", "custom.domain.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if storage.AccessToken != newAccessToken {
|
||||||
|
t.Errorf("expected new access token, got '%s'", storage.AccessToken)
|
||||||
|
}
|
||||||
|
if storage.RefreshToken != "new-refresh-token" {
|
||||||
|
t.Errorf("expected 'new-refresh-token', got '%s'", storage.RefreshToken)
|
||||||
|
}
|
||||||
|
if storage.UserID != "refreshed-user-456" {
|
||||||
|
t.Errorf("expected userID 'refreshed-user-456', got '%s'", storage.UserID)
|
||||||
|
}
|
||||||
|
if storage.ExpiresIn != 3600 {
|
||||||
|
t.Errorf("expected expiresIn 3600, got %d", storage.ExpiresIn)
|
||||||
|
}
|
||||||
|
if storage.RefreshExpiresIn != 86400 {
|
||||||
|
t.Errorf("expected refreshExpiresIn 86400, got %d", storage.RefreshExpiresIn)
|
||||||
|
}
|
||||||
|
if storage.Domain != "custom.domain.com" {
|
||||||
|
t.Errorf("expected domain 'custom.domain.com', got '%s'", storage.Domain)
|
||||||
|
}
|
||||||
|
if storage.Type != "codebuddy" {
|
||||||
|
t.Errorf("expected type 'codebuddy', got '%s'", storage.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshToken_DefaultDomain(t *testing.T) {
|
||||||
|
var receivedDomain string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedDomain = r.Header.Get("X-Domain")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"accessToken": fakeJWT("user-1"),
|
||||||
|
"refreshToken": "rt",
|
||||||
|
"expiresIn": 3600,
|
||||||
|
"tokenType": "bearer",
|
||||||
|
"domain": DefaultDomain,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if receivedDomain != DefaultDomain {
|
||||||
|
t.Errorf("expected default domain '%s', got '%s'", DefaultDomain, receivedDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshToken_Unauthorized(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 401 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshToken_Forbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshToken_APIErrorCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 40001,
|
||||||
|
"msg": "invalid refresh token",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
_, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero API code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshToken_FallbackUserIDAndDomain(t *testing.T) {
|
||||||
|
// When the new access token cannot be decoded for userID, it should fall back to the provided one.
|
||||||
|
// When the response domain is empty, it should fall back to the request domain.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"accessToken": "not-a-valid-jwt",
|
||||||
|
"refreshToken": "new-rt",
|
||||||
|
"expiresIn": 7200,
|
||||||
|
"tokenType": "bearer",
|
||||||
|
"domain": "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
auth := newTestAuth(srv.URL)
|
||||||
|
storage, err := auth.RefreshToken(context.Background(), "at", "rt", "original-uid", "original.domain.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if storage.UserID != "original-uid" {
|
||||||
|
t.Errorf("expected fallback userID 'original-uid', got '%s'", storage.UserID)
|
||||||
|
}
|
||||||
|
if storage.Domain != "original.domain.com" {
|
||||||
|
t.Errorf("expected fallback domain 'original.domain.com', got '%s'", storage.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/auth/codebuddy/codebuddy_auth_test.go
Normal file
22
internal/auth/codebuddy/codebuddy_auth_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package codebuddy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codebuddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeUserID_ValidJWT(t *testing.T) {
|
||||||
|
// JWT payload: {"sub":"test-user-id-123","iat":1234567890}
|
||||||
|
// base64url encode: eyJzdWIiOiJ0ZXN0LXVzZXItaWQtMTIzIiwiaWF0IjoxMjM0NTY3ODkwfQ
|
||||||
|
token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQtMTIzIiwiaWF0IjoxMjM0NTY3ODkwfQ.sig"
|
||||||
|
auth := codebuddy.NewCodeBuddyAuth(nil)
|
||||||
|
userID, err := auth.DecodeUserID(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if userID != "test-user-id-123" {
|
||||||
|
t.Errorf("expected 'test-user-id-123', got '%s'", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
25
internal/auth/codebuddy/errors.go
Normal file
25
internal/auth/codebuddy/errors.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package codebuddy
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPollingTimeout = errors.New("codebuddy: polling timeout, user did not authorize in time")
|
||||||
|
ErrAccessDenied = errors.New("codebuddy: access denied by user")
|
||||||
|
ErrTokenFetchFailed = errors.New("codebuddy: failed to fetch token from server")
|
||||||
|
ErrJWTDecodeFailed = errors.New("codebuddy: failed to decode JWT token")
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetUserFriendlyMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrPollingTimeout):
|
||||||
|
return "Authentication timed out. Please try again."
|
||||||
|
case errors.Is(err, ErrAccessDenied):
|
||||||
|
return "Access denied. Please try again and approve the login request."
|
||||||
|
case errors.Is(err, ErrJWTDecodeFailed):
|
||||||
|
return "Failed to decode token. Please try logging in again."
|
||||||
|
case errors.Is(err, ErrTokenFetchFailed):
|
||||||
|
return "Failed to fetch token from server. Please try again."
|
||||||
|
default:
|
||||||
|
return "Authentication failed: " + err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
internal/auth/codebuddy/token.go
Normal file
65
internal/auth/codebuddy/token.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Package codebuddy provides authentication and token management functionality
|
||||||
|
// for CodeBuddy AI services. It handles OAuth2 token storage, serialization,
|
||||||
|
// and retrieval for maintaining authenticated sessions with the CodeBuddy API.
|
||||||
|
package codebuddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeBuddyTokenStorage stores OAuth token information for CodeBuddy API authentication.
|
||||||
|
// It maintains compatibility with the existing auth system while adding CodeBuddy-specific fields
|
||||||
|
// for managing access tokens and user account information.
|
||||||
|
type CodeBuddyTokenStorage struct {
|
||||||
|
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is the OAuth2 refresh token used to obtain new access tokens.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// ExpiresIn is the number of seconds until the access token expires.
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
// RefreshExpiresIn is the number of seconds until the refresh token expires.
|
||||||
|
RefreshExpiresIn int64 `json:"refresh_expires_in,omitempty"`
|
||||||
|
// TokenType is the type of token, typically "bearer".
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// Domain is the CodeBuddy service domain/region.
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
// UserID is the user ID associated with this token.
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
// Type indicates the authentication provider type, always "codebuddy" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the CodeBuddy token storage to a JSON file.
|
||||||
|
// This method creates the necessary directory structure and writes the token
|
||||||
|
// data in JSON format to the specified file path for persistent storage.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - authFilePath: The full path where the token file should be saved
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: An error if the operation fails, nil otherwise
|
||||||
|
func (s *CodeBuddyTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
s.Type = "codebuddy"
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(authFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = json.NewEncoder(f).Encode(s); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
@@ -222,6 +224,97 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CopilotModelEntry represents a single model entry returned by the Copilot /models API.
|
||||||
|
type CopilotModelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopilotModelsResponse represents the response from the Copilot /models endpoint.
|
||||||
|
type CopilotModelsResponse struct {
|
||||||
|
Data []CopilotModelEntry `json:"data"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxModelsResponseSize is the maximum allowed response size from the /models endpoint (2 MB).
|
||||||
|
const maxModelsResponseSize = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
// allowedCopilotAPIHosts is the set of hosts that are considered safe for Copilot API requests.
|
||||||
|
var allowedCopilotAPIHosts = map[string]bool{
|
||||||
|
"api.githubcopilot.com": true,
|
||||||
|
"api.individual.githubcopilot.com": true,
|
||||||
|
"api.business.githubcopilot.com": true,
|
||||||
|
"copilot-proxy.githubusercontent.com": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels fetches the list of available models from the Copilot API.
|
||||||
|
// It requires a valid Copilot API token (not the GitHub access token).
|
||||||
|
func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) {
|
||||||
|
if apiToken == nil || apiToken.Token == "" {
|
||||||
|
return nil, fmt.Errorf("copilot: api token is required for listing models")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build models URL, validating the endpoint host to prevent SSRF.
|
||||||
|
modelsURL := copilotAPIEndpoint + "/models"
|
||||||
|
if ep := strings.TrimRight(apiToken.Endpoints.API, "/"); ep != "" {
|
||||||
|
parsed, err := url.Parse(ep)
|
||||||
|
if err == nil && parsed.Scheme == "https" && allowedCopilotAPIHosts[parsed.Host] {
|
||||||
|
modelsURL = ep + "/models"
|
||||||
|
} else {
|
||||||
|
log.Warnf("copilot: ignoring untrusted API endpoint %q, using default", ep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.MakeAuthenticatedRequest(ctx, http.MethodGet, modelsURL, nil, apiToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copilot: failed to create models request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copilot: models request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("copilot list models: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Limit response body to prevent memory exhaustion.
|
||||||
|
limitedReader := io.LimitReader(resp.Body, maxModelsResponseSize)
|
||||||
|
bodyBytes, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copilot: failed to read models response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTTPSuccess(resp.StatusCode) {
|
||||||
|
return nil, fmt.Errorf("copilot: list models failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelsResp CopilotModelsResponse
|
||||||
|
if err = json.Unmarshal(bodyBytes, &modelsResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("copilot: failed to parse models response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelsResp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModelsWithGitHubToken is a convenience method that exchanges a GitHub access token
|
||||||
|
// for a Copilot API token and then fetches the available models.
|
||||||
|
func (c *CopilotAuth) ListModelsWithGitHubToken(ctx context.Context, githubAccessToken string) ([]CopilotModelEntry, error) {
|
||||||
|
apiToken, err := c.GetCopilotAPIToken(ctx, githubAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copilot: failed to get API token for model listing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.ListModels(ctx, apiToken)
|
||||||
|
}
|
||||||
|
|
||||||
// buildChatCompletionURL builds the URL for chat completions API.
|
// buildChatCompletionURL builds the URL for chat completions API.
|
||||||
func buildChatCompletionURL() string {
|
func buildChatCompletionURL() string {
|
||||||
return copilotAPIEndpoint + "/chat/completions"
|
return copilotAPIEndpoint + "/chat/completions"
|
||||||
|
|||||||
33
internal/auth/cursor/filename.go
Normal file
33
internal/auth/cursor/filename.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package cursor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialFileName returns the filename used to persist Cursor credentials.
|
||||||
|
// Priority: explicit label > auto-generated from JWT sub hash.
|
||||||
|
// If both label and subHash are empty, falls back to "cursor.json".
|
||||||
|
func CredentialFileName(label, subHash string) string {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
subHash = strings.TrimSpace(subHash)
|
||||||
|
if label != "" {
|
||||||
|
return fmt.Sprintf("cursor.%s.json", label)
|
||||||
|
}
|
||||||
|
if subHash != "" {
|
||||||
|
return fmt.Sprintf("cursor.%s.json", subHash)
|
||||||
|
}
|
||||||
|
return "cursor.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayLabel returns a human-readable label for the Cursor account.
|
||||||
|
func DisplayLabel(label, subHash string) string {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label != "" {
|
||||||
|
return "Cursor " + label
|
||||||
|
}
|
||||||
|
if subHash != "" {
|
||||||
|
return "Cursor " + subHash
|
||||||
|
}
|
||||||
|
return "Cursor User"
|
||||||
|
}
|
||||||
249
internal/auth/cursor/oauth.go
Normal file
249
internal/auth/cursor/oauth.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// Package cursor implements Cursor OAuth PKCE authentication and token refresh.
|
||||||
|
package cursor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CursorLoginURL = "https://cursor.com/loginDeepControl"
|
||||||
|
CursorPollURL = "https://api2.cursor.sh/auth/poll"
|
||||||
|
CursorRefreshURL = "https://api2.cursor.sh/auth/exchange_user_api_key"
|
||||||
|
|
||||||
|
pollMaxAttempts = 150
|
||||||
|
pollBaseDelay = 1 * time.Second
|
||||||
|
pollMaxDelay = 10 * time.Second
|
||||||
|
pollBackoffMultiply = 1.2
|
||||||
|
maxConsecutiveErrors = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthParams holds the PKCE parameters for Cursor login.
|
||||||
|
type AuthParams struct {
|
||||||
|
Verifier string
|
||||||
|
Challenge string
|
||||||
|
UUID string
|
||||||
|
LoginURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPair holds the access and refresh tokens from Cursor.
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePKCE creates a PKCE verifier and challenge pair.
|
||||||
|
func GeneratePKCE() (verifier, challenge string, err error) {
|
||||||
|
verifierBytes := make([]byte, 96)
|
||||||
|
if _, err = rand.Read(verifierBytes); err != nil {
|
||||||
|
return "", "", fmt.Errorf("cursor: failed to generate PKCE verifier: %w", err)
|
||||||
|
}
|
||||||
|
verifier = base64.RawURLEncoding.EncodeToString(verifierBytes)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte(verifier))
|
||||||
|
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
return verifier, challenge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAuthParams creates the full set of auth params for Cursor login.
|
||||||
|
func GenerateAuthParams() (*AuthParams, error) {
|
||||||
|
verifier, challenge, err := GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidBytes := make([]byte, 16)
|
||||||
|
if _, err = rand.Read(uuidBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: failed to generate UUID: %w", err)
|
||||||
|
}
|
||||||
|
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||||
|
uuidBytes[0:4], uuidBytes[4:6], uuidBytes[6:8], uuidBytes[8:10], uuidBytes[10:16])
|
||||||
|
|
||||||
|
loginURL := fmt.Sprintf("%s?challenge=%s&uuid=%s&mode=login&redirectTarget=cli",
|
||||||
|
CursorLoginURL, challenge, uuid)
|
||||||
|
|
||||||
|
return &AuthParams{
|
||||||
|
Verifier: verifier,
|
||||||
|
Challenge: challenge,
|
||||||
|
UUID: uuid,
|
||||||
|
LoginURL: loginURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollForAuth polls the Cursor auth endpoint until the user completes login.
|
||||||
|
func PollForAuth(ctx context.Context, uuid, verifier string) (*TokenPair, error) {
|
||||||
|
delay := pollBaseDelay
|
||||||
|
consecutiveErrors := 0
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
for attempt := 0; attempt < pollMaxAttempts; attempt++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s?uuid=%s&verifier=%s", CursorPollURL, uuid, verifier)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: failed to create poll request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
consecutiveErrors++
|
||||||
|
if consecutiveErrors >= maxConsecutiveErrors {
|
||||||
|
return nil, fmt.Errorf("cursor: too many consecutive poll errors (last: %v)", err)
|
||||||
|
}
|
||||||
|
delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
// Still waiting for user to authorize
|
||||||
|
consecutiveErrors = 0
|
||||||
|
delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
var tokens TokenPair
|
||||||
|
if err := json.Unmarshal(body, &tokens); err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: failed to parse auth response: %w", err)
|
||||||
|
}
|
||||||
|
return &tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cursor: poll failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cursor: authentication polling timeout (waited ~%.0f seconds)",
|
||||||
|
float64(pollMaxAttempts)*pollMaxDelay.Seconds()/2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes a Cursor access token using the refresh token.
|
||||||
|
func RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, CursorRefreshURL,
|
||||||
|
strings.NewReader("{}"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: failed to create refresh request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: token refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("cursor: token refresh failed (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens TokenPair
|
||||||
|
if err := json.Unmarshal(body, &tokens); err != nil {
|
||||||
|
return nil, fmt.Errorf("cursor: failed to parse refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep original refresh token if not returned
|
||||||
|
if tokens.RefreshToken == "" {
|
||||||
|
tokens.RefreshToken = refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJWTSub extracts the "sub" claim from a Cursor JWT access token.
|
||||||
|
// Cursor JWTs contain "sub" like "auth0|user_XXXX" which uniquely identifies
|
||||||
|
// the account. Returns empty string if parsing fails.
|
||||||
|
func ParseJWTSub(token string) string {
|
||||||
|
decoded := decodeJWTPayload(token)
|
||||||
|
if decoded == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return claims.Sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubToShortHash converts a JWT sub claim to a short hex hash for use in filenames.
|
||||||
|
// e.g. "auth0|user_2x..." → "a3f8b2c1"
|
||||||
|
func SubToShortHash(sub string) string {
|
||||||
|
if sub == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h := sha256.Sum256([]byte(sub))
|
||||||
|
return fmt.Sprintf("%x", h[:4]) // 8 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJWTPayload decodes the payload (middle) part of a JWT.
|
||||||
|
func decodeJWTPayload(token string) []byte {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := parts[1]
|
||||||
|
switch len(payload) % 4 {
|
||||||
|
case 2:
|
||||||
|
payload += "=="
|
||||||
|
case 3:
|
||||||
|
payload += "="
|
||||||
|
}
|
||||||
|
payload = strings.ReplaceAll(payload, "-", "+")
|
||||||
|
payload = strings.ReplaceAll(payload, "_", "/")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenExpiry extracts the JWT expiry from an access token with a 5-minute safety margin.
|
||||||
|
// Falls back to 1 hour from now if the token can't be parsed.
|
||||||
|
func GetTokenExpiry(token string) time.Time {
|
||||||
|
decoded := decodeJWTPayload(token)
|
||||||
|
if decoded == nil {
|
||||||
|
return time.Now().Add(1 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims struct {
|
||||||
|
Exp float64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decoded, &claims); err != nil || claims.Exp == 0 {
|
||||||
|
return time.Now().Add(1 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
sec, frac := math.Modf(claims.Exp)
|
||||||
|
expiry := time.Unix(int64(sec), int64(frac*1e9))
|
||||||
|
// Subtract 5-minute safety margin
|
||||||
|
return expiry.Add(-5 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minDuration(a, b time.Duration) time.Duration {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
84
internal/auth/cursor/proto/connect.go
Normal file
84
internal/auth/cursor/proto/connect.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ConnectEndStreamFlag marks the end-of-stream frame (trailers).
|
||||||
|
ConnectEndStreamFlag byte = 0x02
|
||||||
|
// ConnectCompressionFlag indicates the payload is compressed (not supported).
|
||||||
|
ConnectCompressionFlag byte = 0x01
|
||||||
|
// ConnectFrameHeaderSize is the fixed 5-byte frame header.
|
||||||
|
ConnectFrameHeaderSize = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrameConnectMessage wraps a protobuf payload in a Connect frame.
|
||||||
|
// Frame format: [1 byte flags][4 bytes payload length (big-endian)][payload]
|
||||||
|
func FrameConnectMessage(data []byte, flags byte) []byte {
|
||||||
|
frame := make([]byte, ConnectFrameHeaderSize+len(data))
|
||||||
|
frame[0] = flags
|
||||||
|
binary.BigEndian.PutUint32(frame[1:5], uint32(len(data)))
|
||||||
|
copy(frame[5:], data)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnectFrame extracts one frame from a buffer.
|
||||||
|
// Returns (flags, payload, bytesConsumed, ok).
|
||||||
|
// ok is false when the buffer is too short for a complete frame.
|
||||||
|
func ParseConnectFrame(buf []byte) (flags byte, payload []byte, consumed int, ok bool) {
|
||||||
|
if len(buf) < ConnectFrameHeaderSize {
|
||||||
|
return 0, nil, 0, false
|
||||||
|
}
|
||||||
|
flags = buf[0]
|
||||||
|
length := binary.BigEndian.Uint32(buf[1:5])
|
||||||
|
total := ConnectFrameHeaderSize + int(length)
|
||||||
|
if len(buf) < total {
|
||||||
|
return 0, nil, 0, false
|
||||||
|
}
|
||||||
|
return flags, buf[5:total], total, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectError is a structured error from the Connect protocol end-of-stream trailer.
|
||||||
|
// The Code field contains the server-defined error code (e.g. gRPC standard codes
|
||||||
|
// like "resource_exhausted", "unauthenticated", "permission_denied", "unavailable").
|
||||||
|
type ConnectError struct {
|
||||||
|
Code string // server-defined error code
|
||||||
|
Message string // human-readable error description
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConnectError) Error() string {
|
||||||
|
return fmt.Sprintf("Connect error %s: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnectEndStream parses a Connect end-of-stream frame payload (JSON).
|
||||||
|
// Returns nil if there is no error in the trailer.
|
||||||
|
// On error, returns a *ConnectError with the server's error code and message.
|
||||||
|
func ParseConnectEndStream(data []byte) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var trailer struct {
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &trailer); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse Connect end stream: %w", err)
|
||||||
|
}
|
||||||
|
if trailer.Error != nil {
|
||||||
|
code := trailer.Error.Code
|
||||||
|
if code == "" {
|
||||||
|
code = "unknown"
|
||||||
|
}
|
||||||
|
msg := trailer.Error.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = "Unknown error"
|
||||||
|
}
|
||||||
|
return &ConnectError{Code: code, Message: msg}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
564
internal/auth/cursor/proto/decode.go
Normal file
564
internal/auth/cursor/proto/decode.go
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerMessageType identifies the kind of decoded server message.
|
||||||
|
type ServerMessageType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerMsgUnknown ServerMessageType = iota
|
||||||
|
ServerMsgTextDelta // Text content delta
|
||||||
|
ServerMsgThinkingDelta // Thinking/reasoning delta
|
||||||
|
ServerMsgThinkingCompleted // Thinking completed
|
||||||
|
ServerMsgKvGetBlob // Server wants a blob
|
||||||
|
ServerMsgKvSetBlob // Server wants to store a blob
|
||||||
|
ServerMsgExecRequestCtx // Server requests context (tools, etc.)
|
||||||
|
ServerMsgExecMcpArgs // Server wants MCP tool execution
|
||||||
|
ServerMsgExecShellArgs // Rejected: shell command
|
||||||
|
ServerMsgExecReadArgs // Rejected: file read
|
||||||
|
ServerMsgExecWriteArgs // Rejected: file write
|
||||||
|
ServerMsgExecDeleteArgs // Rejected: file delete
|
||||||
|
ServerMsgExecLsArgs // Rejected: directory listing
|
||||||
|
ServerMsgExecGrepArgs // Rejected: grep search
|
||||||
|
ServerMsgExecFetchArgs // Rejected: HTTP fetch
|
||||||
|
ServerMsgExecDiagnostics // Respond with empty diagnostics
|
||||||
|
ServerMsgExecShellStream // Rejected: shell stream
|
||||||
|
ServerMsgExecBgShellSpawn // Rejected: background shell
|
||||||
|
ServerMsgExecWriteShellStdin // Rejected: write shell stdin
|
||||||
|
ServerMsgExecOther // Other exec types (respond with empty)
|
||||||
|
ServerMsgTurnEnded // Turn has ended (no more output)
|
||||||
|
ServerMsgHeartbeat // Server heartbeat
|
||||||
|
ServerMsgTokenDelta // Token usage delta
|
||||||
|
ServerMsgCheckpoint // Conversation checkpoint update
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodedServerMessage holds parsed data from an AgentServerMessage.
|
||||||
|
type DecodedServerMessage struct {
|
||||||
|
Type ServerMessageType
|
||||||
|
|
||||||
|
// For text/thinking deltas
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// For KV messages
|
||||||
|
KvId uint32
|
||||||
|
BlobId []byte // hex-encoded blob ID
|
||||||
|
BlobData []byte // for setBlobArgs
|
||||||
|
|
||||||
|
// For exec messages
|
||||||
|
ExecMsgId uint32
|
||||||
|
ExecId string
|
||||||
|
|
||||||
|
// For MCP args
|
||||||
|
McpToolName string
|
||||||
|
McpToolCallId string
|
||||||
|
McpArgs map[string][]byte // arg name -> protobuf-encoded value
|
||||||
|
|
||||||
|
// For rejection context
|
||||||
|
Path string
|
||||||
|
Command string
|
||||||
|
WorkingDirectory string
|
||||||
|
Url string
|
||||||
|
|
||||||
|
// For other exec - the raw field number for building a response
|
||||||
|
ExecFieldNumber int
|
||||||
|
|
||||||
|
// For TokenDeltaUpdate
|
||||||
|
TokenDelta int64
|
||||||
|
|
||||||
|
// For conversation checkpoint update (raw bytes, not decoded)
|
||||||
|
CheckpointData []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeAgentServerMessage parses an AgentServerMessage and returns
|
||||||
|
// a structured representation of the first meaningful message found.
|
||||||
|
func DecodeAgentServerMessage(data []byte) (*DecodedServerMessage, error) {
|
||||||
|
msg := &DecodedServerMessage{Type: ServerMsgUnknown}
|
||||||
|
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return msg, fmt.Errorf("invalid tag")
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
switch typ {
|
||||||
|
case protowire.BytesType:
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return msg, fmt.Errorf("invalid bytes field %d", num)
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
// Debug: log top-level ASM fields
|
||||||
|
log.Debugf("DecodeAgentServerMessage: found ASM field %d, len=%d", num, len(val))
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case ASM_InteractionUpdate:
|
||||||
|
log.Debugf("DecodeAgentServerMessage: calling decodeInteractionUpdate")
|
||||||
|
decodeInteractionUpdate(val, msg)
|
||||||
|
case ASM_ExecServerMessage:
|
||||||
|
log.Debugf("DecodeAgentServerMessage: calling decodeExecServerMessage")
|
||||||
|
decodeExecServerMessage(val, msg)
|
||||||
|
case ASM_KvServerMessage:
|
||||||
|
decodeKvServerMessage(val, msg)
|
||||||
|
case ASM_ConversationCheckpoint:
|
||||||
|
msg.Type = ServerMsgCheckpoint
|
||||||
|
msg.CheckpointData = append([]byte(nil), val...) // copy raw bytes
|
||||||
|
log.Debugf("DecodeAgentServerMessage: captured checkpoint %d bytes", len(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
case protowire.VarintType:
|
||||||
|
_, n := protowire.ConsumeVarint(data)
|
||||||
|
if n < 0 {
|
||||||
|
return msg, fmt.Errorf("invalid varint field %d", num)
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Skip unknown wire types
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return msg, fmt.Errorf("invalid field %d", num)
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeInteractionUpdate(data []byte, msg *DecodedServerMessage) {
|
||||||
|
log.Debugf("decodeInteractionUpdate: input len=%d, hex=%x", len(data), data)
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
log.Debugf("decodeInteractionUpdate: invalid tag, remaining=%x", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
log.Debugf("decodeInteractionUpdate: field=%d wire=%d remaining=%d bytes", num, typ, len(data))
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
log.Debugf("decodeInteractionUpdate: invalid bytes field %d", num)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
log.Debugf("decodeInteractionUpdate: field %d content len=%d, first 20 bytes: %x", num, len(val), val[:min(20, len(val))])
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case IU_TextDelta:
|
||||||
|
msg.Type = ServerMsgTextDelta
|
||||||
|
msg.Text = decodeStringField(val, TDU_Text)
|
||||||
|
log.Debugf("decodeInteractionUpdate: TextDelta text=%q", msg.Text)
|
||||||
|
case IU_ThinkingDelta:
|
||||||
|
msg.Type = ServerMsgThinkingDelta
|
||||||
|
msg.Text = decodeStringField(val, TKD_Text)
|
||||||
|
log.Debugf("decodeInteractionUpdate: ThinkingDelta text=%q", msg.Text)
|
||||||
|
case IU_ThinkingCompleted:
|
||||||
|
msg.Type = ServerMsgThinkingCompleted
|
||||||
|
log.Debugf("decodeInteractionUpdate: ThinkingCompleted")
|
||||||
|
case 2:
|
||||||
|
// tool_call_started - ignore but log
|
||||||
|
log.Debugf("decodeInteractionUpdate: ToolCallStarted (ignored)")
|
||||||
|
case 3:
|
||||||
|
// tool_call_completed - ignore but log
|
||||||
|
log.Debugf("decodeInteractionUpdate: ToolCallCompleted (ignored)")
|
||||||
|
case 8:
|
||||||
|
// token_delta - extract token count
|
||||||
|
msg.Type = ServerMsgTokenDelta
|
||||||
|
msg.TokenDelta = decodeVarintField(val, 1)
|
||||||
|
log.Debugf("decodeInteractionUpdate: TokenDeltaUpdate tokens=%d", msg.TokenDelta)
|
||||||
|
case 13:
|
||||||
|
// heartbeat from server
|
||||||
|
msg.Type = ServerMsgHeartbeat
|
||||||
|
case 14:
|
||||||
|
// turn_ended - critical: model finished generating
|
||||||
|
msg.Type = ServerMsgTurnEnded
|
||||||
|
log.Debugf("decodeInteractionUpdate: TurnEndedUpdate - stream should end")
|
||||||
|
case 16:
|
||||||
|
// step_started - ignore
|
||||||
|
log.Debugf("decodeInteractionUpdate: StepStartedUpdate (ignored)")
|
||||||
|
case 17:
|
||||||
|
// step_completed - ignore
|
||||||
|
log.Debugf("decodeInteractionUpdate: StepCompletedUpdate (ignored)")
|
||||||
|
default:
|
||||||
|
log.Debugf("decodeInteractionUpdate: unknown field %d", num)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKvServerMessage(data []byte, msg *DecodedServerMessage) {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
switch typ {
|
||||||
|
case protowire.VarintType:
|
||||||
|
val, n := protowire.ConsumeVarint(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == KSM_Id {
|
||||||
|
msg.KvId = uint32(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
case protowire.BytesType:
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case KSM_GetBlobArgs:
|
||||||
|
msg.Type = ServerMsgKvGetBlob
|
||||||
|
msg.BlobId = decodeBytesField(val, GBA_BlobId)
|
||||||
|
case KSM_SetBlobArgs:
|
||||||
|
msg.Type = ServerMsgKvSetBlob
|
||||||
|
decodeSetBlobArgs(val, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSetBlobArgs(data []byte, msg *DecodedServerMessage) {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
switch num {
|
||||||
|
case SBA_BlobId:
|
||||||
|
msg.BlobId = val
|
||||||
|
case SBA_BlobData:
|
||||||
|
msg.BlobData = val
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeExecServerMessage(data []byte, msg *DecodedServerMessage) {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
switch typ {
|
||||||
|
case protowire.VarintType:
|
||||||
|
val, n := protowire.ConsumeVarint(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == ESM_Id {
|
||||||
|
msg.ExecMsgId = uint32(val)
|
||||||
|
log.Debugf("decodeExecServerMessage: ESM_Id = %d", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
case protowire.BytesType:
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
// Debug: log all fields found in ExecServerMessage
|
||||||
|
log.Debugf("decodeExecServerMessage: found field %d, len=%d, first 20 bytes: %x", num, len(val), val[:min(20, len(val))])
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case ESM_ExecId:
|
||||||
|
msg.ExecId = string(val)
|
||||||
|
log.Debugf("decodeExecServerMessage: ESM_ExecId = %q", msg.ExecId)
|
||||||
|
case ESM_RequestContextArgs:
|
||||||
|
msg.Type = ServerMsgExecRequestCtx
|
||||||
|
case ESM_McpArgs:
|
||||||
|
msg.Type = ServerMsgExecMcpArgs
|
||||||
|
decodeMcpArgs(val, msg)
|
||||||
|
case ESM_ShellArgs:
|
||||||
|
msg.Type = ServerMsgExecShellArgs
|
||||||
|
decodeShellArgs(val, msg)
|
||||||
|
case ESM_ShellStreamArgs:
|
||||||
|
msg.Type = ServerMsgExecShellStream
|
||||||
|
decodeShellArgs(val, msg)
|
||||||
|
case ESM_ReadArgs:
|
||||||
|
msg.Type = ServerMsgExecReadArgs
|
||||||
|
msg.Path = decodeStringField(val, RA_Path)
|
||||||
|
case ESM_WriteArgs:
|
||||||
|
msg.Type = ServerMsgExecWriteArgs
|
||||||
|
msg.Path = decodeStringField(val, WA_Path)
|
||||||
|
case ESM_DeleteArgs:
|
||||||
|
msg.Type = ServerMsgExecDeleteArgs
|
||||||
|
msg.Path = decodeStringField(val, DA_Path)
|
||||||
|
case ESM_LsArgs:
|
||||||
|
msg.Type = ServerMsgExecLsArgs
|
||||||
|
msg.Path = decodeStringField(val, LA_Path)
|
||||||
|
case ESM_GrepArgs:
|
||||||
|
msg.Type = ServerMsgExecGrepArgs
|
||||||
|
case ESM_FetchArgs:
|
||||||
|
msg.Type = ServerMsgExecFetchArgs
|
||||||
|
msg.Url = decodeStringField(val, FA_Url)
|
||||||
|
case ESM_DiagnosticsArgs:
|
||||||
|
msg.Type = ServerMsgExecDiagnostics
|
||||||
|
case ESM_BackgroundShellSpawn:
|
||||||
|
msg.Type = ServerMsgExecBgShellSpawn
|
||||||
|
decodeShellArgs(val, msg) // same structure
|
||||||
|
case ESM_WriteShellStdinArgs:
|
||||||
|
msg.Type = ServerMsgExecWriteShellStdin
|
||||||
|
default:
|
||||||
|
// Unknown exec types - only set if we haven't identified the type yet
|
||||||
|
// (other fields like span_context (19) come after the exec type field)
|
||||||
|
if msg.Type == ServerMsgUnknown {
|
||||||
|
msg.Type = ServerMsgExecOther
|
||||||
|
msg.ExecFieldNumber = int(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMcpArgs(data []byte, msg *DecodedServerMessage) {
|
||||||
|
msg.McpArgs = make(map[string][]byte)
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case MCA_Name:
|
||||||
|
msg.McpToolName = string(val)
|
||||||
|
case MCA_Args:
|
||||||
|
// Map entries are encoded as submessages with key=1, value=2
|
||||||
|
decodeMapEntry(val, msg.McpArgs)
|
||||||
|
case MCA_ToolCallId:
|
||||||
|
msg.McpToolCallId = string(val)
|
||||||
|
case MCA_ToolName:
|
||||||
|
// ToolName takes precedence if present
|
||||||
|
if msg.McpToolName == "" || string(val) != "" {
|
||||||
|
msg.McpToolName = string(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMapEntry(data []byte, m map[string][]byte) {
|
||||||
|
var key string
|
||||||
|
var value []byte
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == 1 {
|
||||||
|
key = string(val)
|
||||||
|
} else if num == 2 {
|
||||||
|
value = append([]byte(nil), val...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
m[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeShellArgs(data []byte, msg *DecodedServerMessage) {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
switch num {
|
||||||
|
case SHA_Command:
|
||||||
|
msg.Command = string(val)
|
||||||
|
case SHA_WorkingDirectory:
|
||||||
|
msg.WorkingDirectory = string(val)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper decoders ---
|
||||||
|
|
||||||
|
// decodeStringField extracts a string from the first matching field in a submessage.
|
||||||
|
func decodeStringField(data []byte, targetField protowire.Number) string {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == targetField {
|
||||||
|
return string(val)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBytesField extracts bytes from the first matching field in a submessage.
|
||||||
|
func decodeBytesField(data []byte, targetField protowire.Number) []byte {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
|
||||||
|
if typ == protowire.BytesType {
|
||||||
|
val, n := protowire.ConsumeBytes(data)
|
||||||
|
if n < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == targetField {
|
||||||
|
return append([]byte(nil), val...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVarintField extracts an int64 from the first matching varint field in a submessage.
|
||||||
|
func decodeVarintField(data []byte, targetField protowire.Number) int64 {
|
||||||
|
for len(data) > 0 {
|
||||||
|
num, typ, n := protowire.ConsumeTag(data)
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if typ == protowire.VarintType {
|
||||||
|
val, n := protowire.ConsumeVarint(data)
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
if num == targetField {
|
||||||
|
return int64(val)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n := protowire.ConsumeFieldValue(num, typ, data)
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
data = data[n:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobIdHex returns the hex string of a blob ID for use as a map key.
|
||||||
|
func BlobIdHex(blobId []byte) string {
|
||||||
|
return hex.EncodeToString(blobId)
|
||||||
|
}
|
||||||
|
|
||||||
1244
internal/auth/cursor/proto/descriptor.go
Normal file
1244
internal/auth/cursor/proto/descriptor.go
Normal file
File diff suppressed because it is too large
Load Diff
664
internal/auth/cursor/proto/encode.go
Normal file
664
internal/auth/cursor/proto/encode.go
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
// Package proto provides protobuf encoding for Cursor's gRPC API,
|
||||||
|
// using dynamicpb with the embedded FileDescriptorProto from agent.proto.
|
||||||
|
// This mirrors the cursor-auth TS plugin's use of @bufbuild/protobuf create()+toBinary().
|
||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
"google.golang.org/protobuf/types/dynamicpb"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Public types ---
|
||||||
|
|
||||||
|
// RunRequestParams holds all data needed to build an AgentRunRequest.
|
||||||
|
type RunRequestParams struct {
|
||||||
|
ModelId string
|
||||||
|
SystemPrompt string
|
||||||
|
UserText string
|
||||||
|
MessageId string
|
||||||
|
ConversationId string
|
||||||
|
Images []ImageData
|
||||||
|
Turns []TurnData
|
||||||
|
McpTools []McpToolDef
|
||||||
|
BlobStore map[string][]byte // hex(sha256) -> data, populated during encoding
|
||||||
|
RawCheckpoint []byte // if non-nil, use as conversation_state directly (from server checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageData struct {
|
||||||
|
MimeType string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type TurnData struct {
|
||||||
|
UserText string
|
||||||
|
AssistantText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpToolDef struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
InputSchema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: create a dynamic message and set fields ---
|
||||||
|
|
||||||
|
func newMsg(name string) *dynamicpb.Message {
|
||||||
|
return dynamicpb.NewMessage(Msg(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func field(msg *dynamicpb.Message, name string) protoreflect.FieldDescriptor {
|
||||||
|
return msg.Descriptor().Fields().ByName(protoreflect.Name(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStr(msg *dynamicpb.Message, name, val string) {
|
||||||
|
if val != "" {
|
||||||
|
msg.Set(field(msg, name), protoreflect.ValueOfString(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBytes(msg *dynamicpb.Message, name string, val []byte) {
|
||||||
|
if len(val) > 0 {
|
||||||
|
msg.Set(field(msg, name), protoreflect.ValueOfBytes(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUint32(msg *dynamicpb.Message, name string, val uint32) {
|
||||||
|
msg.Set(field(msg, name), protoreflect.ValueOfUint32(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(msg *dynamicpb.Message, name string, val bool) {
|
||||||
|
msg.Set(field(msg, name), protoreflect.ValueOfBool(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMsg(msg *dynamicpb.Message, name string, sub *dynamicpb.Message) {
|
||||||
|
msg.Set(field(msg, name), protoreflect.ValueOfMessage(sub.ProtoReflect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshal(msg *dynamicpb.Message) []byte {
|
||||||
|
b, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
panic("cursor proto marshal: " + err.Error())
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encode functions mirroring cursor-fetch.ts ---
|
||||||
|
|
||||||
|
// EncodeHeartbeat returns an encoded AgentClientMessage with clientHeartbeat.
|
||||||
|
// Mirrors: create(AgentClientMessageSchema, { message: { case: 'clientHeartbeat', value: create(ClientHeartbeatSchema, {}) } })
|
||||||
|
func EncodeHeartbeat() []byte {
|
||||||
|
hb := newMsg("ClientHeartbeat")
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "client_heartbeat", hb)
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeRunRequest builds a full AgentClientMessage wrapping an AgentRunRequest.
|
||||||
|
// Mirrors buildCursorRequest() in cursor-fetch.ts.
|
||||||
|
// If p.RawCheckpoint is set, it is used directly as the conversation_state bytes
|
||||||
|
// (from a previous conversation_checkpoint_update), skipping manual turn construction.
|
||||||
|
func EncodeRunRequest(p *RunRequestParams) []byte {
|
||||||
|
if p.RawCheckpoint != nil {
|
||||||
|
return encodeRunRequestWithCheckpoint(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.BlobStore == nil {
|
||||||
|
p.BlobStore = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Conversation turns ---
|
||||||
|
// Each turn is serialized as bytes (ConversationTurnStructure → bytes)
|
||||||
|
var turnBytes [][]byte
|
||||||
|
for _, turn := range p.Turns {
|
||||||
|
// UserMessage for this turn
|
||||||
|
um := newMsg("UserMessage")
|
||||||
|
setStr(um, "text", turn.UserText)
|
||||||
|
setStr(um, "message_id", generateId())
|
||||||
|
umBytes := marshal(um)
|
||||||
|
|
||||||
|
// Steps (assistant response)
|
||||||
|
var stepBytes [][]byte
|
||||||
|
if turn.AssistantText != "" {
|
||||||
|
am := newMsg("AssistantMessage")
|
||||||
|
setStr(am, "text", turn.AssistantText)
|
||||||
|
step := newMsg("ConversationStep")
|
||||||
|
setMsg(step, "assistant_message", am)
|
||||||
|
stepBytes = append(stepBytes, marshal(step))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentConversationTurnStructure (fields are bytes, not submessages)
|
||||||
|
agentTurn := newMsg("AgentConversationTurnStructure")
|
||||||
|
setBytes(agentTurn, "user_message", umBytes)
|
||||||
|
for _, sb := range stepBytes {
|
||||||
|
stepsField := field(agentTurn, "steps")
|
||||||
|
list := agentTurn.Mutable(stepsField).List()
|
||||||
|
list.Append(protoreflect.ValueOfBytes(sb))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationTurnStructure (oneof turn → agentConversationTurn)
|
||||||
|
cts := newMsg("ConversationTurnStructure")
|
||||||
|
setMsg(cts, "agent_conversation_turn", agentTurn)
|
||||||
|
turnBytes = append(turnBytes, marshal(cts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System prompt blob ---
|
||||||
|
systemJSON, _ := json.Marshal(map[string]string{"role": "system", "content": p.SystemPrompt})
|
||||||
|
blobId := sha256Sum(systemJSON)
|
||||||
|
p.BlobStore[hex.EncodeToString(blobId)] = systemJSON
|
||||||
|
|
||||||
|
// --- ConversationStateStructure ---
|
||||||
|
css := newMsg("ConversationStateStructure")
|
||||||
|
// rootPromptMessagesJson: repeated bytes
|
||||||
|
rootField := field(css, "root_prompt_messages_json")
|
||||||
|
rootList := css.Mutable(rootField).List()
|
||||||
|
rootList.Append(protoreflect.ValueOfBytes(blobId))
|
||||||
|
// turns: repeated bytes (field 8) + turns_old (field 2) for compatibility
|
||||||
|
turnsField := field(css, "turns")
|
||||||
|
turnsList := css.Mutable(turnsField).List()
|
||||||
|
for _, tb := range turnBytes {
|
||||||
|
turnsList.Append(protoreflect.ValueOfBytes(tb))
|
||||||
|
}
|
||||||
|
turnsOldField := field(css, "turns_old")
|
||||||
|
if turnsOldField != nil {
|
||||||
|
turnsOldList := css.Mutable(turnsOldField).List()
|
||||||
|
for _, tb := range turnBytes {
|
||||||
|
turnsOldList.Append(protoreflect.ValueOfBytes(tb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UserMessage (current) ---
|
||||||
|
userMessage := newMsg("UserMessage")
|
||||||
|
setStr(userMessage, "text", p.UserText)
|
||||||
|
setStr(userMessage, "message_id", p.MessageId)
|
||||||
|
|
||||||
|
// Images via SelectedContext
|
||||||
|
if len(p.Images) > 0 {
|
||||||
|
sc := newMsg("SelectedContext")
|
||||||
|
imgsField := field(sc, "selected_images")
|
||||||
|
imgsList := sc.Mutable(imgsField).List()
|
||||||
|
for _, img := range p.Images {
|
||||||
|
si := newMsg("SelectedImage")
|
||||||
|
setStr(si, "uuid", generateId())
|
||||||
|
setStr(si, "mime_type", img.MimeType)
|
||||||
|
setBytes(si, "data", img.Data)
|
||||||
|
imgsList.Append(protoreflect.ValueOfMessage(si.ProtoReflect()))
|
||||||
|
}
|
||||||
|
setMsg(userMessage, "selected_context", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UserMessageAction ---
|
||||||
|
uma := newMsg("UserMessageAction")
|
||||||
|
setMsg(uma, "user_message", userMessage)
|
||||||
|
|
||||||
|
// --- ConversationAction ---
|
||||||
|
ca := newMsg("ConversationAction")
|
||||||
|
setMsg(ca, "user_message_action", uma)
|
||||||
|
|
||||||
|
// --- ModelDetails ---
|
||||||
|
md := newMsg("ModelDetails")
|
||||||
|
setStr(md, "model_id", p.ModelId)
|
||||||
|
setStr(md, "display_model_id", p.ModelId)
|
||||||
|
setStr(md, "display_name", p.ModelId)
|
||||||
|
|
||||||
|
// --- AgentRunRequest ---
|
||||||
|
arr := newMsg("AgentRunRequest")
|
||||||
|
setMsg(arr, "conversation_state", css)
|
||||||
|
setMsg(arr, "action", ca)
|
||||||
|
setMsg(arr, "model_details", md)
|
||||||
|
setStr(arr, "conversation_id", p.ConversationId)
|
||||||
|
|
||||||
|
// McpTools
|
||||||
|
if len(p.McpTools) > 0 {
|
||||||
|
mcpTools := newMsg("McpTools")
|
||||||
|
toolsField := field(mcpTools, "mcp_tools")
|
||||||
|
toolsList := mcpTools.Mutable(toolsField).List()
|
||||||
|
for _, tool := range p.McpTools {
|
||||||
|
td := newMsg("McpToolDefinition")
|
||||||
|
setStr(td, "name", tool.Name)
|
||||||
|
setStr(td, "description", tool.Description)
|
||||||
|
if len(tool.InputSchema) > 0 {
|
||||||
|
setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema))
|
||||||
|
}
|
||||||
|
setStr(td, "provider_identifier", "proxy")
|
||||||
|
setStr(td, "tool_name", tool.Name)
|
||||||
|
toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect()))
|
||||||
|
}
|
||||||
|
setMsg(arr, "mcp_tools", mcpTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AgentClientMessage ---
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "run_request", arr)
|
||||||
|
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeRunRequestWithCheckpoint builds an AgentClientMessage using a raw checkpoint
|
||||||
|
// as conversation_state. The checkpoint bytes are embedded directly without deserialization.
|
||||||
|
func encodeRunRequestWithCheckpoint(p *RunRequestParams) []byte {
|
||||||
|
// Build UserMessage
|
||||||
|
userMessage := newMsg("UserMessage")
|
||||||
|
setStr(userMessage, "text", p.UserText)
|
||||||
|
setStr(userMessage, "message_id", p.MessageId)
|
||||||
|
if len(p.Images) > 0 {
|
||||||
|
sc := newMsg("SelectedContext")
|
||||||
|
imgsField := field(sc, "selected_images")
|
||||||
|
imgsList := sc.Mutable(imgsField).List()
|
||||||
|
for _, img := range p.Images {
|
||||||
|
si := newMsg("SelectedImage")
|
||||||
|
setStr(si, "uuid", generateId())
|
||||||
|
setStr(si, "mime_type", img.MimeType)
|
||||||
|
setBytes(si, "data", img.Data)
|
||||||
|
imgsList.Append(protoreflect.ValueOfMessage(si.ProtoReflect()))
|
||||||
|
}
|
||||||
|
setMsg(userMessage, "selected_context", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ConversationAction with UserMessageAction
|
||||||
|
uma := newMsg("UserMessageAction")
|
||||||
|
setMsg(uma, "user_message", userMessage)
|
||||||
|
ca := newMsg("ConversationAction")
|
||||||
|
setMsg(ca, "user_message_action", uma)
|
||||||
|
caBytes := marshal(ca)
|
||||||
|
|
||||||
|
// Build ModelDetails
|
||||||
|
md := newMsg("ModelDetails")
|
||||||
|
setStr(md, "model_id", p.ModelId)
|
||||||
|
setStr(md, "display_model_id", p.ModelId)
|
||||||
|
setStr(md, "display_name", p.ModelId)
|
||||||
|
mdBytes := marshal(md)
|
||||||
|
|
||||||
|
// Build McpTools
|
||||||
|
var mcpToolsBytes []byte
|
||||||
|
if len(p.McpTools) > 0 {
|
||||||
|
mcpTools := newMsg("McpTools")
|
||||||
|
toolsField := field(mcpTools, "mcp_tools")
|
||||||
|
toolsList := mcpTools.Mutable(toolsField).List()
|
||||||
|
for _, tool := range p.McpTools {
|
||||||
|
td := newMsg("McpToolDefinition")
|
||||||
|
setStr(td, "name", tool.Name)
|
||||||
|
setStr(td, "description", tool.Description)
|
||||||
|
if len(tool.InputSchema) > 0 {
|
||||||
|
setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema))
|
||||||
|
}
|
||||||
|
setStr(td, "provider_identifier", "proxy")
|
||||||
|
setStr(td, "tool_name", tool.Name)
|
||||||
|
toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect()))
|
||||||
|
}
|
||||||
|
mcpToolsBytes = marshal(mcpTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually assemble AgentRunRequest using protowire to embed raw checkpoint
|
||||||
|
var arrBuf []byte
|
||||||
|
// field 1: conversation_state = raw checkpoint bytes (length-delimited)
|
||||||
|
arrBuf = protowire.AppendTag(arrBuf, ARR_ConversationState, protowire.BytesType)
|
||||||
|
arrBuf = protowire.AppendBytes(arrBuf, p.RawCheckpoint)
|
||||||
|
// field 2: action = ConversationAction
|
||||||
|
arrBuf = protowire.AppendTag(arrBuf, ARR_Action, protowire.BytesType)
|
||||||
|
arrBuf = protowire.AppendBytes(arrBuf, caBytes)
|
||||||
|
// field 3: model_details = ModelDetails
|
||||||
|
arrBuf = protowire.AppendTag(arrBuf, ARR_ModelDetails, protowire.BytesType)
|
||||||
|
arrBuf = protowire.AppendBytes(arrBuf, mdBytes)
|
||||||
|
// field 4: mcp_tools = McpTools
|
||||||
|
if len(mcpToolsBytes) > 0 {
|
||||||
|
arrBuf = protowire.AppendTag(arrBuf, ARR_McpTools, protowire.BytesType)
|
||||||
|
arrBuf = protowire.AppendBytes(arrBuf, mcpToolsBytes)
|
||||||
|
}
|
||||||
|
// field 5: conversation_id = string
|
||||||
|
if p.ConversationId != "" {
|
||||||
|
arrBuf = protowire.AppendTag(arrBuf, ARR_ConversationId, protowire.BytesType)
|
||||||
|
arrBuf = protowire.AppendString(arrBuf, p.ConversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap in AgentClientMessage field 1 (run_request)
|
||||||
|
var acmBuf []byte
|
||||||
|
acmBuf = protowire.AppendTag(acmBuf, ACM_RunRequest, protowire.BytesType)
|
||||||
|
acmBuf = protowire.AppendBytes(acmBuf, arrBuf)
|
||||||
|
|
||||||
|
log.Debugf("cursor encode: built RunRequest with checkpoint (%d bytes), total=%d bytes", len(p.RawCheckpoint), len(acmBuf))
|
||||||
|
return acmBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeRequestParams holds data for a ResumeAction request.
|
||||||
|
type ResumeRequestParams struct {
|
||||||
|
ModelId string
|
||||||
|
ConversationId string
|
||||||
|
McpTools []McpToolDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeResumeRequest builds an AgentClientMessage with ResumeAction.
|
||||||
|
// Used to resume a conversation by conversation_id without re-sending full history.
|
||||||
|
func EncodeResumeRequest(p *ResumeRequestParams) []byte {
|
||||||
|
// RequestContext with tools
|
||||||
|
rc := newMsg("RequestContext")
|
||||||
|
if len(p.McpTools) > 0 {
|
||||||
|
toolsField := field(rc, "tools")
|
||||||
|
toolsList := rc.Mutable(toolsField).List()
|
||||||
|
for _, tool := range p.McpTools {
|
||||||
|
td := newMsg("McpToolDefinition")
|
||||||
|
setStr(td, "name", tool.Name)
|
||||||
|
setStr(td, "description", tool.Description)
|
||||||
|
if len(tool.InputSchema) > 0 {
|
||||||
|
setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema))
|
||||||
|
}
|
||||||
|
setStr(td, "provider_identifier", "proxy")
|
||||||
|
setStr(td, "tool_name", tool.Name)
|
||||||
|
toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeAction
|
||||||
|
ra := newMsg("ResumeAction")
|
||||||
|
setMsg(ra, "request_context", rc)
|
||||||
|
|
||||||
|
// ConversationAction with resume_action
|
||||||
|
ca := newMsg("ConversationAction")
|
||||||
|
setMsg(ca, "resume_action", ra)
|
||||||
|
|
||||||
|
// ModelDetails
|
||||||
|
md := newMsg("ModelDetails")
|
||||||
|
setStr(md, "model_id", p.ModelId)
|
||||||
|
setStr(md, "display_model_id", p.ModelId)
|
||||||
|
setStr(md, "display_name", p.ModelId)
|
||||||
|
|
||||||
|
// AgentRunRequest — no conversation_state needed for resume
|
||||||
|
arr := newMsg("AgentRunRequest")
|
||||||
|
setMsg(arr, "action", ca)
|
||||||
|
setMsg(arr, "model_details", md)
|
||||||
|
setStr(arr, "conversation_id", p.ConversationId)
|
||||||
|
|
||||||
|
// McpTools at top level
|
||||||
|
if len(p.McpTools) > 0 {
|
||||||
|
mcpTools := newMsg("McpTools")
|
||||||
|
toolsField := field(mcpTools, "mcp_tools")
|
||||||
|
toolsList := mcpTools.Mutable(toolsField).List()
|
||||||
|
for _, tool := range p.McpTools {
|
||||||
|
td := newMsg("McpToolDefinition")
|
||||||
|
setStr(td, "name", tool.Name)
|
||||||
|
setStr(td, "description", tool.Description)
|
||||||
|
if len(tool.InputSchema) > 0 {
|
||||||
|
setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema))
|
||||||
|
}
|
||||||
|
setStr(td, "provider_identifier", "proxy")
|
||||||
|
setStr(td, "tool_name", tool.Name)
|
||||||
|
toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect()))
|
||||||
|
}
|
||||||
|
setMsg(arr, "mcp_tools", mcpTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "run_request", arr)
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- KV response encoders ---
|
||||||
|
// Mirrors handleKvMessage() in cursor-fetch.ts
|
||||||
|
|
||||||
|
// EncodeKvGetBlobResult responds to a getBlobArgs request.
|
||||||
|
func EncodeKvGetBlobResult(kvId uint32, blobData []byte) []byte {
|
||||||
|
result := newMsg("GetBlobResult")
|
||||||
|
if blobData != nil {
|
||||||
|
setBytes(result, "blob_data", blobData)
|
||||||
|
}
|
||||||
|
|
||||||
|
kvc := newMsg("KvClientMessage")
|
||||||
|
setUint32(kvc, "id", kvId)
|
||||||
|
setMsg(kvc, "get_blob_result", result)
|
||||||
|
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "kv_client_message", kvc)
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeKvSetBlobResult responds to a setBlobArgs request.
|
||||||
|
func EncodeKvSetBlobResult(kvId uint32) []byte {
|
||||||
|
result := newMsg("SetBlobResult")
|
||||||
|
|
||||||
|
kvc := newMsg("KvClientMessage")
|
||||||
|
setUint32(kvc, "id", kvId)
|
||||||
|
setMsg(kvc, "set_blob_result", result)
|
||||||
|
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "kv_client_message", kvc)
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Exec response encoders ---
|
||||||
|
// Mirrors handleExecMessage() and sendExec() in cursor-fetch.ts
|
||||||
|
|
||||||
|
// EncodeExecRequestContextResult responds to requestContextArgs with tool definitions.
|
||||||
|
func EncodeExecRequestContextResult(execMsgId uint32, execId string, tools []McpToolDef) []byte {
|
||||||
|
// RequestContext with tools
|
||||||
|
rc := newMsg("RequestContext")
|
||||||
|
if len(tools) > 0 {
|
||||||
|
toolsField := field(rc, "tools")
|
||||||
|
toolsList := rc.Mutable(toolsField).List()
|
||||||
|
for _, tool := range tools {
|
||||||
|
td := newMsg("McpToolDefinition")
|
||||||
|
setStr(td, "name", tool.Name)
|
||||||
|
setStr(td, "description", tool.Description)
|
||||||
|
if len(tool.InputSchema) > 0 {
|
||||||
|
setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema))
|
||||||
|
}
|
||||||
|
setStr(td, "provider_identifier", "proxy")
|
||||||
|
setStr(td, "tool_name", tool.Name)
|
||||||
|
toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestContextSuccess
|
||||||
|
rcs := newMsg("RequestContextSuccess")
|
||||||
|
setMsg(rcs, "request_context", rc)
|
||||||
|
|
||||||
|
// RequestContextResult (oneof success)
|
||||||
|
rcr := newMsg("RequestContextResult")
|
||||||
|
setMsg(rcr, "success", rcs)
|
||||||
|
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "request_context_result", rcr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeExecMcpResult responds with MCP tool result.
|
||||||
|
func EncodeExecMcpResult(execMsgId uint32, execId string, content string, isError bool) []byte {
|
||||||
|
textContent := newMsg("McpTextContent")
|
||||||
|
setStr(textContent, "text", content)
|
||||||
|
|
||||||
|
contentItem := newMsg("McpToolResultContentItem")
|
||||||
|
setMsg(contentItem, "text", textContent)
|
||||||
|
|
||||||
|
success := newMsg("McpSuccess")
|
||||||
|
contentField := field(success, "content")
|
||||||
|
contentList := success.Mutable(contentField).List()
|
||||||
|
contentList.Append(protoreflect.ValueOfMessage(contentItem.ProtoReflect()))
|
||||||
|
setBool(success, "is_error", isError)
|
||||||
|
|
||||||
|
result := newMsg("McpResult")
|
||||||
|
setMsg(result, "success", success)
|
||||||
|
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "mcp_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeExecMcpError responds with MCP error.
|
||||||
|
func EncodeExecMcpError(execMsgId uint32, execId string, errMsg string) []byte {
|
||||||
|
mcpErr := newMsg("McpError")
|
||||||
|
setStr(mcpErr, "error", errMsg)
|
||||||
|
|
||||||
|
result := newMsg("McpResult")
|
||||||
|
setMsg(result, "error", mcpErr)
|
||||||
|
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "mcp_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rejection encoders (mirror handleExecMessage rejections) ---
|
||||||
|
|
||||||
|
func EncodeExecReadRejected(execMsgId uint32, execId string, path, reason string) []byte {
|
||||||
|
rej := newMsg("ReadRejected")
|
||||||
|
setStr(rej, "path", path)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("ReadResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "read_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecShellRejected(execMsgId uint32, execId string, command, workDir, reason string) []byte {
|
||||||
|
rej := newMsg("ShellRejected")
|
||||||
|
setStr(rej, "command", command)
|
||||||
|
setStr(rej, "working_directory", workDir)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("ShellResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "shell_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecWriteRejected(execMsgId uint32, execId string, path, reason string) []byte {
|
||||||
|
rej := newMsg("WriteRejected")
|
||||||
|
setStr(rej, "path", path)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("WriteResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "write_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecDeleteRejected(execMsgId uint32, execId string, path, reason string) []byte {
|
||||||
|
rej := newMsg("DeleteRejected")
|
||||||
|
setStr(rej, "path", path)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("DeleteResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "delete_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecLsRejected(execMsgId uint32, execId string, path, reason string) []byte {
|
||||||
|
rej := newMsg("LsRejected")
|
||||||
|
setStr(rej, "path", path)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("LsResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "ls_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecGrepError(execMsgId uint32, execId string, errMsg string) []byte {
|
||||||
|
grepErr := newMsg("GrepError")
|
||||||
|
setStr(grepErr, "error", errMsg)
|
||||||
|
result := newMsg("GrepResult")
|
||||||
|
setMsg(result, "error", grepErr)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "grep_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecFetchError(execMsgId uint32, execId string, url, errMsg string) []byte {
|
||||||
|
fetchErr := newMsg("FetchError")
|
||||||
|
setStr(fetchErr, "url", url)
|
||||||
|
setStr(fetchErr, "error", errMsg)
|
||||||
|
result := newMsg("FetchResult")
|
||||||
|
setMsg(result, "error", fetchErr)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "fetch_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecDiagnosticsResult(execMsgId uint32, execId string) []byte {
|
||||||
|
result := newMsg("DiagnosticsResult")
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "diagnostics_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecBackgroundShellSpawnRejected(execMsgId uint32, execId string, command, workDir, reason string) []byte {
|
||||||
|
rej := newMsg("ShellRejected")
|
||||||
|
setStr(rej, "command", command)
|
||||||
|
setStr(rej, "working_directory", workDir)
|
||||||
|
setStr(rej, "reason", reason)
|
||||||
|
result := newMsg("BackgroundShellSpawnResult")
|
||||||
|
setMsg(result, "rejected", rej)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "background_shell_spawn_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeExecWriteShellStdinError(execMsgId uint32, execId string, errMsg string) []byte {
|
||||||
|
wsErr := newMsg("WriteShellStdinError")
|
||||||
|
setStr(wsErr, "error", errMsg)
|
||||||
|
result := newMsg("WriteShellStdinResult")
|
||||||
|
setMsg(result, "error", wsErr)
|
||||||
|
return encodeExecClientMsg(execMsgId, execId, "write_shell_stdin_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeExecClientMsg wraps an exec result in AgentClientMessage.
|
||||||
|
// Mirrors sendExec() in cursor-fetch.ts.
|
||||||
|
func encodeExecClientMsg(id uint32, execId string, resultFieldName string, resultMsg *dynamicpb.Message) []byte {
|
||||||
|
ecm := newMsg("ExecClientMessage")
|
||||||
|
setUint32(ecm, "id", id)
|
||||||
|
// Force set exec_id even if empty - Cursor requires this field to be set
|
||||||
|
ecm.Set(field(ecm, "exec_id"), protoreflect.ValueOfString(execId))
|
||||||
|
|
||||||
|
// Debug: check if field exists
|
||||||
|
fd := field(ecm, resultFieldName)
|
||||||
|
if fd == nil {
|
||||||
|
panic(fmt.Sprintf("field %q NOT FOUND in ExecClientMessage! Available fields: %v", resultFieldName, listFields(ecm)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: log the actual field being set
|
||||||
|
log.Debugf("encodeExecClientMsg: setting field %q (number=%d, kind=%s)", fd.Name(), fd.Number(), fd.Kind())
|
||||||
|
|
||||||
|
ecm.Set(fd, protoreflect.ValueOfMessage(resultMsg.ProtoReflect()))
|
||||||
|
|
||||||
|
acm := newMsg("AgentClientMessage")
|
||||||
|
setMsg(acm, "exec_client_message", ecm)
|
||||||
|
return marshal(acm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFields(msg *dynamicpb.Message) []string {
|
||||||
|
var names []string
|
||||||
|
for i := 0; i < msg.Descriptor().Fields().Len(); i++ {
|
||||||
|
names = append(names, string(msg.Descriptor().Fields().Get(i).Name()))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utilities ---
|
||||||
|
|
||||||
|
// jsonToProtobufValueBytes converts a JSON schema (json.RawMessage) to protobuf Value binary.
|
||||||
|
// This mirrors the TS pattern: toBinary(ValueSchema, fromJson(ValueSchema, jsonSchema))
|
||||||
|
func jsonToProtobufValueBytes(jsonData json.RawMessage) []byte {
|
||||||
|
if len(jsonData) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v interface{}
|
||||||
|
if err := json.Unmarshal(jsonData, &v); err != nil {
|
||||||
|
return jsonData // fallback to raw JSON if parsing fails
|
||||||
|
}
|
||||||
|
pbVal, err := structpb.NewValue(v)
|
||||||
|
if err != nil {
|
||||||
|
return jsonData // fallback
|
||||||
|
}
|
||||||
|
b, err := proto.Marshal(pbVal)
|
||||||
|
if err != nil {
|
||||||
|
return jsonData // fallback
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtobufValueBytesToJSON converts protobuf Value binary back to JSON.
|
||||||
|
// This mirrors the TS pattern: toJson(ValueSchema, fromBinary(ValueSchema, value))
|
||||||
|
func ProtobufValueBytesToJSON(data []byte) (interface{}, error) {
|
||||||
|
val := &structpb.Value{}
|
||||||
|
if err := proto.Unmarshal(data, val); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return val.AsInterface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Sum(data []byte) []byte {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var idCounter uint64
|
||||||
|
|
||||||
|
func generateId() string {
|
||||||
|
idCounter++
|
||||||
|
h := sha256.Sum256([]byte{byte(idCounter), byte(idCounter >> 8), byte(idCounter >> 16)})
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
332
internal/auth/cursor/proto/fieldnumbers.go
Normal file
332
internal/auth/cursor/proto/fieldnumbers.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
// Package proto provides hand-rolled protobuf encode/decode for Cursor's gRPC API.
|
||||||
|
// Field numbers are extracted from the TypeScript generated proto/agent_pb.ts in alma-plugins/cursor-auth.
|
||||||
|
package proto
|
||||||
|
|
||||||
|
// AgentClientMessage (msg 118) oneof "message"
|
||||||
|
const (
|
||||||
|
ACM_RunRequest = 1 // AgentRunRequest
|
||||||
|
ACM_ExecClientMessage = 2 // ExecClientMessage
|
||||||
|
ACM_KvClientMessage = 3 // KvClientMessage
|
||||||
|
ACM_ConversationAction = 4 // ConversationAction
|
||||||
|
ACM_ExecClientControlMsg = 5 // ExecClientControlMessage
|
||||||
|
ACM_InteractionResponse = 6 // InteractionResponse
|
||||||
|
ACM_ClientHeartbeat = 7 // ClientHeartbeat
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentServerMessage (msg 119) oneof "message"
|
||||||
|
const (
|
||||||
|
ASM_InteractionUpdate = 1 // InteractionUpdate
|
||||||
|
ASM_ExecServerMessage = 2 // ExecServerMessage
|
||||||
|
ASM_ConversationCheckpoint = 3 // ConversationStateStructure
|
||||||
|
ASM_KvServerMessage = 4 // KvServerMessage
|
||||||
|
ASM_ExecServerControlMessage = 5 // ExecServerControlMessage
|
||||||
|
ASM_InteractionQuery = 7 // InteractionQuery
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRunRequest (msg 91)
|
||||||
|
const (
|
||||||
|
ARR_ConversationState = 1 // ConversationStateStructure
|
||||||
|
ARR_Action = 2 // ConversationAction
|
||||||
|
ARR_ModelDetails = 3 // ModelDetails
|
||||||
|
ARR_McpTools = 4 // McpTools
|
||||||
|
ARR_ConversationId = 5 // string (optional)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationStateStructure (msg 83)
|
||||||
|
const (
|
||||||
|
CSS_RootPromptMessagesJson = 1 // repeated bytes
|
||||||
|
CSS_TurnsOld = 2 // repeated bytes (deprecated)
|
||||||
|
CSS_Todos = 3 // repeated bytes
|
||||||
|
CSS_PendingToolCalls = 4 // repeated string
|
||||||
|
CSS_Turns = 8 // repeated bytes (CURRENT field for turns)
|
||||||
|
CSS_PreviousWorkspaceUris = 9 // repeated string
|
||||||
|
CSS_SelfSummaryCount = 17 // uint32
|
||||||
|
CSS_ReadPaths = 18 // repeated string
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationAction (msg 54) oneof "action"
|
||||||
|
const (
|
||||||
|
CA_UserMessageAction = 1 // UserMessageAction
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserMessageAction (msg 55)
|
||||||
|
const (
|
||||||
|
UMA_UserMessage = 1 // UserMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserMessage (msg 63)
|
||||||
|
const (
|
||||||
|
UM_Text = 1 // string
|
||||||
|
UM_MessageId = 2 // string
|
||||||
|
UM_SelectedContext = 3 // SelectedContext (optional)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SelectedContext
|
||||||
|
const (
|
||||||
|
SC_SelectedImages = 1 // repeated SelectedImage
|
||||||
|
)
|
||||||
|
|
||||||
|
// SelectedImage
|
||||||
|
const (
|
||||||
|
SI_BlobId = 1 // bytes (oneof dataOrBlobId)
|
||||||
|
SI_Uuid = 2 // string
|
||||||
|
SI_Path = 3 // string
|
||||||
|
SI_MimeType = 7 // string
|
||||||
|
SI_Data = 8 // bytes (oneof dataOrBlobId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModelDetails (msg 88)
|
||||||
|
const (
|
||||||
|
MD_ModelId = 1 // string
|
||||||
|
MD_ThinkingDetails = 2 // ThinkingDetails (optional)
|
||||||
|
MD_DisplayModelId = 3 // string
|
||||||
|
MD_DisplayName = 4 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpTools (msg 307)
|
||||||
|
const (
|
||||||
|
MT_McpTools = 1 // repeated McpToolDefinition
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpToolDefinition (msg 306)
|
||||||
|
const (
|
||||||
|
MTD_Name = 1 // string
|
||||||
|
MTD_Description = 2 // string
|
||||||
|
MTD_InputSchema = 3 // bytes
|
||||||
|
MTD_ProviderIdentifier = 4 // string
|
||||||
|
MTD_ToolName = 5 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationTurnStructure (msg 70) oneof "turn"
|
||||||
|
const (
|
||||||
|
CTS_AgentConversationTurn = 1 // AgentConversationTurnStructure
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentConversationTurnStructure (msg 72)
|
||||||
|
const (
|
||||||
|
ACTS_UserMessage = 1 // bytes (serialized UserMessage)
|
||||||
|
ACTS_Steps = 2 // repeated bytes (serialized ConversationStep)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationStep (msg 53) oneof "message"
|
||||||
|
const (
|
||||||
|
CS_AssistantMessage = 1 // AssistantMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssistantMessage
|
||||||
|
const (
|
||||||
|
AM_Text = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Server-side message fields ---
|
||||||
|
|
||||||
|
// InteractionUpdate oneof "message"
|
||||||
|
const (
|
||||||
|
IU_TextDelta = 1 // TextDeltaUpdate
|
||||||
|
IU_ThinkingDelta = 4 // ThinkingDeltaUpdate
|
||||||
|
IU_ThinkingCompleted = 5 // ThinkingCompletedUpdate
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextDeltaUpdate (msg 92)
|
||||||
|
const (
|
||||||
|
TDU_Text = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThinkingDeltaUpdate (msg 97)
|
||||||
|
const (
|
||||||
|
TKD_Text = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// KvServerMessage (msg 271)
|
||||||
|
const (
|
||||||
|
KSM_Id = 1 // uint32
|
||||||
|
KSM_GetBlobArgs = 2 // GetBlobArgs
|
||||||
|
KSM_SetBlobArgs = 3 // SetBlobArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlobArgs (msg 267)
|
||||||
|
const (
|
||||||
|
GBA_BlobId = 1 // bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetBlobArgs (msg 269)
|
||||||
|
const (
|
||||||
|
SBA_BlobId = 1 // bytes
|
||||||
|
SBA_BlobData = 2 // bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// KvClientMessage (msg 272)
|
||||||
|
const (
|
||||||
|
KCM_Id = 1 // uint32
|
||||||
|
KCM_GetBlobResult = 2 // GetBlobResult
|
||||||
|
KCM_SetBlobResult = 3 // SetBlobResult
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlobResult (msg 268)
|
||||||
|
const (
|
||||||
|
GBR_BlobData = 1 // bytes (optional)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecServerMessage
|
||||||
|
const (
|
||||||
|
ESM_Id = 1 // uint32
|
||||||
|
ESM_ExecId = 15 // string
|
||||||
|
// oneof message:
|
||||||
|
ESM_ShellArgs = 2 // ShellArgs
|
||||||
|
ESM_WriteArgs = 3 // WriteArgs
|
||||||
|
ESM_DeleteArgs = 4 // DeleteArgs
|
||||||
|
ESM_GrepArgs = 5 // GrepArgs
|
||||||
|
ESM_ReadArgs = 7 // ReadArgs (NOTE: 6 is skipped)
|
||||||
|
ESM_LsArgs = 8 // LsArgs
|
||||||
|
ESM_DiagnosticsArgs = 9 // DiagnosticsArgs
|
||||||
|
ESM_RequestContextArgs = 10 // RequestContextArgs
|
||||||
|
ESM_McpArgs = 11 // McpArgs
|
||||||
|
ESM_ShellStreamArgs = 14 // ShellArgs (stream variant)
|
||||||
|
ESM_BackgroundShellSpawn = 16 // BackgroundShellSpawnArgs
|
||||||
|
ESM_FetchArgs = 20 // FetchArgs
|
||||||
|
ESM_WriteShellStdinArgs = 23 // WriteShellStdinArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecClientMessage
|
||||||
|
const (
|
||||||
|
ECM_Id = 1 // uint32
|
||||||
|
ECM_ExecId = 15 // string
|
||||||
|
// oneof message (mirrors server fields):
|
||||||
|
ECM_ShellResult = 2
|
||||||
|
ECM_WriteResult = 3
|
||||||
|
ECM_DeleteResult = 4
|
||||||
|
ECM_GrepResult = 5
|
||||||
|
ECM_ReadResult = 7
|
||||||
|
ECM_LsResult = 8
|
||||||
|
ECM_DiagnosticsResult = 9
|
||||||
|
ECM_RequestContextResult = 10
|
||||||
|
ECM_McpResult = 11
|
||||||
|
ECM_ShellStream = 14
|
||||||
|
ECM_BackgroundShellSpawnRes = 16
|
||||||
|
ECM_FetchResult = 20
|
||||||
|
ECM_WriteShellStdinResult = 23
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpArgs
|
||||||
|
const (
|
||||||
|
MCA_Name = 1 // string
|
||||||
|
MCA_Args = 2 // map<string, bytes>
|
||||||
|
MCA_ToolCallId = 3 // string
|
||||||
|
MCA_ProviderIdentifier = 4 // string
|
||||||
|
MCA_ToolName = 5 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestContextResult oneof "result"
|
||||||
|
const (
|
||||||
|
RCR_Success = 1 // RequestContextSuccess
|
||||||
|
RCR_Error = 2 // RequestContextError
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestContextSuccess (msg 337)
|
||||||
|
const (
|
||||||
|
RCS_RequestContext = 1 // RequestContext
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestContext
|
||||||
|
const (
|
||||||
|
RC_Rules = 2 // repeated CursorRule
|
||||||
|
RC_Tools = 7 // repeated McpToolDefinition
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpResult oneof "result"
|
||||||
|
const (
|
||||||
|
MCR_Success = 1 // McpSuccess
|
||||||
|
MCR_Error = 2 // McpError
|
||||||
|
MCR_Rejected = 3 // McpRejected
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpSuccess (msg 290)
|
||||||
|
const (
|
||||||
|
MCS_Content = 1 // repeated McpToolResultContentItem
|
||||||
|
MCS_IsError = 2 // bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpToolResultContentItem oneof "content"
|
||||||
|
const (
|
||||||
|
MTRCI_Text = 1 // McpTextContent
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpTextContent (msg 287)
|
||||||
|
const (
|
||||||
|
MTC_Text = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// McpError (msg 291)
|
||||||
|
const (
|
||||||
|
MCE_Error = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Rejection messages ---
|
||||||
|
|
||||||
|
// ReadRejected: path=1, reason=2
|
||||||
|
// ShellRejected: command=1, workingDirectory=2, reason=3, isReadonly=4
|
||||||
|
// WriteRejected: path=1, reason=2
|
||||||
|
// DeleteRejected: path=1, reason=2
|
||||||
|
// LsRejected: path=1, reason=2
|
||||||
|
// GrepError: error=1
|
||||||
|
// FetchError: url=1, error=2
|
||||||
|
// WriteShellStdinError: error=1
|
||||||
|
|
||||||
|
// ReadResult oneof: success=1, error=2, rejected=3
|
||||||
|
// ShellResult oneof: success=1 (+ various), rejected=?
|
||||||
|
// The TS code uses specific result field numbers from the oneof:
|
||||||
|
const (
|
||||||
|
RR_Rejected = 3 // ReadResult.rejected
|
||||||
|
SR_Rejected = 5 // ShellResult.rejected (from TS: ShellResult has success/various/rejected)
|
||||||
|
WR_Rejected = 5 // WriteResult.rejected
|
||||||
|
DR_Rejected = 3 // DeleteResult.rejected
|
||||||
|
LR_Rejected = 3 // LsResult.rejected
|
||||||
|
GR_Error = 2 // GrepResult.error
|
||||||
|
FR_Error = 2 // FetchResult.error
|
||||||
|
BSSR_Rejected = 2 // BackgroundShellSpawnResult.rejected (error field)
|
||||||
|
WSSR_Error = 2 // WriteShellStdinResult.error
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Rejection struct fields ---
|
||||||
|
const (
|
||||||
|
REJ_Path = 1
|
||||||
|
REJ_Reason = 2
|
||||||
|
SREJ_Command = 1
|
||||||
|
SREJ_WorkingDir = 2
|
||||||
|
SREJ_Reason = 3
|
||||||
|
SREJ_IsReadonly = 4
|
||||||
|
GERR_Error = 1
|
||||||
|
FERR_Url = 1
|
||||||
|
FERR_Error = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadArgs
|
||||||
|
const (
|
||||||
|
RA_Path = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteArgs
|
||||||
|
const (
|
||||||
|
WA_Path = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteArgs
|
||||||
|
const (
|
||||||
|
DA_Path = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// LsArgs
|
||||||
|
const (
|
||||||
|
LA_Path = 1 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShellArgs
|
||||||
|
const (
|
||||||
|
SHA_Command = 1 // string
|
||||||
|
SHA_WorkingDirectory = 2 // string
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchArgs
|
||||||
|
const (
|
||||||
|
FA_Url = 1 // string
|
||||||
|
)
|
||||||
313
internal/auth/cursor/proto/h2stream.go
Normal file
313
internal/auth/cursor/proto/h2stream.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/net/http2/hpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultInitialWindowSize = 65535 // HTTP/2 default
|
||||||
|
maxFramePayload = 16384 // HTTP/2 default max frame size
|
||||||
|
)
|
||||||
|
|
||||||
|
// H2Stream provides bidirectional HTTP/2 streaming for the Connect protocol.
|
||||||
|
// Go's net/http does not support full-duplex HTTP/2, so we use the low-level framer.
|
||||||
|
type H2Stream struct {
|
||||||
|
framer *http2.Framer
|
||||||
|
conn net.Conn
|
||||||
|
streamID uint32
|
||||||
|
mu sync.Mutex
|
||||||
|
id string // unique identifier for debugging
|
||||||
|
frameNum int64 // sequential frame counter for debugging
|
||||||
|
|
||||||
|
dataCh chan []byte
|
||||||
|
doneCh chan struct{}
|
||||||
|
err error
|
||||||
|
|
||||||
|
// Send-side flow control
|
||||||
|
sendWindow int32 // available bytes we can send on this stream
|
||||||
|
connWindow int32 // available bytes on the connection level
|
||||||
|
windowCond *sync.Cond // signaled when window is updated
|
||||||
|
windowMu sync.Mutex // protects sendWindow, connWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique identifier for this stream (for logging).
|
||||||
|
func (s *H2Stream) ID() string { return s.id }
|
||||||
|
|
||||||
|
// FrameNum returns the current frame number for debugging.
|
||||||
|
func (s *H2Stream) FrameNum() int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.frameNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialH2Stream establishes a TLS+HTTP/2 connection and opens a new stream.
|
||||||
|
func DialH2Stream(host string, headers map[string]string) (*H2Stream, error) {
|
||||||
|
tlsConn, err := tls.Dial("tcp", host+":443", &tls.Config{
|
||||||
|
NextProtos: []string{"h2"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("h2: TLS dial failed: %w", err)
|
||||||
|
}
|
||||||
|
if tlsConn.ConnectionState().NegotiatedProtocol != "h2" {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: server did not negotiate h2")
|
||||||
|
}
|
||||||
|
|
||||||
|
framer := http2.NewFramer(tlsConn, tlsConn)
|
||||||
|
|
||||||
|
// Client connection preface
|
||||||
|
if _, err := tlsConn.Write([]byte(http2.ClientPreface)); err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: preface write failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial SETTINGS (tell server how much WE can receive)
|
||||||
|
if err := framer.WriteSettings(
|
||||||
|
http2.Setting{ID: http2.SettingInitialWindowSize, Val: 4 * 1024 * 1024},
|
||||||
|
http2.Setting{ID: http2.SettingMaxConcurrentStreams, Val: 100},
|
||||||
|
); err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: settings write failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-level window update (for receiving)
|
||||||
|
if err := framer.WriteWindowUpdate(0, 3*1024*1024); err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: window update failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and handle initial server frames (SETTINGS, WINDOW_UPDATE)
|
||||||
|
// Track server's initial window size (how much WE can send)
|
||||||
|
serverInitialWindowSize := int32(defaultInitialWindowSize)
|
||||||
|
connWindowSize := int32(defaultInitialWindowSize) // connection-level send window
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
f, err := framer.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: initial frame read failed: %w", err)
|
||||||
|
}
|
||||||
|
switch sf := f.(type) {
|
||||||
|
case *http2.SettingsFrame:
|
||||||
|
if !sf.IsAck() {
|
||||||
|
sf.ForeachSetting(func(s http2.Setting) error {
|
||||||
|
if s.ID == http2.SettingInitialWindowSize {
|
||||||
|
serverInitialWindowSize = int32(s.Val)
|
||||||
|
log.Debugf("h2: server initial window size: %d", s.Val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
framer.WriteSettingsAck()
|
||||||
|
} else {
|
||||||
|
goto handshakeDone
|
||||||
|
}
|
||||||
|
case *http2.WindowUpdateFrame:
|
||||||
|
if sf.StreamID == 0 {
|
||||||
|
connWindowSize += int32(sf.Increment)
|
||||||
|
log.Debugf("h2: initial conn window update: +%d, total=%d", sf.Increment, connWindowSize)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// unexpected but continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handshakeDone:
|
||||||
|
|
||||||
|
// Build HEADERS
|
||||||
|
streamID := uint32(1)
|
||||||
|
var hdrBuf []byte
|
||||||
|
enc := hpack.NewEncoder(&sliceWriter{buf: &hdrBuf})
|
||||||
|
enc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"})
|
||||||
|
enc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "https"})
|
||||||
|
enc.WriteField(hpack.HeaderField{Name: ":authority", Value: host})
|
||||||
|
if p, ok := headers[":path"]; ok {
|
||||||
|
enc.WriteField(hpack.HeaderField{Name: ":path", Value: p})
|
||||||
|
}
|
||||||
|
for k, v := range headers {
|
||||||
|
if len(k) > 0 && k[0] == ':' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enc.WriteField(hpack.HeaderField{Name: k, Value: v})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := framer.WriteHeaders(http2.HeadersFrameParam{
|
||||||
|
StreamID: streamID,
|
||||||
|
BlockFragment: hdrBuf,
|
||||||
|
EndStream: false,
|
||||||
|
EndHeaders: true,
|
||||||
|
}); err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, fmt.Errorf("h2: headers write failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &H2Stream{
|
||||||
|
framer: framer,
|
||||||
|
conn: tlsConn,
|
||||||
|
streamID: streamID,
|
||||||
|
dataCh: make(chan []byte, 256),
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
id: fmt.Sprintf("%d-%s", streamID, time.Now().Format("150405.000")),
|
||||||
|
frameNum: 0,
|
||||||
|
sendWindow: serverInitialWindowSize,
|
||||||
|
connWindow: connWindowSize,
|
||||||
|
}
|
||||||
|
s.windowCond = sync.NewCond(&s.windowMu)
|
||||||
|
go s.readLoop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends a DATA frame on the stream, respecting flow control.
|
||||||
|
func (s *H2Stream) Write(data []byte) error {
|
||||||
|
for len(data) > 0 {
|
||||||
|
chunk := data
|
||||||
|
if len(chunk) > maxFramePayload {
|
||||||
|
chunk = data[:maxFramePayload]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for flow control window
|
||||||
|
s.windowMu.Lock()
|
||||||
|
for s.sendWindow <= 0 || s.connWindow <= 0 {
|
||||||
|
s.windowCond.Wait()
|
||||||
|
}
|
||||||
|
// Limit chunk to available window
|
||||||
|
allowed := int(s.sendWindow)
|
||||||
|
if int(s.connWindow) < allowed {
|
||||||
|
allowed = int(s.connWindow)
|
||||||
|
}
|
||||||
|
if len(chunk) > allowed {
|
||||||
|
chunk = chunk[:allowed]
|
||||||
|
}
|
||||||
|
s.sendWindow -= int32(len(chunk))
|
||||||
|
s.connWindow -= int32(len(chunk))
|
||||||
|
s.windowMu.Unlock()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
err := s.framer.WriteData(s.streamID, false, chunk)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = data[len(chunk):]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data returns the channel of received data chunks.
|
||||||
|
func (s *H2Stream) Data() <-chan []byte { return s.dataCh }
|
||||||
|
|
||||||
|
// Done returns a channel closed when the stream ends.
|
||||||
|
func (s *H2Stream) Done() <-chan struct{} { return s.doneCh }
|
||||||
|
|
||||||
|
// Err returns the error (if any) that caused the stream to close.
|
||||||
|
// Returns nil for a clean shutdown (EOF / StreamEnded).
|
||||||
|
func (s *H2Stream) Err() error { return s.err }
|
||||||
|
|
||||||
|
// Close tears down the connection.
|
||||||
|
func (s *H2Stream) Close() {
|
||||||
|
s.conn.Close()
|
||||||
|
// Unblock any writers waiting on flow control
|
||||||
|
s.windowCond.Broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *H2Stream) readLoop() {
|
||||||
|
defer close(s.doneCh)
|
||||||
|
defer close(s.dataCh)
|
||||||
|
|
||||||
|
for {
|
||||||
|
f, err := s.framer.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
s.err = err
|
||||||
|
log.Debugf("h2stream[%s]: readLoop error: %v", s.id, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment frame counter
|
||||||
|
s.mu.Lock()
|
||||||
|
s.frameNum++
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
switch frame := f.(type) {
|
||||||
|
case *http2.DataFrame:
|
||||||
|
if frame.StreamID == s.streamID && len(frame.Data()) > 0 {
|
||||||
|
cp := make([]byte, len(frame.Data()))
|
||||||
|
copy(cp, frame.Data())
|
||||||
|
s.dataCh <- cp
|
||||||
|
|
||||||
|
// Flow control: send WINDOW_UPDATE for received data
|
||||||
|
s.mu.Lock()
|
||||||
|
s.framer.WriteWindowUpdate(0, uint32(len(cp)))
|
||||||
|
s.framer.WriteWindowUpdate(s.streamID, uint32(len(cp)))
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
if frame.StreamEnded() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case *http2.HeadersFrame:
|
||||||
|
if frame.StreamEnded() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case *http2.RSTStreamFrame:
|
||||||
|
s.err = fmt.Errorf("h2: RST_STREAM code=%d", frame.ErrCode)
|
||||||
|
log.Debugf("h2stream[%s]: received RST_STREAM code=%d", s.id, frame.ErrCode)
|
||||||
|
return
|
||||||
|
|
||||||
|
case *http2.GoAwayFrame:
|
||||||
|
s.err = fmt.Errorf("h2: GOAWAY code=%d", frame.ErrCode)
|
||||||
|
return
|
||||||
|
|
||||||
|
case *http2.PingFrame:
|
||||||
|
if !frame.IsAck() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.framer.WritePing(true, frame.Data)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
case *http2.SettingsFrame:
|
||||||
|
if !frame.IsAck() {
|
||||||
|
// Check for window size changes
|
||||||
|
frame.ForeachSetting(func(setting http2.Setting) error {
|
||||||
|
if setting.ID == http2.SettingInitialWindowSize {
|
||||||
|
s.windowMu.Lock()
|
||||||
|
delta := int32(setting.Val) - s.sendWindow
|
||||||
|
s.sendWindow += delta
|
||||||
|
s.windowMu.Unlock()
|
||||||
|
s.windowCond.Broadcast()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
s.mu.Lock()
|
||||||
|
s.framer.WriteSettingsAck()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
case *http2.WindowUpdateFrame:
|
||||||
|
// Update send-side flow control window
|
||||||
|
s.windowMu.Lock()
|
||||||
|
if frame.StreamID == 0 {
|
||||||
|
s.connWindow += int32(frame.Increment)
|
||||||
|
} else if frame.StreamID == s.streamID {
|
||||||
|
s.sendWindow += int32(frame.Increment)
|
||||||
|
}
|
||||||
|
s.windowMu.Unlock()
|
||||||
|
s.windowCond.Broadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sliceWriter struct{ buf *[]byte }
|
||||||
|
|
||||||
|
func (w *sliceWriter) Write(p []byte) (int, error) {
|
||||||
|
*w.buf = append(*w.buf, p...)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
@@ -20,9 +18,9 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
@@ -80,35 +78,15 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
|||||||
}
|
}
|
||||||
callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort)
|
callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort)
|
||||||
|
|
||||||
// Configure proxy settings for the HTTP client if a proxy URL is provided.
|
transport, _, errBuild := proxyutil.BuildHTTPTransport(cfg.ProxyURL)
|
||||||
proxyURL, err := url.Parse(cfg.ProxyURL)
|
if errBuild != nil {
|
||||||
if err == nil {
|
log.Errorf("%v", errBuild)
|
||||||
var transport *http.Transport
|
} else if transport != nil {
|
||||||
if proxyURL.Scheme == "socks5" {
|
|
||||||
// Handle SOCKS5 proxy.
|
|
||||||
username := proxyURL.User.Username()
|
|
||||||
password, _ := proxyURL.User.Password()
|
|
||||||
auth := &proxy.Auth{User: username, Password: password}
|
|
||||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
|
|
||||||
if errSOCKS5 != nil {
|
|
||||||
log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
|
||||||
return nil, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5)
|
|
||||||
}
|
|
||||||
transport = &http.Transport{
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return dialer.Dial(network, addr)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
|
||||||
// Handle HTTP/HTTPS proxy.
|
|
||||||
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if transport != nil {
|
|
||||||
proxyClient := &http.Client{Transport: transport}
|
proxyClient := &http.Client{Transport: transport}
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
var err error
|
||||||
|
|
||||||
// Configure the OAuth2 client.
|
// Configure the OAuth2 client.
|
||||||
conf := &oauth2.Config{
|
conf := &oauth2.Config{
|
||||||
@@ -327,6 +305,9 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
|||||||
defer manualPromptTimer.Stop()
|
defer manualPromptTimer.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manualInputCh <-chan string
|
||||||
|
var manualInputErrCh <-chan error
|
||||||
|
|
||||||
waitForCallback:
|
waitForCallback:
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -348,13 +329,14 @@ waitForCallback:
|
|||||||
return nil, err
|
return nil, err
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
input, err := opts.Prompt("Paste the Gemini callback URL (or press Enter to keep waiting): ")
|
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the Gemini callback URL (or press Enter to keep waiting): ")
|
||||||
if err != nil {
|
continue
|
||||||
return nil, err
|
case input := <-manualInputCh:
|
||||||
}
|
manualInputCh = nil
|
||||||
parsed, err := misc.ParseOAuthCallback(input)
|
manualInputErrCh = nil
|
||||||
if err != nil {
|
parsed, errParse := misc.ParseOAuthCallback(input)
|
||||||
return nil, err
|
if errParse != nil {
|
||||||
|
return nil, errParse
|
||||||
}
|
}
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
continue
|
continue
|
||||||
@@ -367,6 +349,8 @@ waitForCallback:
|
|||||||
}
|
}
|
||||||
authCode = parsed.Code
|
authCode = parsed.Code
|
||||||
break waitForCallback
|
break waitForCallback
|
||||||
|
case errManual := <-manualInputErrCh:
|
||||||
|
return nil, errManual
|
||||||
case <-timeoutTimer.C:
|
case <-timeoutTimer.C:
|
||||||
return nil, fmt.Errorf("oauth flow timed out")
|
return nil, fmt.Errorf("oauth flow timed out")
|
||||||
}
|
}
|
||||||
|
|||||||
492
internal/auth/gitlab/gitlab.go
Normal file
492
internal/auth/gitlab/gitlab.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultBaseURL = "https://gitlab.com"
|
||||||
|
DefaultCallbackPort = 17171
|
||||||
|
defaultOAuthScope = "api read_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PKCECodes struct {
|
||||||
|
CodeVerifier string
|
||||||
|
CodeChallenge string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthResult struct {
|
||||||
|
Code string
|
||||||
|
State string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthServer struct {
|
||||||
|
server *http.Server
|
||||||
|
port int
|
||||||
|
resultChan chan *OAuthResult
|
||||||
|
errorChan chan error
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PublicEmail string `json:"public_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalAccessTokenSelf struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelDetails struct {
|
||||||
|
ModelProvider string `json:"model_provider"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectAccessResponse struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
ModelDetails *ModelDetails `json:"model_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveredModel struct {
|
||||||
|
ModelProvider string
|
||||||
|
ModelName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthClient(cfg *config.Config) *AuthClient {
|
||||||
|
client := &http.Client{}
|
||||||
|
if cfg != nil {
|
||||||
|
client = util.SetProxy(&cfg.SDKConfig, client)
|
||||||
|
}
|
||||||
|
return &AuthClient{httpClient: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeBaseURL(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return DefaultBaseURL
|
||||||
|
}
|
||||||
|
if !strings.Contains(value, "://") {
|
||||||
|
value = "https://" + value
|
||||||
|
}
|
||||||
|
value = strings.TrimRight(value, "/")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenExpiry(now time.Time, token *TokenResponse) time.Time {
|
||||||
|
if token == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if token.CreatedAt > 0 && token.ExpiresIn > 0 {
|
||||||
|
return time.Unix(token.CreatedAt+int64(token.ExpiresIn), 0).UTC()
|
||||||
|
}
|
||||||
|
if token.ExpiresIn > 0 {
|
||||||
|
return now.UTC().Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||||
|
verifierBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(verifierBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab pkce generation failed: %w", err)
|
||||||
|
}
|
||||||
|
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
return &PKCECodes{
|
||||||
|
CodeVerifier: verifier,
|
||||||
|
CodeChallenge: challenge,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthServer(port int) *OAuthServer {
|
||||||
|
return &OAuthServer{
|
||||||
|
port: port,
|
||||||
|
resultChan: make(chan *OAuthResult, 1),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("gitlab oauth server already running")
|
||||||
|
}
|
||||||
|
if !s.isPortAvailable() {
|
||||||
|
return fmt.Errorf("port %d is already in use", s.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/auth/callback", s.handleCallback)
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
s.errorChan <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running || s.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
s.running = false
|
||||||
|
s.server = nil
|
||||||
|
}()
|
||||||
|
return s.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||||
|
select {
|
||||||
|
case result := <-s.resultChan:
|
||||||
|
return result, nil
|
||||||
|
case err := <-s.errorChan:
|
||||||
|
return nil, err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
if errParam := strings.TrimSpace(query.Get("error")); errParam != "" {
|
||||||
|
s.sendResult(&OAuthResult{Error: errParam})
|
||||||
|
http.Error(w, errParam, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(query.Get("code"))
|
||||||
|
state := strings.TrimSpace(query.Get("state"))
|
||||||
|
if code == "" || state == "" {
|
||||||
|
s.sendResult(&OAuthResult{Error: "missing_code_or_state"})
|
||||||
|
http.Error(w, "missing code or state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendResult(&OAuthResult{Code: code, State: state})
|
||||||
|
_, _ = w.Write([]byte("GitLab authentication received. You can close this tab."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||||
|
select {
|
||||||
|
case s.resultChan <- result:
|
||||||
|
default:
|
||||||
|
log.Debug("gitlab oauth result channel full, dropping callback result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthServer) isPortAvailable() bool {
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = listener.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RedirectURL(port int) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d/auth/callback", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) GenerateAuthURL(baseURL, clientID, redirectURI, state string, pkce *PKCECodes) (string, error) {
|
||||||
|
if pkce == nil {
|
||||||
|
return "", fmt.Errorf("gitlab auth URL generation failed: PKCE codes are required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(clientID) == "" {
|
||||||
|
return "", fmt.Errorf("gitlab auth URL generation failed: client ID is required")
|
||||||
|
}
|
||||||
|
baseURL = NormalizeBaseURL(baseURL)
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {strings.TrimSpace(clientID)},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"redirect_uri": {strings.TrimSpace(redirectURI)},
|
||||||
|
"scope": {defaultOAuthScope},
|
||||||
|
"state": {strings.TrimSpace(state)},
|
||||||
|
"code_challenge": {pkce.CodeChallenge},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/oauth/authorize?%s", baseURL, params.Encode()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) ExchangeCodeForTokens(ctx context.Context, baseURL, clientID, clientSecret, redirectURI, code, codeVerifier string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {strings.TrimSpace(clientID)},
|
||||||
|
"code": {strings.TrimSpace(code)},
|
||||||
|
"redirect_uri": {strings.TrimSpace(redirectURI)},
|
||||||
|
"code_verifier": {strings.TrimSpace(codeVerifier)},
|
||||||
|
}
|
||||||
|
if secret := strings.TrimSpace(clientSecret); secret != "" {
|
||||||
|
form.Set("client_secret", secret)
|
||||||
|
}
|
||||||
|
return c.postToken(ctx, NormalizeBaseURL(baseURL)+"/oauth/token", form)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) RefreshTokens(ctx context.Context, baseURL, clientID, clientSecret, refreshToken string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
"refresh_token": {strings.TrimSpace(refreshToken)},
|
||||||
|
}
|
||||||
|
if clientID = strings.TrimSpace(clientID); clientID != "" {
|
||||||
|
form.Set("client_id", clientID)
|
||||||
|
}
|
||||||
|
if secret := strings.TrimSpace(clientSecret); secret != "" {
|
||||||
|
form.Set("client_secret", secret)
|
||||||
|
}
|
||||||
|
return c.postToken(ctx, NormalizeBaseURL(baseURL)+"/oauth/token", form)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) postToken(ctx context.Context, tokenURL string, form url.Values) (*TokenResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab token request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab token request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab token response read failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("gitlab token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var token TokenResponse
|
||||||
|
if err := json.Unmarshal(body, &token); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab token response decode failed: %w", err)
|
||||||
|
}
|
||||||
|
return &token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) GetCurrentUser(ctx context.Context, baseURL, token string) (*User, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, NormalizeBaseURL(baseURL)+"/api/v4/user", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab user request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab user request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab user response read failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("gitlab user request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := json.Unmarshal(body, &user); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab user response decode failed: %w", err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) GetPersonalAccessTokenSelf(ctx context.Context, baseURL, token string) (*PersonalAccessTokenSelf, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, NormalizeBaseURL(baseURL)+"/api/v4/personal_access_tokens/self", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab PAT self request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab PAT self request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab PAT self response read failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("gitlab PAT self request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pat PersonalAccessTokenSelf
|
||||||
|
if err := json.Unmarshal(body, &pat); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab PAT self response decode failed: %w", err)
|
||||||
|
}
|
||||||
|
return &pat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthClient) FetchDirectAccess(ctx context.Context, baseURL, token string) (*DirectAccessResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, NormalizeBaseURL(baseURL)+"/api/v4/code_suggestions/direct_access", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab direct access request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab direct access request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab direct access response read failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("gitlab direct access request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var direct DirectAccessResponse
|
||||||
|
if err := json.Unmarshal(body, &direct); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab direct access response decode failed: %w", err)
|
||||||
|
}
|
||||||
|
if direct.Headers == nil {
|
||||||
|
direct.Headers = make(map[string]string)
|
||||||
|
}
|
||||||
|
return &direct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractDiscoveredModels(metadata map[string]any) []DiscoveredModel {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]DiscoveredModel, 0, 4)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
appendModel := func(provider, name string) {
|
||||||
|
provider = strings.TrimSpace(provider)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
models = append(models, DiscoveredModel{
|
||||||
|
ModelProvider: provider,
|
||||||
|
ModelName: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, ok := metadata["model_details"]; ok {
|
||||||
|
appendDiscoveredModels(raw, appendModel)
|
||||||
|
}
|
||||||
|
appendModel(stringValue(metadata["model_provider"]), stringValue(metadata["model_name"]))
|
||||||
|
|
||||||
|
for _, key := range []string{"models", "supported_models", "discovered_models"} {
|
||||||
|
if raw, ok := metadata[key]; ok {
|
||||||
|
appendDiscoveredModels(raw, appendModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendDiscoveredModels(raw any, appendModel func(provider, name string)) {
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
appendModel(stringValue(typed["model_provider"]), stringValue(typed["model_name"]))
|
||||||
|
appendModel(stringValue(typed["provider"]), stringValue(typed["name"]))
|
||||||
|
if nested, ok := typed["models"]; ok {
|
||||||
|
appendDiscoveredModels(nested, appendModel)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendDiscoveredModels(item, appendModel)
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendModel("", item)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
appendModel("", typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(raw any) string {
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
case fmt.Stringer:
|
||||||
|
return strings.TrimSpace(typed.String())
|
||||||
|
case json.Number:
|
||||||
|
return typed.String()
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(typed)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(typed, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatInt(int64(typed), 10)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
138
internal/auth/gitlab/gitlab_test.go
Normal file
138
internal/auth/gitlab/gitlab_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthClientGenerateAuthURLIncludesPKCE(t *testing.T) {
|
||||||
|
client := NewAuthClient(nil)
|
||||||
|
pkce, err := GeneratePKCECodes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GeneratePKCECodes() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawURL, err := client.GenerateAuthURL("https://gitlab.example.com", "client-id", RedirectURL(17171), "state-123", pkce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateAuthURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(authURL) error = %v", err)
|
||||||
|
}
|
||||||
|
if got := parsed.Path; got != "/oauth/authorize" {
|
||||||
|
t.Fatalf("expected /oauth/authorize path, got %q", got)
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
if got := query.Get("client_id"); got != "client-id" {
|
||||||
|
t.Fatalf("expected client_id, got %q", got)
|
||||||
|
}
|
||||||
|
if got := query.Get("scope"); got != defaultOAuthScope {
|
||||||
|
t.Fatalf("expected scope %q, got %q", defaultOAuthScope, got)
|
||||||
|
}
|
||||||
|
if got := query.Get("code_challenge_method"); got != "S256" {
|
||||||
|
t.Fatalf("expected PKCE method S256, got %q", got)
|
||||||
|
}
|
||||||
|
if got := query.Get("code_challenge"); got == "" {
|
||||||
|
t.Fatal("expected non-empty code_challenge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthClientExchangeCodeForTokens(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/oauth/token" {
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
t.Fatalf("ParseForm() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := r.Form.Get("grant_type"); got != "authorization_code" {
|
||||||
|
t.Fatalf("expected authorization_code grant, got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Form.Get("code_verifier"); got != "verifier-123" {
|
||||||
|
t.Fatalf("expected code_verifier, got %q", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "oauth-access",
|
||||||
|
"refresh_token": "oauth-refresh",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api read_user",
|
||||||
|
"created_at": 1710000000,
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAuthClient(nil)
|
||||||
|
token, err := client.ExchangeCodeForTokens(context.Background(), srv.URL, "client-id", "client-secret", RedirectURL(17171), "auth-code", "verifier-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExchangeCodeForTokens() error = %v", err)
|
||||||
|
}
|
||||||
|
if token.AccessToken != "oauth-access" {
|
||||||
|
t.Fatalf("expected access token, got %q", token.AccessToken)
|
||||||
|
}
|
||||||
|
if token.RefreshToken != "oauth-refresh" {
|
||||||
|
t.Fatalf("expected refresh token, got %q", token.RefreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDiscoveredModels(t *testing.T) {
|
||||||
|
models := ExtractDiscoveredModels(map[string]any{
|
||||||
|
"model_details": map[string]any{
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
"supported_models": []any{
|
||||||
|
map[string]any{"model_provider": "openai", "model_name": "gpt-4.1"},
|
||||||
|
"claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Fatalf("expected 2 unique models, got %d", len(models))
|
||||||
|
}
|
||||||
|
if models[0].ModelName != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("unexpected first model %q", models[0].ModelName)
|
||||||
|
}
|
||||||
|
if models[1].ModelName != "gpt-4.1" {
|
||||||
|
t.Fatalf("unexpected second model %q", models[1].ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDirectAccessDecodesModelDetails(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v4/code_suggestions/direct_access" {
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); !strings.Contains(got, "token-123") {
|
||||||
|
t.Fatalf("expected bearer token, got %q", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"base_url": "https://cloud.gitlab.example.com",
|
||||||
|
"token": "gateway-token",
|
||||||
|
"expires_at": 1710003600,
|
||||||
|
"headers": map[string]string{
|
||||||
|
"X-Gitlab-Realm": "saas",
|
||||||
|
},
|
||||||
|
"model_details": map[string]any{
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAuthClient(nil)
|
||||||
|
direct, err := client.FetchDirectAccess(context.Background(), srv.URL, "token-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchDirectAccess() error = %v", err)
|
||||||
|
}
|
||||||
|
if direct.ModelDetails == nil || direct.ModelDetails.ModelName != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("expected model details, got %+v", direct.ModelDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// newAuthManager creates a new authentication manager instance with all supported
|
// newAuthManager creates a new authentication manager instance with all supported
|
||||||
// authenticators and a file-based token store. It initializes authenticators for
|
// authenticators and a file-based token store.
|
||||||
// Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers.
|
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - *sdkAuth.Manager: A configured authentication manager instance
|
// - *sdkAuth.Manager: A configured authentication manager instance
|
||||||
@@ -23,6 +22,9 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewKiroAuthenticator(),
|
sdkAuth.NewKiroAuthenticator(),
|
||||||
sdkAuth.NewGitHubCopilotAuthenticator(),
|
sdkAuth.NewGitHubCopilotAuthenticator(),
|
||||||
sdkAuth.NewKiloAuthenticator(),
|
sdkAuth.NewKiloAuthenticator(),
|
||||||
|
sdkAuth.NewGitLabAuthenticator(),
|
||||||
|
sdkAuth.NewCodeBuddyAuthenticator(),
|
||||||
|
sdkAuth.NewCursorAuthenticator(),
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|||||||
43
internal/cmd/codebuddy_login.go
Normal file
43
internal/cmd/codebuddy_login.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoCodeBuddyLogin triggers the browser OAuth polling flow for CodeBuddy and saves tokens.
|
||||||
|
// It initiates the OAuth authentication, displays the user code for the user to enter
|
||||||
|
// at the CodeBuddy verification URL, and waits for authorization before saving the tokens.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration containing proxy and auth directory settings
|
||||||
|
// - options: Login options including browser behavior settings
|
||||||
|
func DoCodeBuddyLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
record, savedPath, err := manager.Login(context.Background(), "codebuddy", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("CodeBuddy authentication failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
if record != nil && record.Label != "" {
|
||||||
|
fmt.Printf("Authenticated as %s\n", record.Label)
|
||||||
|
}
|
||||||
|
fmt.Println("CodeBuddy authentication successful!")
|
||||||
|
}
|
||||||
37
internal/cmd/cursor_login.go
Normal file
37
internal/cmd/cursor_login.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoCursorLogin triggers the OAuth PKCE flow for Cursor and saves tokens.
|
||||||
|
func DoCursorLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
Prompt: options.Prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
record, savedPath, err := manager.Login(context.Background(), "cursor", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Cursor authentication failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
log.Infof("Authentication saved to %s", savedPath)
|
||||||
|
}
|
||||||
|
if record != nil && record.Label != "" {
|
||||||
|
log.Infof("Authenticated as %s", record.Label)
|
||||||
|
}
|
||||||
|
log.Info("Cursor authentication successful!")
|
||||||
|
}
|
||||||
69
internal/cmd/gitlab_login.go
Normal file
69
internal/cmd/gitlab_login.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DoGitLabLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFn := options.Prompt
|
||||||
|
if promptFn == nil {
|
||||||
|
promptFn = defaultProjectPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
CallbackPort: options.CallbackPort,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"login_mode": "oauth",
|
||||||
|
},
|
||||||
|
Prompt: promptFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, savedPath, err := manager.Login(context.Background(), "gitlab", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("GitLab Duo authentication failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
fmt.Println("GitLab Duo authentication successful!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func DoGitLabTokenLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFn := options.Prompt
|
||||||
|
if promptFn == nil {
|
||||||
|
promptFn = defaultProjectPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"login_mode": "pat",
|
||||||
|
},
|
||||||
|
Prompt: promptFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, savedPath, err := manager.Login(context.Background(), "gitlab", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("GitLab Duo PAT authentication failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
fmt.Println("GitLab Duo PAT authentication successful!")
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -29,9 +30,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||||
geminiCLIVersion = "v1internal"
|
geminiCLIVersion = "v1internal"
|
||||||
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
|
|
||||||
geminiCLIApiClient = "gl-node/22.17.0"
|
|
||||||
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type projectSelectionRequiredError struct{}
|
type projectSelectionRequiredError struct{}
|
||||||
@@ -409,9 +407,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
|
|||||||
return fmt.Errorf("create request: %w", errRequest)
|
return fmt.Errorf("create request: %w", errRequest)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
req.Header.Set("User-Agent", misc.GeminiCLIUserAgent(""))
|
||||||
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
|
||||||
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
|
||||||
|
|
||||||
resp, errDo := httpClient.Do(req)
|
resp, errDo := httpClient.Do(req)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
@@ -630,7 +626,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
|
|||||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
req.Header.Set("User-Agent", misc.GeminiCLIUserAgent(""))
|
||||||
resp, errDo := httpClient.Do(req)
|
resp, errDo := httpClient.Do(req)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||||
@@ -651,7 +647,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
|
|||||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
req.Header.Set("User-Agent", misc.GeminiCLIUserAgent(""))
|
||||||
resp, errDo = httpClient.Do(req)
|
resp, errDo = httpClient.Do(req)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||||
|
|||||||
55
internal/config/claude_header_defaults_test.go
Normal file
55
internal/config/claude_header_defaults_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigOptional_ClaudeHeaderDefaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yaml")
|
||||||
|
configYAML := []byte(`
|
||||||
|
claude-header-defaults:
|
||||||
|
user-agent: " claude-cli/2.1.70 (external, cli) "
|
||||||
|
package-version: " 0.80.0 "
|
||||||
|
runtime-version: " v24.5.0 "
|
||||||
|
os: " MacOS "
|
||||||
|
arch: " arm64 "
|
||||||
|
timeout: " 900 "
|
||||||
|
stabilize-device-profile: false
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfigOptional(configPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfigOptional() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.UserAgent; got != "claude-cli/2.1.70 (external, cli)" {
|
||||||
|
t.Fatalf("UserAgent = %q, want %q", got, "claude-cli/2.1.70 (external, cli)")
|
||||||
|
}
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.PackageVersion; got != "0.80.0" {
|
||||||
|
t.Fatalf("PackageVersion = %q, want %q", got, "0.80.0")
|
||||||
|
}
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.RuntimeVersion; got != "v24.5.0" {
|
||||||
|
t.Fatalf("RuntimeVersion = %q, want %q", got, "v24.5.0")
|
||||||
|
}
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.OS; got != "MacOS" {
|
||||||
|
t.Fatalf("OS = %q, want %q", got, "MacOS")
|
||||||
|
}
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.Arch; got != "arm64" {
|
||||||
|
t.Fatalf("Arch = %q, want %q", got, "arm64")
|
||||||
|
}
|
||||||
|
if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" {
|
||||||
|
t.Fatalf("Timeout = %q, want %q", got, "900")
|
||||||
|
}
|
||||||
|
if cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil {
|
||||||
|
t.Fatal("StabilizeDeviceProfile = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if got := *cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile; got {
|
||||||
|
t.Fatalf("StabilizeDeviceProfile = %v, want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/config/codex_websocket_header_defaults_test.go
Normal file
32
internal/config/codex_websocket_header_defaults_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yaml")
|
||||||
|
configYAML := []byte(`
|
||||||
|
codex-header-defaults:
|
||||||
|
user-agent: " my-codex-client/1.0 "
|
||||||
|
beta-features: " feature-a,feature-b "
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfigOptional(configPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfigOptional() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cfg.CodexHeaderDefaults.UserAgent; got != "my-codex-client/1.0" {
|
||||||
|
t.Fatalf("UserAgent = %q, want %q", got, "my-codex-client/1.0")
|
||||||
|
}
|
||||||
|
if got := cfg.CodexHeaderDefaults.BetaFeatures; got != "feature-a,feature-b" {
|
||||||
|
t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -69,6 +70,9 @@ type Config struct {
|
|||||||
|
|
||||||
// RequestRetry defines the retry times when the request failed.
|
// RequestRetry defines the retry times when the request failed.
|
||||||
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
||||||
|
// MaxRetryCredentials defines the maximum number of credentials to try for a failed request.
|
||||||
|
// Set to 0 or a negative value to keep trying all available credentials (legacy behavior).
|
||||||
|
MaxRetryCredentials int `yaml:"max-retry-credentials" json:"max-retry-credentials"`
|
||||||
// MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential.
|
// MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential.
|
||||||
MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"`
|
MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"`
|
||||||
|
|
||||||
@@ -98,6 +102,10 @@ type Config struct {
|
|||||||
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
||||||
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
||||||
|
|
||||||
|
// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.
|
||||||
|
// These are used only when the client does not send its own headers.
|
||||||
|
CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"`
|
||||||
|
|
||||||
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
|
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
|
||||||
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
||||||
|
|
||||||
@@ -138,13 +146,27 @@ type Config struct {
|
|||||||
legacyMigrationPending bool `yaml:"-" json:"-"`
|
legacyMigrationPending bool `yaml:"-" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaudeHeaderDefaults configures default header values injected into Claude API requests
|
// ClaudeHeaderDefaults configures default header values injected into Claude API requests.
|
||||||
// when the client does not send them. Update these when Claude Code releases a new version.
|
// In legacy mode, UserAgent/PackageVersion/RuntimeVersion/Timeout act as fallbacks when
|
||||||
|
// the client omits them, while OS/Arch remain runtime-derived. When stabilized device
|
||||||
|
// profiles are enabled, OS/Arch become the pinned platform baseline, while
|
||||||
|
// UserAgent/PackageVersion/RuntimeVersion seed the upgradeable software fingerprint.
|
||||||
type ClaudeHeaderDefaults struct {
|
type ClaudeHeaderDefaults struct {
|
||||||
UserAgent string `yaml:"user-agent" json:"user-agent"`
|
UserAgent string `yaml:"user-agent" json:"user-agent"`
|
||||||
PackageVersion string `yaml:"package-version" json:"package-version"`
|
PackageVersion string `yaml:"package-version" json:"package-version"`
|
||||||
RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
|
RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
|
||||||
|
OS string `yaml:"os" json:"os"`
|
||||||
|
Arch string `yaml:"arch" json:"arch"`
|
||||||
Timeout string `yaml:"timeout" json:"timeout"`
|
Timeout string `yaml:"timeout" json:"timeout"`
|
||||||
|
StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexHeaderDefaults configures fallback header values injected into Codex
|
||||||
|
// model requests for OAuth/file-backed auth when the client omits them.
|
||||||
|
// UserAgent applies to HTTP and websocket requests; BetaFeatures only applies to websockets.
|
||||||
|
type CodexHeaderDefaults struct {
|
||||||
|
UserAgent string `yaml:"user-agent" json:"user-agent"`
|
||||||
|
BetaFeatures string `yaml:"beta-features" json:"beta-features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSConfig holds HTTPS server settings.
|
// TLSConfig holds HTTPS server settings.
|
||||||
@@ -173,6 +195,9 @@ type RemoteManagement struct {
|
|||||||
SecretKey string `yaml:"secret-key"`
|
SecretKey string `yaml:"secret-key"`
|
||||||
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
||||||
DisableControlPanel bool `yaml:"disable-control-panel"`
|
DisableControlPanel bool `yaml:"disable-control-panel"`
|
||||||
|
// DisableAutoUpdatePanel disables automatic periodic background updates of the management panel asset from GitHub.
|
||||||
|
// When false (the default), the background updater remains enabled; when true, the panel is only downloaded on first access if missing.
|
||||||
|
DisableAutoUpdatePanel bool `yaml:"disable-auto-update-panel"`
|
||||||
// PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset.
|
// PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset.
|
||||||
// Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint.
|
// Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint.
|
||||||
PanelGitHubRepository string `yaml:"panel-github-repository"`
|
PanelGitHubRepository string `yaml:"panel-github-repository"`
|
||||||
@@ -553,6 +578,10 @@ type OpenAICompatibilityModel struct {
|
|||||||
|
|
||||||
// Alias is the model name alias that clients will use to reference this model.
|
// Alias is the model name alias that clients will use to reference this model.
|
||||||
Alias string `yaml:"alias" json:"alias"`
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
|
|
||||||
|
// Thinking configures the thinking/reasoning capability for this model.
|
||||||
|
// If nil, the model defaults to level-based reasoning with levels ["low", "medium", "high"].
|
||||||
|
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
|
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
|
||||||
@@ -576,16 +605,6 @@ func LoadConfig(configFile string) (*Config, error) {
|
|||||||
// If optional is true and the file is missing, it returns an empty Config.
|
// If optional is true and the file is missing, it returns an empty Config.
|
||||||
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
||||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||||
// NOTE: Startup oauth-model-alias migration is intentionally disabled.
|
|
||||||
// Reason: avoid mutating config.yaml during server startup.
|
|
||||||
// Re-enable the block below if automatic startup migration is needed again.
|
|
||||||
// if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
|
|
||||||
// // Log warning but don't fail - config loading should still work
|
|
||||||
// fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
|
|
||||||
// } else if migrated {
|
|
||||||
// fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Read the entire configuration file into memory.
|
// Read the entire configuration file into memory.
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -673,15 +692,25 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.ErrorLogsMaxFiles = 10
|
cfg.ErrorLogsMaxFiles = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.MaxRetryCredentials < 0 {
|
||||||
|
cfg.MaxRetryCredentials = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize Gemini API key configuration and migrate legacy entries.
|
// Sanitize Gemini API key configuration and migrate legacy entries.
|
||||||
cfg.SanitizeGeminiKeys()
|
cfg.SanitizeGeminiKeys()
|
||||||
|
|
||||||
// Sanitize Vertex-compatible API keys: drop entries without base-url
|
// Sanitize Vertex-compatible API keys.
|
||||||
cfg.SanitizeVertexCompatKeys()
|
cfg.SanitizeVertexCompatKeys()
|
||||||
|
|
||||||
// Sanitize Codex keys: drop entries without base-url
|
// Sanitize Codex keys: drop entries without base-url
|
||||||
cfg.SanitizeCodexKeys()
|
cfg.SanitizeCodexKeys()
|
||||||
|
|
||||||
|
// Sanitize Codex header defaults.
|
||||||
|
cfg.SanitizeCodexHeaderDefaults()
|
||||||
|
|
||||||
|
// Sanitize Claude header defaults.
|
||||||
|
cfg.SanitizeClaudeHeaderDefaults()
|
||||||
|
|
||||||
// Sanitize Claude key headers
|
// Sanitize Claude key headers
|
||||||
cfg.SanitizeClaudeKeys()
|
cfg.SanitizeClaudeKeys()
|
||||||
|
|
||||||
@@ -774,6 +803,30 @@ func payloadRawString(value any) ([]byte, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SanitizeCodexHeaderDefaults trims surrounding whitespace from the
|
||||||
|
// configured Codex header fallback values.
|
||||||
|
func (cfg *Config) SanitizeCodexHeaderDefaults() {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.CodexHeaderDefaults.UserAgent = strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent)
|
||||||
|
cfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeClaudeHeaderDefaults trims surrounding whitespace from the
|
||||||
|
// configured Claude fingerprint baseline values.
|
||||||
|
func (cfg *Config) SanitizeClaudeHeaderDefaults() {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent)
|
||||||
|
cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion)
|
||||||
|
cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion)
|
||||||
|
cfg.ClaudeHeaderDefaults.OS = strings.TrimSpace(cfg.ClaudeHeaderDefaults.OS)
|
||||||
|
cfg.ClaudeHeaderDefaults.Arch = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Arch)
|
||||||
|
cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
|
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
|
||||||
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
|
||||||
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
|
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
|
||||||
@@ -1669,9 +1722,6 @@ func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {
|
|||||||
srcIdx := findMapKeyIndex(srcRoot, key)
|
srcIdx := findMapKeyIndex(srcRoot, key)
|
||||||
if srcIdx < 0 {
|
if srcIdx < 0 {
|
||||||
// Keep an explicit empty mapping for oauth-model-alias when it was previously present.
|
// Keep an explicit empty mapping for oauth-model-alias when it was previously present.
|
||||||
//
|
|
||||||
// Rationale: LoadConfig runs MigrateOAuthModelAlias before unmarshalling. If the
|
|
||||||
// oauth-model-alias key is missing, migration will add the default antigravity aliases.
|
|
||||||
// When users delete the last channel from oauth-model-alias via the management API,
|
// When users delete the last channel from oauth-model-alias via the management API,
|
||||||
// we want that deletion to persist across hot reloads and restarts.
|
// we want that deletion to persist across hot reloads and restarts.
|
||||||
if key == "oauth-model-alias" {
|
if key == "oauth-model-alias" {
|
||||||
|
|||||||
61
internal/config/oauth_model_alias_defaults.go
Normal file
61
internal/config/oauth_model_alias_defaults.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// defaultKiroAliases returns default oauth-model-alias entries for Kiro.
|
||||||
|
// These aliases expose standard Claude IDs for Kiro-prefixed upstream models.
|
||||||
|
func defaultKiroAliases() []OAuthModelAlias {
|
||||||
|
return []OAuthModelAlias{
|
||||||
|
// Sonnet 4.6
|
||||||
|
{Name: "kiro-claude-sonnet-4-6", Alias: "claude-sonnet-4-6", Fork: true},
|
||||||
|
// Sonnet 4.5
|
||||||
|
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5-20250929", Fork: true},
|
||||||
|
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5", Fork: true},
|
||||||
|
// Sonnet 4
|
||||||
|
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4-20250514", Fork: true},
|
||||||
|
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4", Fork: true},
|
||||||
|
// Opus 4.6
|
||||||
|
{Name: "kiro-claude-opus-4-6", Alias: "claude-opus-4-6", Fork: true},
|
||||||
|
// Opus 4.5
|
||||||
|
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5-20251101", Fork: true},
|
||||||
|
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5", Fork: true},
|
||||||
|
// Haiku 4.5
|
||||||
|
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5-20251001", Fork: true},
|
||||||
|
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5", Fork: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultGitHubCopilotAliases returns default oauth-model-alias entries for
|
||||||
|
// GitHub Copilot Claude models. It exposes hyphen-style IDs used by clients.
|
||||||
|
func defaultGitHubCopilotAliases() []OAuthModelAlias {
|
||||||
|
return []OAuthModelAlias{
|
||||||
|
{Name: "claude-haiku-4.5", Alias: "claude-haiku-4-5", Fork: true},
|
||||||
|
{Name: "claude-opus-4.1", Alias: "claude-opus-4-1", Fork: true},
|
||||||
|
{Name: "claude-opus-4.5", Alias: "claude-opus-4-5", Fork: true},
|
||||||
|
{Name: "claude-opus-4.6", Alias: "claude-opus-4-6", Fork: true},
|
||||||
|
{Name: "claude-sonnet-4.5", Alias: "claude-sonnet-4-5", Fork: true},
|
||||||
|
{Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHubCopilotAliasesFromModels generates oauth-model-alias entries from a dynamic
|
||||||
|
// list of model IDs fetched from the Copilot API. It auto-creates aliases for
|
||||||
|
// models whose ID contains a dot (e.g. "claude-opus-4.6" → "claude-opus-4-6"),
|
||||||
|
// which is the pattern used by Claude models on Copilot.
|
||||||
|
func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias {
|
||||||
|
var aliases []OAuthModelAlias
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, id := range modelIDs {
|
||||||
|
if !strings.Contains(id, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hyphenID := strings.ReplaceAll(id, ".", "-")
|
||||||
|
key := id + "→" + hyphenID
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
aliases = append(aliases, OAuthModelAlias{Name: id, Alias: hyphenID, Fork: true})
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// antigravityModelConversionTable maps old built-in aliases to actual model names
|
|
||||||
// for the antigravity channel during migration.
|
|
||||||
var antigravityModelConversionTable = map[string]string{
|
|
||||||
"gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p",
|
|
||||||
"gemini-3-pro-image-preview": "gemini-3-pro-image",
|
|
||||||
"gemini-3-pro-preview": "gemini-3-pro-high",
|
|
||||||
"gemini-3-flash-preview": "gemini-3-flash",
|
|
||||||
"gemini-claude-sonnet-4-5": "claude-sonnet-4-5",
|
|
||||||
"gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
|
||||||
"gemini-claude-opus-4-5-thinking": "claude-opus-4-5-thinking",
|
|
||||||
"gemini-claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultKiroAliases returns the default oauth-model-alias configuration
|
|
||||||
// for the kiro channel. Maps kiro-prefixed model names to standard Claude model
|
|
||||||
// names so that clients like Claude Code can use standard names directly.
|
|
||||||
func defaultKiroAliases() []OAuthModelAlias {
|
|
||||||
return []OAuthModelAlias{
|
|
||||||
// Sonnet 4.6
|
|
||||||
{Name: "kiro-claude-sonnet-4-6", Alias: "claude-sonnet-4-6", Fork: true},
|
|
||||||
// Sonnet 4.5
|
|
||||||
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5-20250929", Fork: true},
|
|
||||||
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5", Fork: true},
|
|
||||||
// Sonnet 4
|
|
||||||
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4-20250514", Fork: true},
|
|
||||||
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4", Fork: true},
|
|
||||||
// Opus 4.6
|
|
||||||
{Name: "kiro-claude-opus-4-6", Alias: "claude-opus-4-6", Fork: true},
|
|
||||||
// Opus 4.5
|
|
||||||
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5-20251101", Fork: true},
|
|
||||||
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5", Fork: true},
|
|
||||||
// Haiku 4.5
|
|
||||||
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5-20251001", Fork: true},
|
|
||||||
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5", Fork: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultGitHubCopilotAliases returns default oauth-model-alias entries that
|
|
||||||
// expose Claude hyphen-style IDs for GitHub Copilot Claude models.
|
|
||||||
// This keeps compatibility with clients (e.g. Claude Code) that use
|
|
||||||
// Anthropic-style model IDs like "claude-opus-4-6".
|
|
||||||
func defaultGitHubCopilotAliases() []OAuthModelAlias {
|
|
||||||
return []OAuthModelAlias{
|
|
||||||
{Name: "claude-haiku-4.5", Alias: "claude-haiku-4-5", Fork: true},
|
|
||||||
{Name: "claude-opus-4.1", Alias: "claude-opus-4-1", Fork: true},
|
|
||||||
{Name: "claude-opus-4.5", Alias: "claude-opus-4-5", Fork: true},
|
|
||||||
{Name: "claude-opus-4.6", Alias: "claude-opus-4-6", Fork: true},
|
|
||||||
{Name: "claude-sonnet-4.5", Alias: "claude-sonnet-4-5", Fork: true},
|
|
||||||
{Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultAntigravityAliases returns the default oauth-model-alias configuration
|
|
||||||
// for the antigravity channel when neither field exists.
|
|
||||||
func defaultAntigravityAliases() []OAuthModelAlias {
|
|
||||||
return []OAuthModelAlias{
|
|
||||||
{Name: "rev19-uic3-1p", Alias: "gemini-2.5-computer-use-preview-10-2025"},
|
|
||||||
{Name: "gemini-3-pro-image", Alias: "gemini-3-pro-image-preview"},
|
|
||||||
{Name: "gemini-3-pro-high", Alias: "gemini-3-pro-preview"},
|
|
||||||
{Name: "gemini-3-flash", Alias: "gemini-3-flash-preview"},
|
|
||||||
{Name: "claude-sonnet-4-5", Alias: "gemini-claude-sonnet-4-5"},
|
|
||||||
{Name: "claude-sonnet-4-5-thinking", Alias: "gemini-claude-sonnet-4-5-thinking"},
|
|
||||||
{Name: "claude-opus-4-5-thinking", Alias: "gemini-claude-opus-4-5-thinking"},
|
|
||||||
{Name: "claude-opus-4-6-thinking", Alias: "gemini-claude-opus-4-6-thinking"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateOAuthModelAlias checks for and performs migration from oauth-model-mappings
|
|
||||||
// to oauth-model-alias at startup. Returns true if migration was performed.
|
|
||||||
//
|
|
||||||
// Migration flow:
|
|
||||||
// 1. Check if oauth-model-alias exists -> skip migration
|
|
||||||
// 2. Check if oauth-model-mappings exists -> convert and migrate
|
|
||||||
// - For antigravity channel, convert old built-in aliases to actual model names
|
|
||||||
//
|
|
||||||
// 3. Neither exists -> add default antigravity config
|
|
||||||
func MigrateOAuthModelAlias(configFile string) (bool, error) {
|
|
||||||
data, err := os.ReadFile(configFile)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse YAML into node tree to preserve structure
|
|
||||||
var root yaml.Node
|
|
||||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootMap := root.Content[0]
|
|
||||||
if rootMap == nil || rootMap.Kind != yaml.MappingNode {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if oauth-model-alias already exists
|
|
||||||
if findMapKeyIndex(rootMap, "oauth-model-alias") >= 0 {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if oauth-model-mappings exists
|
|
||||||
oldIdx := findMapKeyIndex(rootMap, "oauth-model-mappings")
|
|
||||||
if oldIdx >= 0 {
|
|
||||||
// Migrate from old field
|
|
||||||
return migrateFromOldField(configFile, &root, rootMap, oldIdx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neither field exists - add default antigravity config
|
|
||||||
return addDefaultAntigravityConfig(configFile, &root, rootMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateFromOldField converts oauth-model-mappings to oauth-model-alias
|
|
||||||
func migrateFromOldField(configFile string, root *yaml.Node, rootMap *yaml.Node, oldIdx int) (bool, error) {
|
|
||||||
if oldIdx+1 >= len(rootMap.Content) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
oldValue := rootMap.Content[oldIdx+1]
|
|
||||||
if oldValue == nil || oldValue.Kind != yaml.MappingNode {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the old aliases
|
|
||||||
oldAliases := parseOldAliasNode(oldValue)
|
|
||||||
if len(oldAliases) == 0 {
|
|
||||||
// Remove the old field and write
|
|
||||||
removeMapKeyByIndex(rootMap, oldIdx)
|
|
||||||
return writeYAMLNode(configFile, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert model names for antigravity channel
|
|
||||||
newAliases := make(map[string][]OAuthModelAlias, len(oldAliases))
|
|
||||||
for channel, entries := range oldAliases {
|
|
||||||
converted := make([]OAuthModelAlias, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
newEntry := OAuthModelAlias{
|
|
||||||
Name: entry.Name,
|
|
||||||
Alias: entry.Alias,
|
|
||||||
Fork: entry.Fork,
|
|
||||||
}
|
|
||||||
// Convert model names for antigravity channel
|
|
||||||
if strings.EqualFold(channel, "antigravity") {
|
|
||||||
if actual, ok := antigravityModelConversionTable[entry.Name]; ok {
|
|
||||||
newEntry.Name = actual
|
|
||||||
}
|
|
||||||
}
|
|
||||||
converted = append(converted, newEntry)
|
|
||||||
}
|
|
||||||
newAliases[channel] = converted
|
|
||||||
}
|
|
||||||
|
|
||||||
// For antigravity channel, supplement missing default aliases
|
|
||||||
if antigravityEntries, exists := newAliases["antigravity"]; exists {
|
|
||||||
// Build a set of already configured model names (upstream names)
|
|
||||||
configuredModels := make(map[string]bool, len(antigravityEntries))
|
|
||||||
for _, entry := range antigravityEntries {
|
|
||||||
configuredModels[entry.Name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing default aliases
|
|
||||||
for _, defaultAlias := range defaultAntigravityAliases() {
|
|
||||||
if !configuredModels[defaultAlias.Name] {
|
|
||||||
antigravityEntries = append(antigravityEntries, defaultAlias)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newAliases["antigravity"] = antigravityEntries
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build new node
|
|
||||||
newNode := buildOAuthModelAliasNode(newAliases)
|
|
||||||
|
|
||||||
// Replace old key with new key and value
|
|
||||||
rootMap.Content[oldIdx].Value = "oauth-model-alias"
|
|
||||||
rootMap.Content[oldIdx+1] = newNode
|
|
||||||
|
|
||||||
return writeYAMLNode(configFile, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addDefaultAntigravityConfig adds the default antigravity configuration
|
|
||||||
func addDefaultAntigravityConfig(configFile string, root *yaml.Node, rootMap *yaml.Node) (bool, error) {
|
|
||||||
defaults := map[string][]OAuthModelAlias{
|
|
||||||
"antigravity": defaultAntigravityAliases(),
|
|
||||||
}
|
|
||||||
newNode := buildOAuthModelAliasNode(defaults)
|
|
||||||
|
|
||||||
// Add new key-value pair
|
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "oauth-model-alias"}
|
|
||||||
rootMap.Content = append(rootMap.Content, keyNode, newNode)
|
|
||||||
|
|
||||||
return writeYAMLNode(configFile, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseOldAliasNode parses the old oauth-model-mappings node structure
|
|
||||||
func parseOldAliasNode(node *yaml.Node) map[string][]OAuthModelAlias {
|
|
||||||
if node == nil || node.Kind != yaml.MappingNode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make(map[string][]OAuthModelAlias)
|
|
||||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
||||||
channelNode := node.Content[i]
|
|
||||||
entriesNode := node.Content[i+1]
|
|
||||||
if channelNode == nil || entriesNode == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
channel := strings.ToLower(strings.TrimSpace(channelNode.Value))
|
|
||||||
if channel == "" || entriesNode.Kind != yaml.SequenceNode {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries := make([]OAuthModelAlias, 0, len(entriesNode.Content))
|
|
||||||
for _, entryNode := range entriesNode.Content {
|
|
||||||
if entryNode == nil || entryNode.Kind != yaml.MappingNode {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry := parseAliasEntry(entryNode)
|
|
||||||
if entry.Name != "" && entry.Alias != "" {
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(entries) > 0 {
|
|
||||||
result[channel] = entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAliasEntry parses a single alias entry node
|
|
||||||
func parseAliasEntry(node *yaml.Node) OAuthModelAlias {
|
|
||||||
var entry OAuthModelAlias
|
|
||||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
||||||
keyNode := node.Content[i]
|
|
||||||
valNode := node.Content[i+1]
|
|
||||||
if keyNode == nil || valNode == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.TrimSpace(keyNode.Value)) {
|
|
||||||
case "name":
|
|
||||||
entry.Name = strings.TrimSpace(valNode.Value)
|
|
||||||
case "alias":
|
|
||||||
entry.Alias = strings.TrimSpace(valNode.Value)
|
|
||||||
case "fork":
|
|
||||||
entry.Fork = strings.ToLower(strings.TrimSpace(valNode.Value)) == "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildOAuthModelAliasNode creates a YAML node for oauth-model-alias
|
|
||||||
func buildOAuthModelAliasNode(aliases map[string][]OAuthModelAlias) *yaml.Node {
|
|
||||||
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
for channel, entries := range aliases {
|
|
||||||
channelNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: channel}
|
|
||||||
entriesNode := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
||||||
for _, entry := range entries {
|
|
||||||
entryNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
entryNode.Content = append(entryNode.Content,
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "name"},
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Name},
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "alias"},
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Alias},
|
|
||||||
)
|
|
||||||
if entry.Fork {
|
|
||||||
entryNode.Content = append(entryNode.Content,
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "fork"},
|
|
||||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entriesNode.Content = append(entriesNode.Content, entryNode)
|
|
||||||
}
|
|
||||||
node.Content = append(node.Content, channelNode, entriesNode)
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeMapKeyByIndex removes a key-value pair from a mapping node by index
|
|
||||||
func removeMapKeyByIndex(mapNode *yaml.Node, keyIdx int) {
|
|
||||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if keyIdx < 0 || keyIdx+1 >= len(mapNode.Content) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mapNode.Content = append(mapNode.Content[:keyIdx], mapNode.Content[keyIdx+2:]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeYAMLNode writes the YAML node tree back to file
|
|
||||||
func writeYAMLNode(configFile string, root *yaml.Node) (bool, error) {
|
|
||||||
f, err := os.Create(configFile)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
enc := yaml.NewEncoder(f)
|
|
||||||
enc.SetIndent(2)
|
|
||||||
if err := enc.Encode(root); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := enc.Close(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_SkipsIfNewFieldExists(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
content := `oauth-model-alias:
|
|
||||||
gemini-cli:
|
|
||||||
- name: "gemini-2.5-pro"
|
|
||||||
alias: "g2.5p"
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if migrated {
|
|
||||||
t.Fatal("expected no migration when oauth-model-alias already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file unchanged
|
|
||||||
data, _ := os.ReadFile(configFile)
|
|
||||||
if !strings.Contains(string(data), "oauth-model-alias:") {
|
|
||||||
t.Fatal("file should still contain oauth-model-alias")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_MigratesOldField(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
content := `oauth-model-mappings:
|
|
||||||
gemini-cli:
|
|
||||||
- name: "gemini-2.5-pro"
|
|
||||||
alias: "g2.5p"
|
|
||||||
fork: true
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !migrated {
|
|
||||||
t.Fatal("expected migration to occur")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify new field exists and old field removed
|
|
||||||
data, _ := os.ReadFile(configFile)
|
|
||||||
if strings.Contains(string(data), "oauth-model-mappings:") {
|
|
||||||
t.Fatal("old field should be removed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), "oauth-model-alias:") {
|
|
||||||
t.Fatal("new field should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and verify structure
|
|
||||||
var root yaml.Node
|
|
||||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_ConvertsAntigravityModels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
// Use old model names that should be converted
|
|
||||||
content := `oauth-model-mappings:
|
|
||||||
antigravity:
|
|
||||||
- name: "gemini-2.5-computer-use-preview-10-2025"
|
|
||||||
alias: "computer-use"
|
|
||||||
- name: "gemini-3-pro-preview"
|
|
||||||
alias: "g3p"
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !migrated {
|
|
||||||
t.Fatal("expected migration to occur")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify model names were converted
|
|
||||||
data, _ := os.ReadFile(configFile)
|
|
||||||
content = string(data)
|
|
||||||
if !strings.Contains(content, "rev19-uic3-1p") {
|
|
||||||
t.Fatal("expected gemini-2.5-computer-use-preview-10-2025 to be converted to rev19-uic3-1p")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "gemini-3-pro-high") {
|
|
||||||
t.Fatal("expected gemini-3-pro-preview to be converted to gemini-3-pro-high")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify missing default aliases were supplemented
|
|
||||||
if !strings.Contains(content, "gemini-3-pro-image") {
|
|
||||||
t.Fatal("expected missing default alias gemini-3-pro-image to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "gemini-3-flash") {
|
|
||||||
t.Fatal("expected missing default alias gemini-3-flash to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "claude-sonnet-4-5") {
|
|
||||||
t.Fatal("expected missing default alias claude-sonnet-4-5 to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "claude-sonnet-4-5-thinking") {
|
|
||||||
t.Fatal("expected missing default alias claude-sonnet-4-5-thinking to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "claude-opus-4-5-thinking") {
|
|
||||||
t.Fatal("expected missing default alias claude-opus-4-5-thinking to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "claude-opus-4-6-thinking") {
|
|
||||||
t.Fatal("expected missing default alias claude-opus-4-6-thinking to be added")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_AddsDefaultIfNeitherExists(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
content := `debug: true
|
|
||||||
port: 8080
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !migrated {
|
|
||||||
t.Fatal("expected migration to add default config")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify default antigravity config was added
|
|
||||||
data, _ := os.ReadFile(configFile)
|
|
||||||
content = string(data)
|
|
||||||
if !strings.Contains(content, "oauth-model-alias:") {
|
|
||||||
t.Fatal("expected oauth-model-alias to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "antigravity:") {
|
|
||||||
t.Fatal("expected antigravity channel to be added")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "rev19-uic3-1p") {
|
|
||||||
t.Fatal("expected default antigravity aliases to include rev19-uic3-1p")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_PreservesOtherConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
content := `debug: true
|
|
||||||
port: 8080
|
|
||||||
oauth-model-mappings:
|
|
||||||
gemini-cli:
|
|
||||||
- name: "test"
|
|
||||||
alias: "t"
|
|
||||||
api-keys:
|
|
||||||
- "key1"
|
|
||||||
- "key2"
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !migrated {
|
|
||||||
t.Fatal("expected migration to occur")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify other config preserved
|
|
||||||
data, _ := os.ReadFile(configFile)
|
|
||||||
content = string(data)
|
|
||||||
if !strings.Contains(content, "debug: true") {
|
|
||||||
t.Fatal("expected debug field to be preserved")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "port: 8080") {
|
|
||||||
t.Fatal("expected port field to be preserved")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "api-keys:") {
|
|
||||||
t.Fatal("expected api-keys field to be preserved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_NonexistentFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias("/nonexistent/path/config.yaml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error for nonexistent file: %v", err)
|
|
||||||
}
|
|
||||||
if migrated {
|
|
||||||
t.Fatal("expected no migration for nonexistent file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateOAuthModelAlias_EmptyFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
|
|
||||||
if err := os.WriteFile(configFile, []byte(""), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated, err := MigrateOAuthModelAlias(configFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if migrated {
|
|
||||||
t.Fatal("expected no migration for empty file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,9 +20,9 @@ type VertexCompatKey struct {
|
|||||||
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
|
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
// BaseURL is the base URL for the Vertex-compatible API endpoint.
|
// BaseURL optionally overrides the Vertex-compatible API endpoint.
|
||||||
// The executor will append "/v1/publishers/google/models/{model}:action" to this.
|
// The executor will append "/v1/publishers/google/models/{model}:action" to this.
|
||||||
// Example: "https://zenmux.ai/api" becomes "https://zenmux.ai/api/v1/publishers/google/models/..."
|
// When empty, requests fall back to the default Vertex API base URL.
|
||||||
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
|
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
|
||||||
|
|
||||||
// ProxyURL optionally overrides the global proxy for this API key.
|
// ProxyURL optionally overrides the global proxy for this API key.
|
||||||
@@ -34,6 +34,9 @@ type VertexCompatKey struct {
|
|||||||
|
|
||||||
// Models defines the model configurations including aliases for routing.
|
// Models defines the model configurations including aliases for routing.
|
||||||
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
|
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
|
||||||
|
|
||||||
|
// ExcludedModels lists model IDs that should be excluded for this provider.
|
||||||
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k VertexCompatKey) GetAPIKey() string { return k.APIKey }
|
func (k VertexCompatKey) GetAPIKey() string { return k.APIKey }
|
||||||
@@ -68,12 +71,9 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
|
|||||||
}
|
}
|
||||||
entry.Prefix = normalizeModelPrefix(entry.Prefix)
|
entry.Prefix = normalizeModelPrefix(entry.Prefix)
|
||||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||||
if entry.BaseURL == "" {
|
|
||||||
// BaseURL is required for Vertex API key entries
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
|
|
||||||
// Sanitize models: remove entries without valid alias
|
// Sanitize models: remove entries without valid alias
|
||||||
sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))
|
sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||||
managementSyncMinInterval = 30 * time.Second
|
managementSyncMinInterval = 30 * time.Second
|
||||||
updateCheckInterval = 3 * time.Hour
|
updateCheckInterval = 3 * time.Hour
|
||||||
|
maxAssetDownloadSize = 50 << 20 // 10 MB safety limit for management asset downloads
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManagementFileName exposes the control panel asset filename.
|
// ManagementFileName exposes the control panel asset filename.
|
||||||
@@ -88,6 +89,10 @@ func runAutoUpdater(ctx context.Context) {
|
|||||||
log.Debug("management asset auto-updater skipped: control panel disabled")
|
log.Debug("management asset auto-updater skipped: control panel disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.RemoteManagement.DisableAutoUpdatePanel {
|
||||||
|
log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
configPath, _ := schedulerConfigPath.Load().(string)
|
configPath, _ := schedulerConfigPath.Load().(string)
|
||||||
staticDir := StaticDir(configPath)
|
staticDir := StaticDir(configPath)
|
||||||
@@ -259,7 +264,8 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
log.Errorf("management asset digest mismatch: expected %s got %s — aborting update for safety", remoteHash, downloadedHash)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
@@ -282,6 +288,9 @@ func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, loca
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Warnf("management asset downloaded from fallback URL without digest verification (hash=%s) — "+
|
||||||
|
"enable verified GitHub updates by keeping disable-auto-update-panel set to false", downloadedHash)
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
log.WithError(err).Warn("failed to persist fallback management control panel page")
|
log.WithError(err).Warn("failed to persist fallback management control panel page")
|
||||||
return false
|
return false
|
||||||
@@ -392,10 +401,13 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string)
|
|||||||
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("read download body: %w", err)
|
return nil, "", fmt.Errorf("read download body: %w", err)
|
||||||
}
|
}
|
||||||
|
if int64(len(data)) > maxAssetDownloadSize {
|
||||||
|
return nil, "", fmt.Errorf("download exceeds maximum allowed size of %d bytes", maxAssetDownloadSize)
|
||||||
|
}
|
||||||
|
|
||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
return data, hex.EncodeToString(sum[:]), nil
|
return data, hex.EncodeToString(sum[:]), nil
|
||||||
|
|||||||
@@ -4,10 +4,98 @@
|
|||||||
package misc
|
package misc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// GeminiCLIVersion is the version string reported in the User-Agent for upstream requests.
|
||||||
|
GeminiCLIVersion = "0.31.0"
|
||||||
|
|
||||||
|
// GeminiCLIApiClientHeader is the value for the X-Goog-Api-Client header sent to the Gemini CLI upstream.
|
||||||
|
GeminiCLIApiClientHeader = "google-genai-sdk/1.41.0 gl-node/v22.19.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// geminiCLIOS maps Go runtime OS names to the Node.js-style platform strings used by Gemini CLI.
|
||||||
|
func geminiCLIOS() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return "win32"
|
||||||
|
default:
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// geminiCLIArch maps Go runtime architecture names to the Node.js-style arch strings used by Gemini CLI.
|
||||||
|
func geminiCLIArch() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "amd64":
|
||||||
|
return "x64"
|
||||||
|
case "386":
|
||||||
|
return "x86"
|
||||||
|
default:
|
||||||
|
return runtime.GOARCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeminiCLIUserAgent returns a User-Agent string that matches the Gemini CLI format.
|
||||||
|
// The model parameter is included in the UA; pass "" or "unknown" when the model is not applicable.
|
||||||
|
func GeminiCLIUserAgent(model string) string {
|
||||||
|
if model == "" {
|
||||||
|
model = "unknown"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s)", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrubProxyAndFingerprintHeaders removes all headers that could reveal
|
||||||
|
// proxy infrastructure, client identity, or browser fingerprints from an
|
||||||
|
// outgoing request. This ensures requests to upstream services look like they
|
||||||
|
// originate directly from a native client rather than a third-party client
|
||||||
|
// behind a reverse proxy.
|
||||||
|
func ScrubProxyAndFingerprintHeaders(req *http.Request) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Proxy tracing headers ---
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
req.Header.Del("X-Forwarded-Host")
|
||||||
|
req.Header.Del("X-Forwarded-Proto")
|
||||||
|
req.Header.Del("X-Forwarded-Port")
|
||||||
|
req.Header.Del("X-Real-IP")
|
||||||
|
req.Header.Del("Forwarded")
|
||||||
|
req.Header.Del("Via")
|
||||||
|
|
||||||
|
// --- Client identity headers ---
|
||||||
|
req.Header.Del("X-Title")
|
||||||
|
req.Header.Del("X-Stainless-Lang")
|
||||||
|
req.Header.Del("X-Stainless-Package-Version")
|
||||||
|
req.Header.Del("X-Stainless-Os")
|
||||||
|
req.Header.Del("X-Stainless-Arch")
|
||||||
|
req.Header.Del("X-Stainless-Runtime")
|
||||||
|
req.Header.Del("X-Stainless-Runtime-Version")
|
||||||
|
req.Header.Del("Http-Referer")
|
||||||
|
req.Header.Del("Referer")
|
||||||
|
|
||||||
|
// --- Browser / Chromium fingerprint headers ---
|
||||||
|
// These are sent by Electron-based clients (e.g. CherryStudio) using the
|
||||||
|
// Fetch API, but NOT by Node.js https module (which Antigravity uses).
|
||||||
|
req.Header.Del("Sec-Ch-Ua")
|
||||||
|
req.Header.Del("Sec-Ch-Ua-Mobile")
|
||||||
|
req.Header.Del("Sec-Ch-Ua-Platform")
|
||||||
|
req.Header.Del("Sec-Fetch-Mode")
|
||||||
|
req.Header.Del("Sec-Fetch-Site")
|
||||||
|
req.Header.Del("Sec-Fetch-Dest")
|
||||||
|
req.Header.Del("Priority")
|
||||||
|
|
||||||
|
// --- Encoding negotiation ---
|
||||||
|
// Antigravity (Node.js) sends "gzip, deflate, br" by default;
|
||||||
|
// Electron-based clients may add "zstd" which is a fingerprint mismatch.
|
||||||
|
req.Header.Del("Accept-Encoding")
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureHeader ensures that a header exists in the target header map by checking
|
// EnsureHeader ensures that a header exists in the target header map by checking
|
||||||
// multiple sources in order of priority: source headers, existing target headers,
|
// multiple sources in order of priority: source headers, existing target headers,
|
||||||
// and finally the default value. It only sets the header if it's not already present
|
// and finally the default value. It only sets the header if it's not already present
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ type OAuthCallback struct {
|
|||||||
ErrorDescription string
|
ErrorDescription string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AsyncPrompt runs a prompt function in a goroutine and returns channels for
|
||||||
|
// the result. The returned channels are buffered (size 1) so the goroutine can
|
||||||
|
// complete even if the caller abandons the channels.
|
||||||
|
func AsyncPrompt(promptFn func(string) (string, error), message string) (<-chan string, <-chan error) {
|
||||||
|
inputCh := make(chan string, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
input, err := promptFn(message)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputCh <- input
|
||||||
|
}()
|
||||||
|
return inputCh, errCh
|
||||||
|
}
|
||||||
|
|
||||||
// ParseOAuthCallback extracts OAuth parameters from a callback URL.
|
// ParseOAuthCallback extracts OAuth parameters from a callback URL.
|
||||||
// It returns nil when the input is empty.
|
// It returns nil when the input is empty.
|
||||||
func ParseOAuthCallback(input string) (*OAuthCallback, error) {
|
func ParseOAuthCallback(input string) (*OAuthCallback, error) {
|
||||||
|
|||||||
@@ -1,12 +1,186 @@
|
|||||||
// Package registry provides model definitions and lookup helpers for various AI providers.
|
// Package registry provides model definitions and lookup helpers for various AI providers.
|
||||||
// Static model metadata is stored in model_definitions_static_data.go.
|
// Static model metadata is loaded from the embedded models.json file and can be refreshed from network.
|
||||||
package registry
|
package registry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// staticModelsJSON mirrors the top-level structure of models.json.
|
||||||
|
type staticModelsJSON struct {
|
||||||
|
Claude []*ModelInfo `json:"claude"`
|
||||||
|
Gemini []*ModelInfo `json:"gemini"`
|
||||||
|
Vertex []*ModelInfo `json:"vertex"`
|
||||||
|
GeminiCLI []*ModelInfo `json:"gemini-cli"`
|
||||||
|
AIStudio []*ModelInfo `json:"aistudio"`
|
||||||
|
CodexFree []*ModelInfo `json:"codex-free"`
|
||||||
|
CodexTeam []*ModelInfo `json:"codex-team"`
|
||||||
|
CodexPlus []*ModelInfo `json:"codex-plus"`
|
||||||
|
CodexPro []*ModelInfo `json:"codex-pro"`
|
||||||
|
Qwen []*ModelInfo `json:"qwen"`
|
||||||
|
IFlow []*ModelInfo `json:"iflow"`
|
||||||
|
Kimi []*ModelInfo `json:"kimi"`
|
||||||
|
Antigravity []*ModelInfo `json:"antigravity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaudeModels returns the standard Claude model definitions.
|
||||||
|
func GetClaudeModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Claude)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGeminiModels returns the standard Gemini model definitions.
|
||||||
|
func GetGeminiModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Gemini)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGeminiVertexModels returns Gemini model definitions for Vertex AI.
|
||||||
|
func GetGeminiVertexModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Vertex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGeminiCLIModels returns Gemini model definitions for the Gemini CLI.
|
||||||
|
func GetGeminiCLIModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().GeminiCLI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIStudioModels returns model definitions for AI Studio.
|
||||||
|
func GetAIStudioModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().AIStudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodexFreeModels returns model definitions for the Codex free plan tier.
|
||||||
|
func GetCodexFreeModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().CodexFree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodexTeamModels returns model definitions for the Codex team plan tier.
|
||||||
|
func GetCodexTeamModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().CodexTeam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodexPlusModels returns model definitions for the Codex plus plan tier.
|
||||||
|
func GetCodexPlusModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().CodexPlus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodexProModels returns model definitions for the Codex pro plan tier.
|
||||||
|
func GetCodexProModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().CodexPro)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQwenModels returns the standard Qwen model definitions.
|
||||||
|
func GetQwenModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Qwen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIFlowModels returns the standard iFlow model definitions.
|
||||||
|
func GetIFlowModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().IFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions.
|
||||||
|
func GetKimiModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Kimi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAntigravityModels returns the standard Antigravity model definitions.
|
||||||
|
func GetAntigravityModels() []*ModelInfo {
|
||||||
|
return cloneModelInfos(getModels().Antigravity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodeBuddyModels returns the available models for CodeBuddy (Tencent).
|
||||||
|
// These models are served through the copilot.tencent.com API.
|
||||||
|
func GetCodeBuddyModels() []*ModelInfo {
|
||||||
|
now := int64(1748044800) // 2025-05-24
|
||||||
|
return []*ModelInfo{
|
||||||
|
{
|
||||||
|
ID: "glm-5.0",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "GLM-5.0",
|
||||||
|
Description: "GLM-5.0 via CodeBuddy",
|
||||||
|
ContextLength: 128000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "glm-4.7",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "GLM-4.7",
|
||||||
|
Description: "GLM-4.7 via CodeBuddy",
|
||||||
|
ContextLength: 128000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "minimax-m2.5",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "MiniMax M2.5",
|
||||||
|
Description: "MiniMax M2.5 via CodeBuddy",
|
||||||
|
ContextLength: 200000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kimi-k2.5",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "Kimi K2.5",
|
||||||
|
Description: "Kimi K2.5 via CodeBuddy",
|
||||||
|
ContextLength: 128000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "deepseek-v3-2-volc",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "DeepSeek V3.2 (Volc)",
|
||||||
|
Description: "DeepSeek V3.2 via CodeBuddy (Volcano Engine)",
|
||||||
|
ContextLength: 128000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "hunyuan-2.0-thinking",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "tencent",
|
||||||
|
Type: "codebuddy",
|
||||||
|
DisplayName: "Hunyuan 2.0 Thinking",
|
||||||
|
Description: "Tencent Hunyuan 2.0 Thinking via CodeBuddy",
|
||||||
|
ContextLength: 128000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
Thinking: &ThinkingSupport{ZeroAllowed: true},
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneModelInfos returns a shallow copy of the slice with each element deep-cloned.
|
||||||
|
func cloneModelInfos(models []*ModelInfo) []*ModelInfo {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*ModelInfo, len(models))
|
||||||
|
for i, m := range models {
|
||||||
|
out[i] = cloneModelInfo(m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GetStaticModelDefinitionsByChannel returns static model definitions for a given channel/provider.
|
// GetStaticModelDefinitionsByChannel returns static model definitions for a given channel/provider.
|
||||||
// It returns nil when the channel is unknown.
|
// It returns nil when the channel is unknown.
|
||||||
//
|
//
|
||||||
@@ -20,10 +194,8 @@ import (
|
|||||||
// - qwen
|
// - qwen
|
||||||
// - iflow
|
// - iflow
|
||||||
// - kimi
|
// - kimi
|
||||||
// - kiro
|
|
||||||
// - kilo
|
// - kilo
|
||||||
// - github-copilot
|
// - github-copilot
|
||||||
// - kiro
|
|
||||||
// - amazonq
|
// - amazonq
|
||||||
// - antigravity (returns static overrides only)
|
// - antigravity (returns static overrides only)
|
||||||
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
||||||
@@ -40,7 +212,7 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
|||||||
case "aistudio":
|
case "aistudio":
|
||||||
return GetAIStudioModels()
|
return GetAIStudioModels()
|
||||||
case "codex":
|
case "codex":
|
||||||
return GetOpenAIModels()
|
return GetCodexProModels()
|
||||||
case "qwen":
|
case "qwen":
|
||||||
return GetQwenModels()
|
return GetQwenModels()
|
||||||
case "iflow":
|
case "iflow":
|
||||||
@@ -56,33 +228,28 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
|||||||
case "amazonq":
|
case "amazonq":
|
||||||
return GetAmazonQModels()
|
return GetAmazonQModels()
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
cfg := GetAntigravityModelConfig()
|
return GetAntigravityModels()
|
||||||
if len(cfg) == 0 {
|
case "codebuddy":
|
||||||
return nil
|
return GetCodeBuddyModels()
|
||||||
}
|
case "cursor":
|
||||||
models := make([]*ModelInfo, 0, len(cfg))
|
return GetCursorModels()
|
||||||
for modelID, entry := range cfg {
|
|
||||||
if modelID == "" || entry == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
models = append(models, &ModelInfo{
|
|
||||||
ID: modelID,
|
|
||||||
Object: "model",
|
|
||||||
OwnedBy: "antigravity",
|
|
||||||
Type: "antigravity",
|
|
||||||
Thinking: entry.Thinking,
|
|
||||||
MaxCompletionTokens: entry.MaxCompletionTokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(models, func(i, j int) bool {
|
|
||||||
return strings.ToLower(models[i].ID) < strings.ToLower(models[j].ID)
|
|
||||||
})
|
|
||||||
return models
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCursorModels returns the fallback Cursor model definitions.
|
||||||
|
func GetCursorModels() []*ModelInfo {
|
||||||
|
return []*ModelInfo{
|
||||||
|
{ID: "composer-2", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Composer 2", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}},
|
||||||
|
{ID: "claude-4-sonnet", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}},
|
||||||
|
{ID: "claude-3.5-sonnet", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 3.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192},
|
||||||
|
{ID: "gpt-4o", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "GPT-4o", ContextLength: 128000, MaxCompletionTokens: 16384},
|
||||||
|
{ID: "cursor-small", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Cursor Small", ContextLength: 200000, MaxCompletionTokens: 64000},
|
||||||
|
{ID: "gemini-2.5-pro", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Gemini 2.5 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LookupStaticModelInfo searches all static model definitions for a model by ID.
|
// LookupStaticModelInfo searches all static model definitions for a model by ID.
|
||||||
// Returns nil if no matching model is found.
|
// Returns nil if no matching model is found.
|
||||||
func LookupStaticModelInfo(modelID string) *ModelInfo {
|
func LookupStaticModelInfo(modelID string) *ModelInfo {
|
||||||
@@ -90,38 +257,33 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data := getModels()
|
||||||
allModels := [][]*ModelInfo{
|
allModels := [][]*ModelInfo{
|
||||||
GetClaudeModels(),
|
data.Claude,
|
||||||
GetGeminiModels(),
|
data.Gemini,
|
||||||
GetGeminiVertexModels(),
|
data.Vertex,
|
||||||
GetGeminiCLIModels(),
|
data.GeminiCLI,
|
||||||
GetAIStudioModels(),
|
data.AIStudio,
|
||||||
GetOpenAIModels(),
|
data.CodexPro,
|
||||||
GetQwenModels(),
|
data.Qwen,
|
||||||
GetIFlowModels(),
|
data.IFlow,
|
||||||
GetKimiModels(),
|
data.Kimi,
|
||||||
|
data.Antigravity,
|
||||||
GetGitHubCopilotModels(),
|
GetGitHubCopilotModels(),
|
||||||
GetKiroModels(),
|
GetKiroModels(),
|
||||||
GetKiloModels(),
|
GetKiloModels(),
|
||||||
GetAmazonQModels(),
|
GetAmazonQModels(),
|
||||||
|
GetCodeBuddyModels(),
|
||||||
|
GetCursorModels(),
|
||||||
}
|
}
|
||||||
for _, models := range allModels {
|
for _, models := range allModels {
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if m != nil && m.ID == modelID {
|
if m != nil && m.ID == modelID {
|
||||||
return m
|
return cloneModelInfo(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Antigravity static config
|
|
||||||
if cfg := GetAntigravityModelConfig()[modelID]; cfg != nil {
|
|
||||||
return &ModelInfo{
|
|
||||||
ID: modelID,
|
|
||||||
Thinking: cfg.Thinking,
|
|
||||||
MaxCompletionTokens: cfg.MaxCompletionTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +314,7 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
Description: "OpenAI GPT-4.1 via GitHub Copilot",
|
Description: "OpenAI GPT-4.1 via GitHub Copilot",
|
||||||
ContextLength: 128000,
|
ContextLength: 128000,
|
||||||
MaxCompletionTokens: 16384,
|
MaxCompletionTokens: 16384,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions", "/responses"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +329,7 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
Description: entry.Description,
|
Description: entry.Description,
|
||||||
ContextLength: 128000,
|
ContextLength: 128000,
|
||||||
MaxCompletionTokens: 16384,
|
MaxCompletionTokens: 16384,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions", "/responses"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +464,19 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
SupportedEndpoints: []string{"/responses"},
|
SupportedEndpoints: []string{"/responses"},
|
||||||
Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}},
|
Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "gpt-5.4",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "github-copilot",
|
||||||
|
Type: "github-copilot",
|
||||||
|
DisplayName: "GPT-5.4",
|
||||||
|
Description: "OpenAI GPT-5.4 via GitHub Copilot",
|
||||||
|
ContextLength: 200000,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
SupportedEndpoints: []string{"/responses"},
|
||||||
|
Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-haiku-4.5",
|
ID: "claude-haiku-4.5",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,10 @@ type ModelInfo struct {
|
|||||||
SupportedParameters []string `json:"supported_parameters,omitempty"`
|
SupportedParameters []string `json:"supported_parameters,omitempty"`
|
||||||
// SupportedEndpoints lists supported API endpoints (e.g., "/chat/completions", "/responses").
|
// SupportedEndpoints lists supported API endpoints (e.g., "/chat/completions", "/responses").
|
||||||
SupportedEndpoints []string `json:"supported_endpoints,omitempty"`
|
SupportedEndpoints []string `json:"supported_endpoints,omitempty"`
|
||||||
|
// SupportedInputModalities lists supported input modalities (e.g., TEXT, IMAGE, VIDEO, AUDIO)
|
||||||
|
SupportedInputModalities []string `json:"supportedInputModalities,omitempty"`
|
||||||
|
// SupportedOutputModalities lists supported output modalities (e.g., TEXT, IMAGE)
|
||||||
|
SupportedOutputModalities []string `json:"supportedOutputModalities,omitempty"`
|
||||||
|
|
||||||
// Thinking holds provider-specific reasoning/thinking budget capabilities.
|
// Thinking holds provider-specific reasoning/thinking budget capabilities.
|
||||||
// This is optional and currently used for Gemini thinking budget normalization.
|
// This is optional and currently used for Gemini thinking budget normalization.
|
||||||
@@ -60,20 +64,25 @@ type ModelInfo struct {
|
|||||||
UserDefined bool `json:"-"`
|
UserDefined bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type availableModelsCacheEntry struct {
|
||||||
|
models []map[string]any
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// ThinkingSupport describes a model family's supported internal reasoning budget range.
|
// ThinkingSupport describes a model family's supported internal reasoning budget range.
|
||||||
// Values are interpreted in provider-native token units.
|
// Values are interpreted in provider-native token units.
|
||||||
type ThinkingSupport struct {
|
type ThinkingSupport struct {
|
||||||
// Min is the minimum allowed thinking budget (inclusive).
|
// Min is the minimum allowed thinking budget (inclusive).
|
||||||
Min int `json:"min,omitempty"`
|
Min int `json:"min,omitempty" yaml:"min,omitempty"`
|
||||||
// Max is the maximum allowed thinking budget (inclusive).
|
// Max is the maximum allowed thinking budget (inclusive).
|
||||||
Max int `json:"max,omitempty"`
|
Max int `json:"max,omitempty" yaml:"max,omitempty"`
|
||||||
// ZeroAllowed indicates whether 0 is a valid value (to disable thinking).
|
// ZeroAllowed indicates whether 0 is a valid value (to disable thinking).
|
||||||
ZeroAllowed bool `json:"zero_allowed,omitempty"`
|
ZeroAllowed bool `json:"zero_allowed,omitempty" yaml:"zero-allowed,omitempty"`
|
||||||
// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
|
// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
|
||||||
DynamicAllowed bool `json:"dynamic_allowed,omitempty"`
|
DynamicAllowed bool `json:"dynamic_allowed,omitempty" yaml:"dynamic-allowed,omitempty"`
|
||||||
// Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
|
// Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
|
||||||
// When set, the model uses level-based reasoning instead of token budgets.
|
// When set, the model uses level-based reasoning instead of token budgets.
|
||||||
Levels []string `json:"levels,omitempty"`
|
Levels []string `json:"levels,omitempty" yaml:"levels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelRegistration tracks a model's availability
|
// ModelRegistration tracks a model's availability
|
||||||
@@ -114,6 +123,8 @@ type ModelRegistry struct {
|
|||||||
clientProviders map[string]string
|
clientProviders map[string]string
|
||||||
// mutex ensures thread-safe access to the registry
|
// mutex ensures thread-safe access to the registry
|
||||||
mutex *sync.RWMutex
|
mutex *sync.RWMutex
|
||||||
|
// availableModelsCache stores per-handler snapshots for GetAvailableModels.
|
||||||
|
availableModelsCache map[string]availableModelsCacheEntry
|
||||||
// hook is an optional callback sink for model registration changes
|
// hook is an optional callback sink for model registration changes
|
||||||
hook ModelRegistryHook
|
hook ModelRegistryHook
|
||||||
}
|
}
|
||||||
@@ -130,11 +141,24 @@ func GetGlobalRegistry() *ModelRegistry {
|
|||||||
clientModels: make(map[string][]string),
|
clientModels: make(map[string][]string),
|
||||||
clientModelInfos: make(map[string]map[string]*ModelInfo),
|
clientModelInfos: make(map[string]map[string]*ModelInfo),
|
||||||
clientProviders: make(map[string]string),
|
clientProviders: make(map[string]string),
|
||||||
|
availableModelsCache: make(map[string]availableModelsCacheEntry),
|
||||||
mutex: &sync.RWMutex{},
|
mutex: &sync.RWMutex{},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalRegistry
|
return globalRegistry
|
||||||
}
|
}
|
||||||
|
func (r *ModelRegistry) ensureAvailableModelsCacheLocked() {
|
||||||
|
if r.availableModelsCache == nil {
|
||||||
|
r.availableModelsCache = make(map[string]availableModelsCacheEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ModelRegistry) invalidateAvailableModelsCacheLocked() {
|
||||||
|
if len(r.availableModelsCache) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clear(r.availableModelsCache)
|
||||||
|
}
|
||||||
|
|
||||||
// LookupModelInfo searches dynamic registry (provider-specific > global) then static definitions.
|
// LookupModelInfo searches dynamic registry (provider-specific > global) then static definitions.
|
||||||
func LookupModelInfo(modelID string, provider ...string) *ModelInfo {
|
func LookupModelInfo(modelID string, provider ...string) *ModelInfo {
|
||||||
@@ -149,9 +173,9 @@ func LookupModelInfo(modelID string, provider ...string) *ModelInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if info := GetGlobalRegistry().GetModelInfo(modelID, p); info != nil {
|
if info := GetGlobalRegistry().GetModelInfo(modelID, p); info != nil {
|
||||||
return info
|
return cloneModelInfo(info)
|
||||||
}
|
}
|
||||||
return LookupStaticModelInfo(modelID)
|
return cloneModelInfo(LookupStaticModelInfo(modelID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHook sets an optional hook for observing model registration changes.
|
// SetHook sets an optional hook for observing model registration changes.
|
||||||
@@ -165,6 +189,7 @@ func (r *ModelRegistry) SetHook(hook ModelRegistryHook) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultModelRegistryHookTimeout = 5 * time.Second
|
const defaultModelRegistryHookTimeout = 5 * time.Second
|
||||||
|
const modelQuotaExceededWindow = 5 * time.Minute
|
||||||
|
|
||||||
func (r *ModelRegistry) triggerModelsRegistered(provider, clientID string, models []*ModelInfo) {
|
func (r *ModelRegistry) triggerModelsRegistered(provider, clientID string, models []*ModelInfo) {
|
||||||
hook := r.hook
|
hook := r.hook
|
||||||
@@ -209,6 +234,7 @@ func (r *ModelRegistry) triggerModelsUnregistered(provider, clientID string) {
|
|||||||
func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models []*ModelInfo) {
|
func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models []*ModelInfo) {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
provider := strings.ToLower(clientProvider)
|
provider := strings.ToLower(clientProvider)
|
||||||
uniqueModelIDs := make([]string, 0, len(models))
|
uniqueModelIDs := make([]string, 0, len(models))
|
||||||
@@ -234,6 +260,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
|
|||||||
delete(r.clientModels, clientID)
|
delete(r.clientModels, clientID)
|
||||||
delete(r.clientModelInfos, clientID)
|
delete(r.clientModelInfos, clientID)
|
||||||
delete(r.clientProviders, clientID)
|
delete(r.clientProviders, clientID)
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
misc.LogCredentialSeparator()
|
misc.LogCredentialSeparator()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -261,6 +288,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
|
|||||||
} else {
|
} else {
|
||||||
delete(r.clientProviders, clientID)
|
delete(r.clientProviders, clientID)
|
||||||
}
|
}
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
r.triggerModelsRegistered(provider, clientID, models)
|
r.triggerModelsRegistered(provider, clientID, models)
|
||||||
log.Debugf("Registered client %s from provider %s with %d models", clientID, clientProvider, len(rawModelIDs))
|
log.Debugf("Registered client %s from provider %s with %d models", clientID, clientProvider, len(rawModelIDs))
|
||||||
misc.LogCredentialSeparator()
|
misc.LogCredentialSeparator()
|
||||||
@@ -363,6 +391,9 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
|
|||||||
reg.InfoByProvider[provider] = cloneModelInfo(model)
|
reg.InfoByProvider[provider] = cloneModelInfo(model)
|
||||||
}
|
}
|
||||||
reg.LastUpdated = now
|
reg.LastUpdated = now
|
||||||
|
// Re-registering an existing client/model binding starts a fresh registry
|
||||||
|
// snapshot for that binding. Cooldown and suspension are transient
|
||||||
|
// scheduling state and must not survive this reconciliation step.
|
||||||
if reg.QuotaExceededClients != nil {
|
if reg.QuotaExceededClients != nil {
|
||||||
delete(reg.QuotaExceededClients, clientID)
|
delete(reg.QuotaExceededClients, clientID)
|
||||||
}
|
}
|
||||||
@@ -404,6 +435,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
|
|||||||
delete(r.clientProviders, clientID)
|
delete(r.clientProviders, clientID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
r.triggerModelsRegistered(provider, clientID, models)
|
r.triggerModelsRegistered(provider, clientID, models)
|
||||||
if len(added) == 0 && len(removed) == 0 && !providerChanged {
|
if len(added) == 0 && len(removed) == 0 && !providerChanged {
|
||||||
// Only metadata (e.g., display name) changed; skip separator when no log output.
|
// Only metadata (e.g., display name) changed; skip separator when no log output.
|
||||||
@@ -501,8 +533,18 @@ func cloneModelInfo(model *ModelInfo) *ModelInfo {
|
|||||||
if len(model.SupportedParameters) > 0 {
|
if len(model.SupportedParameters) > 0 {
|
||||||
copyModel.SupportedParameters = append([]string(nil), model.SupportedParameters...)
|
copyModel.SupportedParameters = append([]string(nil), model.SupportedParameters...)
|
||||||
}
|
}
|
||||||
if len(model.SupportedEndpoints) > 0 {
|
if len(model.SupportedInputModalities) > 0 {
|
||||||
copyModel.SupportedEndpoints = append([]string(nil), model.SupportedEndpoints...)
|
copyModel.SupportedInputModalities = append([]string(nil), model.SupportedInputModalities...)
|
||||||
|
}
|
||||||
|
if len(model.SupportedOutputModalities) > 0 {
|
||||||
|
copyModel.SupportedOutputModalities = append([]string(nil), model.SupportedOutputModalities...)
|
||||||
|
}
|
||||||
|
if model.Thinking != nil {
|
||||||
|
copyThinking := *model.Thinking
|
||||||
|
if len(model.Thinking.Levels) > 0 {
|
||||||
|
copyThinking.Levels = append([]string(nil), model.Thinking.Levels...)
|
||||||
|
}
|
||||||
|
copyModel.Thinking = ©Thinking
|
||||||
}
|
}
|
||||||
return ©Model
|
return ©Model
|
||||||
}
|
}
|
||||||
@@ -533,6 +575,7 @@ func (r *ModelRegistry) UnregisterClient(clientID string) {
|
|||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
r.unregisterClientInternal(clientID)
|
r.unregisterClientInternal(clientID)
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// unregisterClientInternal performs the actual client unregistration (internal, no locking)
|
// unregisterClientInternal performs the actual client unregistration (internal, no locking)
|
||||||
@@ -599,9 +642,12 @@ func (r *ModelRegistry) unregisterClientInternal(clientID string) {
|
|||||||
func (r *ModelRegistry) SetModelQuotaExceeded(clientID, modelID string) {
|
func (r *ModelRegistry) SetModelQuotaExceeded(clientID, modelID string) {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
if registration, exists := r.models[modelID]; exists {
|
if registration, exists := r.models[modelID]; exists {
|
||||||
registration.QuotaExceededClients[clientID] = new(time.Now())
|
now := time.Now()
|
||||||
|
registration.QuotaExceededClients[clientID] = &now
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
log.Debugf("Marked model %s as quota exceeded for client %s", modelID, clientID)
|
log.Debugf("Marked model %s as quota exceeded for client %s", modelID, clientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,9 +659,11 @@ func (r *ModelRegistry) SetModelQuotaExceeded(clientID, modelID string) {
|
|||||||
func (r *ModelRegistry) ClearModelQuotaExceeded(clientID, modelID string) {
|
func (r *ModelRegistry) ClearModelQuotaExceeded(clientID, modelID string) {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
if registration, exists := r.models[modelID]; exists {
|
if registration, exists := r.models[modelID]; exists {
|
||||||
delete(registration.QuotaExceededClients, clientID)
|
delete(registration.QuotaExceededClients, clientID)
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
// log.Debugf("Cleared quota exceeded status for model %s and client %s", modelID, clientID)
|
// log.Debugf("Cleared quota exceeded status for model %s and client %s", modelID, clientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,6 +679,7 @@ func (r *ModelRegistry) SuspendClientModel(clientID, modelID, reason string) {
|
|||||||
}
|
}
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
registration, exists := r.models[modelID]
|
registration, exists := r.models[modelID]
|
||||||
if !exists || registration == nil {
|
if !exists || registration == nil {
|
||||||
@@ -644,6 +693,7 @@ func (r *ModelRegistry) SuspendClientModel(clientID, modelID, reason string) {
|
|||||||
}
|
}
|
||||||
registration.SuspendedClients[clientID] = reason
|
registration.SuspendedClients[clientID] = reason
|
||||||
registration.LastUpdated = time.Now()
|
registration.LastUpdated = time.Now()
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
log.Debugf("Suspended client %s for model %s: %s", clientID, modelID, reason)
|
log.Debugf("Suspended client %s for model %s: %s", clientID, modelID, reason)
|
||||||
} else {
|
} else {
|
||||||
@@ -661,6 +711,7 @@ func (r *ModelRegistry) ResumeClientModel(clientID, modelID string) {
|
|||||||
}
|
}
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
registration, exists := r.models[modelID]
|
registration, exists := r.models[modelID]
|
||||||
if !exists || registration == nil || registration.SuspendedClients == nil {
|
if !exists || registration == nil || registration.SuspendedClients == nil {
|
||||||
@@ -671,6 +722,7 @@ func (r *ModelRegistry) ResumeClientModel(clientID, modelID string) {
|
|||||||
}
|
}
|
||||||
delete(registration.SuspendedClients, clientID)
|
delete(registration.SuspendedClients, clientID)
|
||||||
registration.LastUpdated = time.Now()
|
registration.LastUpdated = time.Now()
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
log.Debugf("Resumed client %s for model %s", clientID, modelID)
|
log.Debugf("Resumed client %s for model %s", clientID, modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,22 +758,51 @@ func (r *ModelRegistry) ClientSupportsModel(clientID, modelID string) bool {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - []map[string]any: List of available models in the requested format
|
// - []map[string]any: List of available models in the requested format
|
||||||
func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any {
|
func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any {
|
||||||
r.mutex.RLock()
|
|
||||||
defer r.mutex.RUnlock()
|
|
||||||
|
|
||||||
models := make([]map[string]any, 0)
|
|
||||||
quotaExpiredDuration := 5 * time.Minute
|
|
||||||
|
|
||||||
for _, registration := range r.models {
|
|
||||||
// Check if model has any non-quota-exceeded clients
|
|
||||||
availableClients := registration.Count
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Count clients that have exceeded quota but haven't recovered yet
|
r.mutex.RLock()
|
||||||
|
if cache, ok := r.availableModelsCache[handlerType]; ok && (cache.expiresAt.IsZero() || now.Before(cache.expiresAt)) {
|
||||||
|
models := cloneModelMaps(cache.models)
|
||||||
|
r.mutex.RUnlock()
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
r.mutex.RUnlock()
|
||||||
|
|
||||||
|
r.mutex.Lock()
|
||||||
|
defer r.mutex.Unlock()
|
||||||
|
r.ensureAvailableModelsCacheLocked()
|
||||||
|
|
||||||
|
if cache, ok := r.availableModelsCache[handlerType]; ok && (cache.expiresAt.IsZero() || now.Before(cache.expiresAt)) {
|
||||||
|
return cloneModelMaps(cache.models)
|
||||||
|
}
|
||||||
|
|
||||||
|
models, expiresAt := r.buildAvailableModelsLocked(handlerType, now)
|
||||||
|
r.availableModelsCache[handlerType] = availableModelsCacheEntry{
|
||||||
|
models: cloneModelMaps(models),
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ModelRegistry) buildAvailableModelsLocked(handlerType string, now time.Time) ([]map[string]any, time.Time) {
|
||||||
|
models := make([]map[string]any, 0, len(r.models))
|
||||||
|
var expiresAt time.Time
|
||||||
|
|
||||||
|
for _, registration := range r.models {
|
||||||
|
availableClients := registration.Count
|
||||||
|
|
||||||
expiredClients := 0
|
expiredClients := 0
|
||||||
for _, quotaTime := range registration.QuotaExceededClients {
|
for _, quotaTime := range registration.QuotaExceededClients {
|
||||||
if quotaTime != nil && now.Sub(*quotaTime) < quotaExpiredDuration {
|
if quotaTime == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recoveryAt := quotaTime.Add(modelQuotaExceededWindow)
|
||||||
|
if now.Before(recoveryAt) {
|
||||||
expiredClients++
|
expiredClients++
|
||||||
|
if expiresAt.IsZero() || recoveryAt.Before(expiresAt) {
|
||||||
|
expiresAt = recoveryAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +823,6 @@ func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any
|
|||||||
effectiveClients = 0
|
effectiveClients = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include models that have available clients, or those solely cooling down.
|
|
||||||
if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {
|
if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {
|
||||||
model := r.convertModelToMap(registration.Info, handlerType)
|
model := r.convertModelToMap(registration.Info, handlerType)
|
||||||
if model != nil {
|
if model != nil {
|
||||||
@@ -751,7 +831,44 @@ func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return models
|
return models, expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneModelMaps(models []map[string]any) []map[string]any {
|
||||||
|
cloned := make([]map[string]any, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
if model == nil {
|
||||||
|
cloned = append(cloned, nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copyModel := make(map[string]any, len(model))
|
||||||
|
for key, value := range model {
|
||||||
|
copyModel[key] = cloneModelMapValue(value)
|
||||||
|
}
|
||||||
|
cloned = append(cloned, copyModel)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneModelMapValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
copyMap := make(map[string]any, len(typed))
|
||||||
|
for key, entry := range typed {
|
||||||
|
copyMap[key] = cloneModelMapValue(entry)
|
||||||
|
}
|
||||||
|
return copyMap
|
||||||
|
case []any:
|
||||||
|
copySlice := make([]any, len(typed))
|
||||||
|
for i, entry := range typed {
|
||||||
|
copySlice[i] = cloneModelMapValue(entry)
|
||||||
|
}
|
||||||
|
return copySlice
|
||||||
|
case []string:
|
||||||
|
return append([]string(nil), typed...)
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableModelsByProvider returns models available for the given provider identifier.
|
// GetAvailableModelsByProvider returns models available for the given provider identifier.
|
||||||
@@ -815,7 +932,6 @@ func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelIn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
quotaExpiredDuration := 5 * time.Minute
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
result := make([]*ModelInfo, 0, len(providerModels))
|
result := make([]*ModelInfo, 0, len(providerModels))
|
||||||
|
|
||||||
@@ -837,7 +953,7 @@ func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelIn
|
|||||||
if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {
|
if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if quotaTime != nil && now.Sub(*quotaTime) < quotaExpiredDuration {
|
if quotaTime != nil && now.Sub(*quotaTime) < modelQuotaExceededWindow {
|
||||||
expiredClients++
|
expiredClients++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,11 +983,11 @@ func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelIn
|
|||||||
|
|
||||||
if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {
|
if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {
|
||||||
if entry.info != nil {
|
if entry.info != nil {
|
||||||
result = append(result, entry.info)
|
result = append(result, cloneModelInfo(entry.info))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ok && registration != nil && registration.Info != nil {
|
if ok && registration != nil && registration.Info != nil {
|
||||||
result = append(result, registration.Info)
|
result = append(result, cloneModelInfo(registration.Info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,12 +1007,11 @@ func (r *ModelRegistry) GetModelCount(modelID string) int {
|
|||||||
|
|
||||||
if registration, exists := r.models[modelID]; exists {
|
if registration, exists := r.models[modelID]; exists {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
quotaExpiredDuration := 5 * time.Minute
|
|
||||||
|
|
||||||
// Count clients that have exceeded quota but haven't recovered yet
|
// Count clients that have exceeded quota but haven't recovered yet
|
||||||
expiredClients := 0
|
expiredClients := 0
|
||||||
for _, quotaTime := range registration.QuotaExceededClients {
|
for _, quotaTime := range registration.QuotaExceededClients {
|
||||||
if quotaTime != nil && now.Sub(*quotaTime) < quotaExpiredDuration {
|
if quotaTime != nil && now.Sub(*quotaTime) < modelQuotaExceededWindow {
|
||||||
expiredClients++
|
expiredClients++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,13 +1095,13 @@ func (r *ModelRegistry) GetModelInfo(modelID, provider string) *ModelInfo {
|
|||||||
if reg.Providers != nil {
|
if reg.Providers != nil {
|
||||||
if count, ok := reg.Providers[provider]; ok && count > 0 {
|
if count, ok := reg.Providers[provider]; ok && count > 0 {
|
||||||
if info, ok := reg.InfoByProvider[provider]; ok && info != nil {
|
if info, ok := reg.InfoByProvider[provider]; ok && info != nil {
|
||||||
return info
|
return cloneModelInfo(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to global info (last registered)
|
// Fallback to global info (last registered)
|
||||||
return reg.Info
|
return cloneModelInfo(reg.Info)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1141,7 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
|
|||||||
result["max_completion_tokens"] = model.MaxCompletionTokens
|
result["max_completion_tokens"] = model.MaxCompletionTokens
|
||||||
}
|
}
|
||||||
if len(model.SupportedParameters) > 0 {
|
if len(model.SupportedParameters) > 0 {
|
||||||
result["supported_parameters"] = model.SupportedParameters
|
result["supported_parameters"] = append([]string(nil), model.SupportedParameters...)
|
||||||
}
|
}
|
||||||
if len(model.SupportedEndpoints) > 0 {
|
if len(model.SupportedEndpoints) > 0 {
|
||||||
result["supported_endpoints"] = model.SupportedEndpoints
|
result["supported_endpoints"] = model.SupportedEndpoints
|
||||||
@@ -1087,7 +1202,13 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
|
|||||||
result["outputTokenLimit"] = model.OutputTokenLimit
|
result["outputTokenLimit"] = model.OutputTokenLimit
|
||||||
}
|
}
|
||||||
if len(model.SupportedGenerationMethods) > 0 {
|
if len(model.SupportedGenerationMethods) > 0 {
|
||||||
result["supportedGenerationMethods"] = model.SupportedGenerationMethods
|
result["supportedGenerationMethods"] = append([]string(nil), model.SupportedGenerationMethods...)
|
||||||
|
}
|
||||||
|
if len(model.SupportedInputModalities) > 0 {
|
||||||
|
result["supportedInputModalities"] = append([]string(nil), model.SupportedInputModalities...)
|
||||||
|
}
|
||||||
|
if len(model.SupportedOutputModalities) > 0 {
|
||||||
|
result["supportedOutputModalities"] = append([]string(nil), model.SupportedOutputModalities...)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1116,16 +1237,20 @@ func (r *ModelRegistry) CleanupExpiredQuotas() {
|
|||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
quotaExpiredDuration := 5 * time.Minute
|
invalidated := false
|
||||||
|
|
||||||
for modelID, registration := range r.models {
|
for modelID, registration := range r.models {
|
||||||
for clientID, quotaTime := range registration.QuotaExceededClients {
|
for clientID, quotaTime := range registration.QuotaExceededClients {
|
||||||
if quotaTime != nil && now.Sub(*quotaTime) >= quotaExpiredDuration {
|
if quotaTime != nil && now.Sub(*quotaTime) >= modelQuotaExceededWindow {
|
||||||
delete(registration.QuotaExceededClients, clientID)
|
delete(registration.QuotaExceededClients, clientID)
|
||||||
|
invalidated = true
|
||||||
log.Debugf("Cleaned up expired quota tracking for model %s, client %s", modelID, clientID)
|
log.Debugf("Cleaned up expired quota tracking for model %s, client %s", modelID, clientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if invalidated {
|
||||||
|
r.invalidateAvailableModelsCacheLocked()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFirstAvailableModel returns the first available model for the given handler type.
|
// GetFirstAvailableModel returns the first available model for the given handler type.
|
||||||
@@ -1139,8 +1264,6 @@ func (r *ModelRegistry) CleanupExpiredQuotas() {
|
|||||||
// - string: The model ID of the first available model, or empty string if none available
|
// - string: The model ID of the first available model, or empty string if none available
|
||||||
// - error: An error if no models are available
|
// - error: An error if no models are available
|
||||||
func (r *ModelRegistry) GetFirstAvailableModel(handlerType string) (string, error) {
|
func (r *ModelRegistry) GetFirstAvailableModel(handlerType string) (string, error) {
|
||||||
r.mutex.RLock()
|
|
||||||
defer r.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Get all available models for this handler type
|
// Get all available models for this handler type
|
||||||
models := r.GetAvailableModels(handlerType)
|
models := r.GetAvailableModels(handlerType)
|
||||||
@@ -1200,13 +1323,13 @@ func (r *ModelRegistry) GetModelsForClient(clientID string) []*ModelInfo {
|
|||||||
// Prefer client's own model info to preserve original type/owned_by
|
// Prefer client's own model info to preserve original type/owned_by
|
||||||
if clientInfos != nil {
|
if clientInfos != nil {
|
||||||
if info, ok := clientInfos[modelID]; ok && info != nil {
|
if info, ok := clientInfos[modelID]; ok && info != nil {
|
||||||
result = append(result, info)
|
result = append(result, cloneModelInfo(info))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to global registry (for backwards compatibility)
|
// Fallback to global registry (for backwards compatibility)
|
||||||
if reg, ok := r.models[modelID]; ok && reg.Info != nil {
|
if reg, ok := r.models[modelID]; ok && reg.Info != nil {
|
||||||
result = append(result, reg.Info)
|
result = append(result, cloneModelInfo(reg.Info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
54
internal/registry/model_registry_cache_test.go
Normal file
54
internal/registry/model_registry_cache_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGetAvailableModelsReturnsClonedSnapshots(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})
|
||||||
|
|
||||||
|
first := r.GetAvailableModels("openai")
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Fatalf("expected 1 model, got %d", len(first))
|
||||||
|
}
|
||||||
|
first[0]["id"] = "mutated"
|
||||||
|
first[0]["display_name"] = "Mutated"
|
||||||
|
|
||||||
|
second := r.GetAvailableModels("openai")
|
||||||
|
if got := second[0]["id"]; got != "m1" {
|
||||||
|
t.Fatalf("expected cached snapshot to stay isolated, got id %v", got)
|
||||||
|
}
|
||||||
|
if got := second[0]["display_name"]; got != "Model One" {
|
||||||
|
t.Fatalf("expected cached snapshot to stay isolated, got display_name %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})
|
||||||
|
|
||||||
|
models := r.GetAvailableModels("openai")
|
||||||
|
if len(models) != 1 {
|
||||||
|
t.Fatalf("expected 1 model, got %d", len(models))
|
||||||
|
}
|
||||||
|
if got := models[0]["display_name"]; got != "Model One" {
|
||||||
|
t.Fatalf("expected initial display_name Model One, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One Updated"}})
|
||||||
|
models = r.GetAvailableModels("openai")
|
||||||
|
if got := models[0]["display_name"]; got != "Model One Updated" {
|
||||||
|
t.Fatalf("expected updated display_name after cache invalidation, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.SuspendClientModel("client-1", "m1", "manual")
|
||||||
|
models = r.GetAvailableModels("openai")
|
||||||
|
if len(models) != 0 {
|
||||||
|
t.Fatalf("expected no available models after suspension, got %d", len(models))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ResumeClientModel("client-1", "m1")
|
||||||
|
models = r.GetAvailableModels("openai")
|
||||||
|
if len(models) != 1 {
|
||||||
|
t.Fatalf("expected model to reappear after resume, got %d", len(models))
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/registry/model_registry_safety_test.go
Normal file
149
internal/registry/model_registry_safety_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetModelInfoReturnsClone(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
|
||||||
|
ID: "m1",
|
||||||
|
DisplayName: "Model One",
|
||||||
|
Thinking: &ThinkingSupport{Min: 1, Max: 2, Levels: []string{"low", "high"}},
|
||||||
|
}})
|
||||||
|
|
||||||
|
first := r.GetModelInfo("m1", "gemini")
|
||||||
|
if first == nil {
|
||||||
|
t.Fatal("expected model info")
|
||||||
|
}
|
||||||
|
first.DisplayName = "mutated"
|
||||||
|
first.Thinking.Levels[0] = "mutated"
|
||||||
|
|
||||||
|
second := r.GetModelInfo("m1", "gemini")
|
||||||
|
if second.DisplayName != "Model One" {
|
||||||
|
t.Fatalf("expected cloned display name, got %q", second.DisplayName)
|
||||||
|
}
|
||||||
|
if second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] != "low" {
|
||||||
|
t.Fatalf("expected cloned thinking levels, got %+v", second.Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelsForClientReturnsClones(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
|
||||||
|
ID: "m1",
|
||||||
|
DisplayName: "Model One",
|
||||||
|
Thinking: &ThinkingSupport{Levels: []string{"low", "high"}},
|
||||||
|
}})
|
||||||
|
|
||||||
|
first := r.GetModelsForClient("client-1")
|
||||||
|
if len(first) != 1 || first[0] == nil {
|
||||||
|
t.Fatalf("expected one model, got %+v", first)
|
||||||
|
}
|
||||||
|
first[0].DisplayName = "mutated"
|
||||||
|
first[0].Thinking.Levels[0] = "mutated"
|
||||||
|
|
||||||
|
second := r.GetModelsForClient("client-1")
|
||||||
|
if len(second) != 1 || second[0] == nil {
|
||||||
|
t.Fatalf("expected one model on second fetch, got %+v", second)
|
||||||
|
}
|
||||||
|
if second[0].DisplayName != "Model One" {
|
||||||
|
t.Fatalf("expected cloned display name, got %q", second[0].DisplayName)
|
||||||
|
}
|
||||||
|
if second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != "low" {
|
||||||
|
t.Fatalf("expected cloned thinking levels, got %+v", second[0].Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableModelsByProviderReturnsClones(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
|
||||||
|
ID: "m1",
|
||||||
|
DisplayName: "Model One",
|
||||||
|
Thinking: &ThinkingSupport{Levels: []string{"low", "high"}},
|
||||||
|
}})
|
||||||
|
|
||||||
|
first := r.GetAvailableModelsByProvider("gemini")
|
||||||
|
if len(first) != 1 || first[0] == nil {
|
||||||
|
t.Fatalf("expected one model, got %+v", first)
|
||||||
|
}
|
||||||
|
first[0].DisplayName = "mutated"
|
||||||
|
first[0].Thinking.Levels[0] = "mutated"
|
||||||
|
|
||||||
|
second := r.GetAvailableModelsByProvider("gemini")
|
||||||
|
if len(second) != 1 || second[0] == nil {
|
||||||
|
t.Fatalf("expected one model on second fetch, got %+v", second)
|
||||||
|
}
|
||||||
|
if second[0].DisplayName != "Model One" {
|
||||||
|
t.Fatalf("expected cloned display name, got %q", second[0].DisplayName)
|
||||||
|
}
|
||||||
|
if second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != "low" {
|
||||||
|
t.Fatalf("expected cloned thinking levels, got %+v", second[0].Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupExpiredQuotasInvalidatesAvailableModelsCache(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "openai", []*ModelInfo{{ID: "m1", Created: 1}})
|
||||||
|
r.SetModelQuotaExceeded("client-1", "m1")
|
||||||
|
if models := r.GetAvailableModels("openai"); len(models) != 1 {
|
||||||
|
t.Fatalf("expected cooldown model to remain listed before cleanup, got %d", len(models))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mutex.Lock()
|
||||||
|
quotaTime := time.Now().Add(-6 * time.Minute)
|
||||||
|
r.models["m1"].QuotaExceededClients["client-1"] = "aTime
|
||||||
|
r.mutex.Unlock()
|
||||||
|
|
||||||
|
r.CleanupExpiredQuotas()
|
||||||
|
|
||||||
|
if count := r.GetModelCount("m1"); count != 1 {
|
||||||
|
t.Fatalf("expected model count 1 after cleanup, got %d", count)
|
||||||
|
}
|
||||||
|
models := r.GetAvailableModels("openai")
|
||||||
|
if len(models) != 1 {
|
||||||
|
t.Fatalf("expected model to stay available after cleanup, got %d", len(models))
|
||||||
|
}
|
||||||
|
if got := models[0]["id"]; got != "m1" {
|
||||||
|
t.Fatalf("expected model id m1, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) {
|
||||||
|
r := newTestModelRegistry()
|
||||||
|
r.RegisterClient("client-1", "openai", []*ModelInfo{{
|
||||||
|
ID: "m1",
|
||||||
|
DisplayName: "Model One",
|
||||||
|
SupportedParameters: []string{"temperature", "top_p"},
|
||||||
|
}})
|
||||||
|
|
||||||
|
first := r.GetAvailableModels("openai")
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Fatalf("expected one model, got %d", len(first))
|
||||||
|
}
|
||||||
|
params, ok := first[0]["supported_parameters"].([]string)
|
||||||
|
if !ok || len(params) != 2 {
|
||||||
|
t.Fatalf("expected supported_parameters slice, got %#v", first[0]["supported_parameters"])
|
||||||
|
}
|
||||||
|
params[0] = "mutated"
|
||||||
|
|
||||||
|
second := r.GetAvailableModels("openai")
|
||||||
|
params, ok = second[0]["supported_parameters"].([]string)
|
||||||
|
if !ok || len(params) != 2 || params[0] != "temperature" {
|
||||||
|
t.Fatalf("expected cloned supported_parameters, got %#v", second[0]["supported_parameters"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) {
|
||||||
|
first := LookupModelInfo("glm-4.6")
|
||||||
|
if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 {
|
||||||
|
t.Fatalf("expected static model with thinking levels, got %+v", first)
|
||||||
|
}
|
||||||
|
first.Thinking.Levels[0] = "mutated"
|
||||||
|
|
||||||
|
second := LookupModelInfo("glm-4.6")
|
||||||
|
if second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == "mutated" {
|
||||||
|
t.Fatalf("expected static lookup clone, got %+v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
372
internal/registry/model_updater.go
Normal file
372
internal/registry/model_updater.go
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modelsFetchTimeout = 30 * time.Second
|
||||||
|
modelsRefreshInterval = 3 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var modelsURLs = []string{
|
||||||
|
"https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/models.json",
|
||||||
|
"https://models.router-for.me/models.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed models/models.json
|
||||||
|
var embeddedModelsJSON []byte
|
||||||
|
|
||||||
|
type modelStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data *staticModelsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelsCatalogStore = &modelStore{}
|
||||||
|
|
||||||
|
var updaterOnce sync.Once
|
||||||
|
|
||||||
|
// ModelRefreshCallback is invoked when startup or periodic model refresh detects changes.
|
||||||
|
// changedProviders contains the provider names whose model definitions changed.
|
||||||
|
type ModelRefreshCallback func(changedProviders []string)
|
||||||
|
|
||||||
|
var (
|
||||||
|
refreshCallbackMu sync.Mutex
|
||||||
|
refreshCallback ModelRefreshCallback
|
||||||
|
pendingRefreshChanges []string
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetModelRefreshCallback registers a callback that is invoked when startup or
|
||||||
|
// periodic model refresh detects changes. Only one callback is supported;
|
||||||
|
// subsequent calls replace the previous callback.
|
||||||
|
func SetModelRefreshCallback(cb ModelRefreshCallback) {
|
||||||
|
refreshCallbackMu.Lock()
|
||||||
|
refreshCallback = cb
|
||||||
|
var pending []string
|
||||||
|
if cb != nil && len(pendingRefreshChanges) > 0 {
|
||||||
|
pending = append([]string(nil), pendingRefreshChanges...)
|
||||||
|
pendingRefreshChanges = nil
|
||||||
|
}
|
||||||
|
refreshCallbackMu.Unlock()
|
||||||
|
|
||||||
|
if cb != nil && len(pending) > 0 {
|
||||||
|
cb(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Load embedded data as fallback on startup.
|
||||||
|
if err := loadModelsFromBytes(embeddedModelsJSON, "embed"); err != nil {
|
||||||
|
panic(fmt.Sprintf("registry: failed to parse embedded models.json: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartModelsUpdater starts a background updater that fetches models
|
||||||
|
// immediately on startup and then refreshes the model catalog every 3 hours.
|
||||||
|
// Safe to call multiple times; only one updater will run.
|
||||||
|
func StartModelsUpdater(ctx context.Context) {
|
||||||
|
updaterOnce.Do(func() {
|
||||||
|
go runModelsUpdater(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runModelsUpdater(ctx context.Context) {
|
||||||
|
tryStartupRefresh(ctx)
|
||||||
|
periodicRefresh(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func periodicRefresh(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(modelsRefreshInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
log.Infof("periodic model refresh started (interval=%s)", modelsRefreshInterval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
tryPeriodicRefresh(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryPeriodicRefresh fetches models from remote, compares with the current
|
||||||
|
// catalog, and notifies the registered callback if any provider changed.
|
||||||
|
func tryPeriodicRefresh(ctx context.Context) {
|
||||||
|
tryRefreshModels(ctx, "periodic model refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryStartupRefresh fetches models from remote in the background during
|
||||||
|
// process startup. It uses the same change detection as periodic refresh so
|
||||||
|
// existing auth registrations can be updated after the callback is registered.
|
||||||
|
func tryStartupRefresh(ctx context.Context) {
|
||||||
|
tryRefreshModels(ctx, "startup model refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryRefreshModels(ctx context.Context, label string) {
|
||||||
|
oldData := getModels()
|
||||||
|
|
||||||
|
parsed, url := fetchModelsFromRemote(ctx)
|
||||||
|
if parsed == nil {
|
||||||
|
log.Warnf("%s: fetch failed from all URLs, keeping current data", label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect changes before updating store.
|
||||||
|
changed := detectChangedProviders(oldData, parsed)
|
||||||
|
|
||||||
|
// Update store with new data regardless.
|
||||||
|
modelsCatalogStore.mu.Lock()
|
||||||
|
modelsCatalogStore.data = parsed
|
||||||
|
modelsCatalogStore.mu.Unlock()
|
||||||
|
|
||||||
|
if len(changed) == 0 {
|
||||||
|
log.Infof("%s completed from %s, no changes detected", label, url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("%s completed from %s, changes detected for providers: %v", label, url, changed)
|
||||||
|
notifyModelRefresh(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchModelsFromRemote tries all remote URLs and returns the parsed model catalog
|
||||||
|
// along with the URL it was fetched from. Returns (nil, "") if all fetches fail.
|
||||||
|
func fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) {
|
||||||
|
client := &http.Client{Timeout: modelsFetchTimeout}
|
||||||
|
for _, url := range modelsURLs {
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, modelsFetchTimeout)
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Debugf("models fetch request creation failed for %s: %v", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Debugf("models fetch failed from %s: %v", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
log.Debugf("models fetch returned %d from %s", resp.StatusCode, url)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("models fetch read error from %s: %v", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed staticModelsJSON
|
||||||
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||||
|
log.Warnf("models parse failed from %s: %v", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateModelsCatalog(&parsed); err != nil {
|
||||||
|
log.Warnf("models validate failed from %s: %v", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return &parsed, url
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectChangedProviders compares two model catalogs and returns provider names
|
||||||
|
// whose model definitions differ. Codex tiers (free/team/plus/pro) are grouped
|
||||||
|
// under a single "codex" provider.
|
||||||
|
func detectChangedProviders(oldData, newData *staticModelsJSON) []string {
|
||||||
|
if oldData == nil || newData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type section struct {
|
||||||
|
provider string
|
||||||
|
oldList []*ModelInfo
|
||||||
|
newList []*ModelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
sections := []section{
|
||||||
|
{"claude", oldData.Claude, newData.Claude},
|
||||||
|
{"gemini", oldData.Gemini, newData.Gemini},
|
||||||
|
{"vertex", oldData.Vertex, newData.Vertex},
|
||||||
|
{"gemini-cli", oldData.GeminiCLI, newData.GeminiCLI},
|
||||||
|
{"aistudio", oldData.AIStudio, newData.AIStudio},
|
||||||
|
{"codex", oldData.CodexFree, newData.CodexFree},
|
||||||
|
{"codex", oldData.CodexTeam, newData.CodexTeam},
|
||||||
|
{"codex", oldData.CodexPlus, newData.CodexPlus},
|
||||||
|
{"codex", oldData.CodexPro, newData.CodexPro},
|
||||||
|
{"qwen", oldData.Qwen, newData.Qwen},
|
||||||
|
{"iflow", oldData.IFlow, newData.IFlow},
|
||||||
|
{"kimi", oldData.Kimi, newData.Kimi},
|
||||||
|
{"antigravity", oldData.Antigravity, newData.Antigravity},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool, len(sections))
|
||||||
|
var changed []string
|
||||||
|
for _, s := range sections {
|
||||||
|
if seen[s.provider] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if modelSectionChanged(s.oldList, s.newList) {
|
||||||
|
changed = append(changed, s.provider)
|
||||||
|
seen[s.provider] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelSectionChanged reports whether two model slices differ.
|
||||||
|
func modelSectionChanged(a, b []*ModelInfo) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(a) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
aj, err1 := json.Marshal(a)
|
||||||
|
bj, err2 := json.Marshal(b)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return string(aj) != string(bj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyModelRefresh(changedProviders []string) {
|
||||||
|
if len(changedProviders) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCallbackMu.Lock()
|
||||||
|
cb := refreshCallback
|
||||||
|
if cb == nil {
|
||||||
|
pendingRefreshChanges = mergeProviderNames(pendingRefreshChanges, changedProviders)
|
||||||
|
refreshCallbackMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshCallbackMu.Unlock()
|
||||||
|
cb(changedProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeProviderNames(existing, incoming []string) []string {
|
||||||
|
if len(incoming) == 0 {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(existing)+len(incoming))
|
||||||
|
merged := make([]string, 0, len(existing)+len(incoming))
|
||||||
|
for _, provider := range existing {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
merged = append(merged, name)
|
||||||
|
}
|
||||||
|
for _, provider := range incoming {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
merged = append(merged, name)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadModelsFromBytes(data []byte, source string) error {
|
||||||
|
var parsed staticModelsJSON
|
||||||
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("%s: decode models catalog: %w", source, err)
|
||||||
|
}
|
||||||
|
if err := validateModelsCatalog(&parsed); err != nil {
|
||||||
|
return fmt.Errorf("%s: validate models catalog: %w", source, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsCatalogStore.mu.Lock()
|
||||||
|
modelsCatalogStore.data = &parsed
|
||||||
|
modelsCatalogStore.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getModels() *staticModelsJSON {
|
||||||
|
modelsCatalogStore.mu.RLock()
|
||||||
|
defer modelsCatalogStore.mu.RUnlock()
|
||||||
|
return modelsCatalogStore.data
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateModelsCatalog(data *staticModelsJSON) error {
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("catalog is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredSections := []struct {
|
||||||
|
name string
|
||||||
|
models []*ModelInfo
|
||||||
|
}{
|
||||||
|
{name: "claude", models: data.Claude},
|
||||||
|
{name: "gemini", models: data.Gemini},
|
||||||
|
{name: "vertex", models: data.Vertex},
|
||||||
|
{name: "gemini-cli", models: data.GeminiCLI},
|
||||||
|
{name: "aistudio", models: data.AIStudio},
|
||||||
|
{name: "codex-free", models: data.CodexFree},
|
||||||
|
{name: "codex-team", models: data.CodexTeam},
|
||||||
|
{name: "codex-plus", models: data.CodexPlus},
|
||||||
|
{name: "codex-pro", models: data.CodexPro},
|
||||||
|
{name: "qwen", models: data.Qwen},
|
||||||
|
{name: "iflow", models: data.IFlow},
|
||||||
|
{name: "kimi", models: data.Kimi},
|
||||||
|
{name: "antigravity", models: data.Antigravity},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, section := range requiredSections {
|
||||||
|
if err := validateModelSection(section.name, section.models); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateModelSection(section string, models []*ModelInfo) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return fmt.Errorf("%s section is empty", section)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(models))
|
||||||
|
for i, model := range models {
|
||||||
|
if model == nil {
|
||||||
|
return fmt.Errorf("%s[%d] is null", section, i)
|
||||||
|
}
|
||||||
|
modelID := strings.TrimSpace(model.ID)
|
||||||
|
if modelID == "" {
|
||||||
|
return fmt.Errorf("%s[%d] has empty id", section, i)
|
||||||
|
}
|
||||||
|
if _, exists := seen[modelID]; exists {
|
||||||
|
return fmt.Errorf("%s contains duplicate model id %q", section, modelID)
|
||||||
|
}
|
||||||
|
seen[modelID] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
2683
internal/registry/models/models.json
Normal file
2683
internal/registry/models/models.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -164,7 +164,7 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
|
|||||||
reporter.publish(ctx, parseGeminiUsage(wsResp.Body))
|
reporter.publish(ctx, parseGeminiUsage(wsResp.Body))
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: ensureColonSpacedJSON([]byte(out)), Headers: wsResp.Headers.Clone()}
|
resp = cliproxyexecutor.Response{Payload: ensureColonSpacedJSON(out), Headers: wsResp.Headers.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
|
|||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, ¶m)
|
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}
|
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
|
|||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m)
|
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}
|
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}
|
||||||
}
|
}
|
||||||
reporter.publish(ctx, parseGeminiUsage(event.Payload))
|
reporter.publish(ctx, parseGeminiUsage(event.Payload))
|
||||||
return false
|
return false
|
||||||
@@ -373,7 +373,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
|
|||||||
return cliproxyexecutor.Response{}, fmt.Errorf("wsrelay: totalTokens missing in response")
|
return cliproxyexecutor.Response{}, fmt.Errorf("wsrelay: totalTokens missing in response")
|
||||||
}
|
}
|
||||||
translated := sdktranslator.TranslateTokenCount(ctx, body.toFormat, opts.SourceFormat, totalTokens, resp.Body)
|
translated := sdktranslator.TranslateTokenCount(ctx, body.toFormat, opts.SourceFormat, totalTokens, resp.Body)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil
|
return cliproxyexecutor.Response{Payload: translated}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh refreshes the authentication credentials (no-op for AI Studio).
|
// Refresh refreshes the authentication credentials (no-op for AI Studio).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -23,7 +24,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
@@ -42,90 +42,19 @@ const (
|
|||||||
antigravityCountTokensPath = "/v1internal:countTokens"
|
antigravityCountTokensPath = "/v1internal:countTokens"
|
||||||
antigravityStreamPath = "/v1internal:streamGenerateContent"
|
antigravityStreamPath = "/v1internal:streamGenerateContent"
|
||||||
antigravityGeneratePath = "/v1internal:generateContent"
|
antigravityGeneratePath = "/v1internal:generateContent"
|
||||||
antigravityModelsPath = "/v1internal:fetchAvailableModels"
|
|
||||||
antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||||
antigravityClientSecret = "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"
|
antigravityClientSecret = "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"
|
||||||
defaultAntigravityAgent = "antigravity/1.104.0 darwin/arm64"
|
defaultAntigravityAgent = "antigravity/1.19.6 darwin/arm64"
|
||||||
antigravityAuthType = "antigravity"
|
antigravityAuthType = "antigravity"
|
||||||
refreshSkew = 3000 * time.Second
|
refreshSkew = 3000 * time.Second
|
||||||
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**"
|
// 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**"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
|
randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
randSourceMutex sync.Mutex
|
randSourceMutex sync.Mutex
|
||||||
// antigravityPrimaryModelsCache keeps the latest non-empty model list fetched
|
|
||||||
// from any antigravity auth. Empty fetches never overwrite this cache.
|
|
||||||
antigravityPrimaryModelsCache struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
models []*registry.ModelInfo
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func cloneAntigravityModels(models []*registry.ModelInfo) []*registry.ModelInfo {
|
|
||||||
if len(models) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]*registry.ModelInfo, 0, len(models))
|
|
||||||
for _, model := range models {
|
|
||||||
if model == nil || strings.TrimSpace(model.ID) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, cloneAntigravityModelInfo(model))
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneAntigravityModelInfo(model *registry.ModelInfo) *registry.ModelInfo {
|
|
||||||
if model == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
clone := *model
|
|
||||||
if len(model.SupportedGenerationMethods) > 0 {
|
|
||||||
clone.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)
|
|
||||||
}
|
|
||||||
if len(model.SupportedParameters) > 0 {
|
|
||||||
clone.SupportedParameters = append([]string(nil), model.SupportedParameters...)
|
|
||||||
}
|
|
||||||
if model.Thinking != nil {
|
|
||||||
thinkingClone := *model.Thinking
|
|
||||||
if len(model.Thinking.Levels) > 0 {
|
|
||||||
thinkingClone.Levels = append([]string(nil), model.Thinking.Levels...)
|
|
||||||
}
|
|
||||||
clone.Thinking = &thinkingClone
|
|
||||||
}
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeAntigravityPrimaryModels(models []*registry.ModelInfo) bool {
|
|
||||||
cloned := cloneAntigravityModels(models)
|
|
||||||
if len(cloned) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
antigravityPrimaryModelsCache.mu.Lock()
|
|
||||||
antigravityPrimaryModelsCache.models = cloned
|
|
||||||
antigravityPrimaryModelsCache.mu.Unlock()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadAntigravityPrimaryModels() []*registry.ModelInfo {
|
|
||||||
antigravityPrimaryModelsCache.mu.RLock()
|
|
||||||
cloned := cloneAntigravityModels(antigravityPrimaryModelsCache.models)
|
|
||||||
antigravityPrimaryModelsCache.mu.RUnlock()
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func fallbackAntigravityPrimaryModels() []*registry.ModelInfo {
|
|
||||||
models := loadAntigravityPrimaryModels()
|
|
||||||
if len(models) > 0 {
|
|
||||||
log.Debugf("antigravity executor: using cached primary model list (%d models)", len(models))
|
|
||||||
}
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
|
|
||||||
// AntigravityExecutor proxies requests to the antigravity upstream.
|
// AntigravityExecutor proxies requests to the antigravity upstream.
|
||||||
type AntigravityExecutor struct {
|
type AntigravityExecutor struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@@ -142,6 +71,62 @@ func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor {
|
|||||||
return &AntigravityExecutor{cfg: cfg}
|
return &AntigravityExecutor{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// antigravityTransport is a singleton HTTP/1.1 transport shared by all Antigravity requests.
|
||||||
|
// It is initialized once via antigravityTransportOnce to avoid leaking a new connection pool
|
||||||
|
// (and the goroutines managing it) on every request.
|
||||||
|
var (
|
||||||
|
antigravityTransport *http.Transport
|
||||||
|
antigravityTransportOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func cloneTransportWithHTTP11(base *http.Transport) *http.Transport {
|
||||||
|
if base == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := base.Clone()
|
||||||
|
clone.ForceAttemptHTTP2 = false
|
||||||
|
// Wipe TLSNextProto to prevent implicit HTTP/2 upgrade.
|
||||||
|
clone.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
|
||||||
|
if clone.TLSClientConfig == nil {
|
||||||
|
clone.TLSClientConfig = &tls.Config{}
|
||||||
|
} else {
|
||||||
|
clone.TLSClientConfig = clone.TLSClientConfig.Clone()
|
||||||
|
}
|
||||||
|
// Actively advertise only HTTP/1.1 in the ALPN handshake.
|
||||||
|
clone.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// initAntigravityTransport creates the shared HTTP/1.1 transport exactly once.
|
||||||
|
func initAntigravityTransport() {
|
||||||
|
base, ok := http.DefaultTransport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
base = &http.Transport{}
|
||||||
|
}
|
||||||
|
antigravityTransport = cloneTransportWithHTTP11(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAntigravityHTTPClient creates an HTTP client specifically for Antigravity,
|
||||||
|
// enforcing HTTP/1.1 by disabling HTTP/2 to perfectly mimic Node.js https defaults.
|
||||||
|
// The underlying Transport is a singleton to avoid leaking connection pools.
|
||||||
|
func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||||
|
antigravityTransportOnce.Do(initAntigravityTransport)
|
||||||
|
|
||||||
|
client := newProxyAwareHTTPClient(ctx, cfg, auth, timeout)
|
||||||
|
// If no transport is set, use the shared HTTP/1.1 transport.
|
||||||
|
if client.Transport == nil {
|
||||||
|
client.Transport = antigravityTransport
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve proxy settings from proxy-aware transports while forcing HTTP/1.1.
|
||||||
|
if transport, ok := client.Transport.(*http.Transport); ok {
|
||||||
|
client.Transport = cloneTransportWithHTTP11(transport)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
// Identifier returns the executor identifier.
|
// Identifier returns the executor identifier.
|
||||||
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
||||||
|
|
||||||
@@ -162,6 +147,8 @@ func (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyau
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HttpRequest injects Antigravity credentials into the request and executes it.
|
// HttpRequest injects Antigravity credentials into the request and executes it.
|
||||||
|
// It uses a whitelist approach: all incoming headers are stripped and only
|
||||||
|
// the minimum set required by the Antigravity protocol is explicitly set.
|
||||||
func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, fmt.Errorf("antigravity executor: request is nil")
|
return nil, fmt.Errorf("antigravity executor: request is nil")
|
||||||
@@ -170,10 +157,29 @@ func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyaut
|
|||||||
ctx = req.Context()
|
ctx = req.Context()
|
||||||
}
|
}
|
||||||
httpReq := req.WithContext(ctx)
|
httpReq := req.WithContext(ctx)
|
||||||
|
|
||||||
|
// --- Whitelist: save only the headers we need from the original request ---
|
||||||
|
contentType := httpReq.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
// Wipe ALL incoming headers
|
||||||
|
for k := range httpReq.Header {
|
||||||
|
delete(httpReq.Header, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Set only the headers Antigravity actually sends ---
|
||||||
|
if contentType != "" {
|
||||||
|
httpReq.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
// Content-Length is managed automatically by Go's http.Client from the Body
|
||||||
|
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
||||||
|
httpReq.Close = true // sends Connection: close
|
||||||
|
|
||||||
|
// Inject Authorization: Bearer <token>
|
||||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
||||||
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
return httpClient.Do(httpReq)
|
return httpClient.Do(httpReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +191,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
isClaude := strings.Contains(strings.ToLower(baseModel), "claude")
|
isClaude := strings.Contains(strings.ToLower(baseModel), "claude")
|
||||||
|
|
||||||
if isClaude || strings.Contains(baseModel, "gemini-3-pro") {
|
if isClaude || strings.Contains(baseModel, "gemini-3-pro") || strings.Contains(baseModel, "gemini-3.1-flash-image") {
|
||||||
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +226,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
attempts := antigravityRetryAttempts(auth, e.cfg)
|
attempts := antigravityRetryAttempts(auth, e.cfg)
|
||||||
|
|
||||||
@@ -302,7 +308,7 @@ attemptLoop:
|
|||||||
reporter.publish(ctx, parseAntigravityUsage(bodyBytes))
|
reporter.publish(ctx, parseAntigravityUsage(bodyBytes))
|
||||||
var param any
|
var param any
|
||||||
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m)
|
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(converted), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: converted, Headers: httpResp.Header.Clone()}
|
||||||
reporter.ensurePublished(ctx)
|
reporter.ensurePublished(ctx)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -362,7 +368,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
attempts := antigravityRetryAttempts(auth, e.cfg)
|
attempts := antigravityRetryAttempts(auth, e.cfg)
|
||||||
|
|
||||||
@@ -506,7 +512,7 @@ attemptLoop:
|
|||||||
reporter.publish(ctx, parseAntigravityUsage(resp.Payload))
|
reporter.publish(ctx, parseAntigravityUsage(resp.Payload))
|
||||||
var param any
|
var param any
|
||||||
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, resp.Payload, ¶m)
|
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, resp.Payload, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(converted), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: converted, Headers: httpResp.Header.Clone()}
|
||||||
reporter.ensurePublished(ctx)
|
reporter.ensurePublished(ctx)
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -685,31 +691,42 @@ func (e *AntigravityExecutor) convertStreamToNonStream(stream []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
partsJSON, _ := json.Marshal(parts)
|
partsJSON, _ := json.Marshal(parts)
|
||||||
responseTemplate, _ = sjson.SetRaw(responseTemplate, "candidates.0.content.parts", string(partsJSON))
|
updatedTemplate, _ := sjson.SetRawBytes([]byte(responseTemplate), "candidates.0.content.parts", partsJSON)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
if role != "" {
|
if role != "" {
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "candidates.0.content.role", role)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "candidates.0.content.role", role)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
}
|
}
|
||||||
if finishReason != "" {
|
if finishReason != "" {
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "candidates.0.finishReason", finishReason)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "candidates.0.finishReason", finishReason)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
}
|
}
|
||||||
if modelVersion != "" {
|
if modelVersion != "" {
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "modelVersion", modelVersion)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "modelVersion", modelVersion)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
}
|
}
|
||||||
if responseID != "" {
|
if responseID != "" {
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "responseId", responseID)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "responseId", responseID)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
}
|
}
|
||||||
if usageRaw != "" {
|
if usageRaw != "" {
|
||||||
responseTemplate, _ = sjson.SetRaw(responseTemplate, "usageMetadata", usageRaw)
|
updatedTemplate, _ = sjson.SetRawBytes([]byte(responseTemplate), "usageMetadata", []byte(usageRaw))
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
} else if !gjson.Get(responseTemplate, "usageMetadata").Exists() {
|
} else if !gjson.Get(responseTemplate, "usageMetadata").Exists() {
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "usageMetadata.promptTokenCount", 0)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "usageMetadata.promptTokenCount", 0)
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "usageMetadata.candidatesTokenCount", 0)
|
responseTemplate = string(updatedTemplate)
|
||||||
responseTemplate, _ = sjson.Set(responseTemplate, "usageMetadata.totalTokenCount", 0)
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "usageMetadata.candidatesTokenCount", 0)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
|
updatedTemplate, _ = sjson.SetBytes([]byte(responseTemplate), "usageMetadata.totalTokenCount", 0)
|
||||||
|
responseTemplate = string(updatedTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := `{"response":{},"traceId":""}`
|
output := `{"response":{},"traceId":""}`
|
||||||
output, _ = sjson.SetRaw(output, "response", responseTemplate)
|
updatedOutput, _ := sjson.SetRawBytes([]byte(output), "response", []byte(responseTemplate))
|
||||||
|
output = string(updatedOutput)
|
||||||
if traceID != "" {
|
if traceID != "" {
|
||||||
output, _ = sjson.Set(output, "traceId", traceID)
|
updatedOutput, _ = sjson.SetBytes([]byte(output), "traceId", traceID)
|
||||||
|
output = string(updatedOutput)
|
||||||
}
|
}
|
||||||
return []byte(output)
|
return []byte(output)
|
||||||
}
|
}
|
||||||
@@ -754,7 +771,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
attempts := antigravityRetryAttempts(auth, e.cfg)
|
attempts := antigravityRetryAttempts(auth, e.cfg)
|
||||||
|
|
||||||
@@ -874,12 +891,12 @@ attemptLoop:
|
|||||||
|
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), ¶m)
|
tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), ¶m)
|
||||||
for i := range tail {
|
for i := range tail {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(tail[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
@@ -956,7 +973,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
payload = deleteJSONField(payload, "request.safetySettings")
|
payload = deleteJSONField(payload, "request.safetySettings")
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
@@ -987,10 +1004,10 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
return cliproxyexecutor.Response{}, errReq
|
return cliproxyexecutor.Response{}, errReq
|
||||||
}
|
}
|
||||||
|
httpReq.Close = true
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
|
||||||
if host := resolveHost(base); host != "" {
|
if host := resolveHost(base); host != "" {
|
||||||
httpReq.Host = host
|
httpReq.Host = host
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1054,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
|
if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
|
||||||
count := gjson.GetBytes(bodyBytes, "totalTokens").Int()
|
count := gjson.GetBytes(bodyBytes, "totalTokens").Int()
|
||||||
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, bodyBytes)
|
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, bodyBytes)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated), Headers: httpResp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: translated, Headers: httpResp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastStatus = httpResp.StatusCode
|
lastStatus = httpResp.StatusCode
|
||||||
@@ -1072,133 +1089,6 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchAntigravityModels retrieves available models using the supplied auth.
|
|
||||||
func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
|
|
||||||
exec := &AntigravityExecutor{cfg: cfg}
|
|
||||||
token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth)
|
|
||||||
if errToken != nil || token == "" {
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
if updatedAuth != nil {
|
|
||||||
auth = updatedAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0)
|
|
||||||
|
|
||||||
for idx, baseURL := range baseURLs {
|
|
||||||
modelsURL := baseURL + antigravityModelsPath
|
|
||||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader([]byte(`{}`)))
|
|
||||||
if errReq != nil {
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
|
||||||
if host := resolveHost(baseURL); host != "" {
|
|
||||||
httpReq.Host = host
|
|
||||||
}
|
|
||||||
|
|
||||||
httpResp, errDo := httpClient.Do(httpReq)
|
|
||||||
if errDo != nil {
|
|
||||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
if idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, errRead := io.ReadAll(httpResp.Body)
|
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
||||||
log.Errorf("antigravity executor: close response body error: %v", errClose)
|
|
||||||
}
|
|
||||||
if errRead != nil {
|
|
||||||
if idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
|
||||||
if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: models request failed with status %d on base url %s, retrying with fallback base url: %s", httpResp.StatusCode, baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
|
|
||||||
result := gjson.GetBytes(bodyBytes, "models")
|
|
||||||
if !result.Exists() {
|
|
||||||
if idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: models field missing on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
modelConfig := registry.GetAntigravityModelConfig()
|
|
||||||
models := make([]*registry.ModelInfo, 0, len(result.Map()))
|
|
||||||
for originalName, modelData := range result.Map() {
|
|
||||||
modelID := strings.TrimSpace(originalName)
|
|
||||||
if modelID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch modelID {
|
|
||||||
case "chat_20706", "chat_23310", "gemini-2.5-flash-thinking", "gemini-3-pro-low", "gemini-2.5-pro":
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
modelCfg := modelConfig[modelID]
|
|
||||||
|
|
||||||
// Extract displayName from upstream response, fallback to modelID
|
|
||||||
displayName := modelData.Get("displayName").String()
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = modelID
|
|
||||||
}
|
|
||||||
|
|
||||||
modelInfo := ®istry.ModelInfo{
|
|
||||||
ID: modelID,
|
|
||||||
Name: modelID,
|
|
||||||
Description: displayName,
|
|
||||||
DisplayName: displayName,
|
|
||||||
Version: modelID,
|
|
||||||
Object: "model",
|
|
||||||
Created: now,
|
|
||||||
OwnedBy: antigravityAuthType,
|
|
||||||
Type: antigravityAuthType,
|
|
||||||
}
|
|
||||||
// Look up Thinking support from static config using upstream model name.
|
|
||||||
if modelCfg != nil {
|
|
||||||
if modelCfg.Thinking != nil {
|
|
||||||
modelInfo.Thinking = modelCfg.Thinking
|
|
||||||
}
|
|
||||||
if modelCfg.MaxCompletionTokens > 0 {
|
|
||||||
modelInfo.MaxCompletionTokens = modelCfg.MaxCompletionTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
models = append(models, modelInfo)
|
|
||||||
}
|
|
||||||
if len(models) == 0 {
|
|
||||||
if idx+1 < len(baseURLs) {
|
|
||||||
log.Debugf("antigravity executor: empty models list on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Debug("antigravity executor: fetched empty model list; retaining cached primary model list")
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
storeAntigravityPrimaryModels(models)
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
return fallbackAntigravityPrimaryModels()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {
|
func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {
|
||||||
if auth == nil {
|
if auth == nil {
|
||||||
return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
|
return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
|
||||||
@@ -1241,10 +1131,11 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
|
|||||||
return auth, errReq
|
return auth, errReq
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Host", "oauth2.googleapis.com")
|
httpReq.Header.Set("Host", "oauth2.googleapis.com")
|
||||||
httpReq.Header.Set("User-Agent", defaultAntigravityAgent)
|
|
||||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
// Real Antigravity uses Go's default User-Agent for OAuth token refresh
|
||||||
|
httpReq.Header.Set("User-Agent", "Go-http-client/2.0")
|
||||||
|
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
httpResp, errDo := httpClient.Do(httpReq)
|
httpResp, errDo := httpClient.Do(httpReq)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
return auth, errDo
|
return auth, errDo
|
||||||
@@ -1315,7 +1206,7 @@ func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, au
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
|
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
|
||||||
if errFetch != nil {
|
if errFetch != nil {
|
||||||
return errFetch
|
return errFetch
|
||||||
@@ -1369,7 +1260,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
payload = geminiToAntigravity(modelName, payload, projectID)
|
payload = geminiToAntigravity(modelName, payload, projectID)
|
||||||
payload, _ = sjson.SetBytes(payload, "model", modelName)
|
payload, _ = sjson.SetBytes(payload, "model", modelName)
|
||||||
|
|
||||||
useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high")
|
useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro")
|
||||||
payloadStr := string(payload)
|
payloadStr := string(payload)
|
||||||
paths := make([]string, 0)
|
paths := make([]string, 0)
|
||||||
util.Walk(gjson.Parse(payloadStr), "", "parametersJsonSchema", &paths)
|
util.Walk(gjson.Parse(payloadStr), "", "parametersJsonSchema", &paths)
|
||||||
@@ -1383,21 +1274,22 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
payloadStr = util.CleanJSONSchemaForGemini(payloadStr)
|
payloadStr = util.CleanJSONSchemaForGemini(payloadStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if useAntigravitySchema {
|
// if useAntigravitySchema {
|
||||||
systemInstructionPartsResult := gjson.Get(payloadStr, "request.systemInstruction.parts")
|
// systemInstructionPartsResult := gjson.Get(payloadStr, "request.systemInstruction.parts")
|
||||||
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.role", "user")
|
// payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.role", "user")
|
||||||
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.parts.0.text", systemInstruction)
|
// payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.parts.0.text", systemInstruction)
|
||||||
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
|
// payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
|
||||||
|
|
||||||
if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
|
// if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
|
||||||
for _, partResult := range systemInstructionPartsResult.Array() {
|
// for _, partResult := range systemInstructionPartsResult.Array() {
|
||||||
payloadStr, _ = sjson.SetRaw(payloadStr, "request.systemInstruction.parts.-1", partResult.Raw)
|
// payloadStr, _ = sjson.SetRawBytes([]byte(payloadStr), "request.systemInstruction.parts.-1", []byte(partResult.Raw))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
if strings.Contains(modelName, "claude") {
|
if strings.Contains(modelName, "claude") {
|
||||||
payloadStr, _ = sjson.Set(payloadStr, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
||||||
|
payloadStr = string(updated)
|
||||||
} else {
|
} else {
|
||||||
payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens")
|
payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens")
|
||||||
}
|
}
|
||||||
@@ -1406,14 +1298,10 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
return nil, errReq
|
return nil, errReq
|
||||||
}
|
}
|
||||||
|
httpReq.Close = true
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
||||||
if stream {
|
|
||||||
httpReq.Header.Set("Accept", "text/event-stream")
|
|
||||||
} else {
|
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
|
||||||
}
|
|
||||||
if host := resolveHost(base); host != "" {
|
if host := resolveHost(base); host != "" {
|
||||||
httpReq.Host = host
|
httpReq.Host = host
|
||||||
}
|
}
|
||||||
@@ -1623,31 +1511,50 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func geminiToAntigravity(modelName string, payload []byte, projectID string) []byte {
|
func geminiToAntigravity(modelName string, payload []byte, projectID string) []byte {
|
||||||
template, _ := sjson.Set(string(payload), "model", modelName)
|
template := payload
|
||||||
template, _ = sjson.Set(template, "userAgent", "antigravity")
|
template, _ = sjson.SetBytes(template, "model", modelName)
|
||||||
template, _ = sjson.Set(template, "requestType", "agent")
|
template, _ = sjson.SetBytes(template, "userAgent", "antigravity")
|
||||||
|
|
||||||
|
isImageModel := strings.Contains(modelName, "image")
|
||||||
|
|
||||||
|
var reqType string
|
||||||
|
if isImageModel {
|
||||||
|
reqType = "image_gen"
|
||||||
|
} else {
|
||||||
|
reqType = "agent"
|
||||||
|
}
|
||||||
|
template, _ = sjson.SetBytes(template, "requestType", reqType)
|
||||||
|
|
||||||
// Use real project ID from auth if available, otherwise generate random (legacy fallback)
|
// Use real project ID from auth if available, otherwise generate random (legacy fallback)
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
template, _ = sjson.Set(template, "project", projectID)
|
template, _ = sjson.SetBytes(template, "project", projectID)
|
||||||
} else {
|
} else {
|
||||||
template, _ = sjson.Set(template, "project", generateProjectID())
|
template, _ = sjson.SetBytes(template, "project", generateProjectID())
|
||||||
}
|
}
|
||||||
template, _ = sjson.Set(template, "requestId", generateRequestID())
|
|
||||||
template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload))
|
|
||||||
|
|
||||||
template, _ = sjson.Delete(template, "request.safetySettings")
|
if isImageModel {
|
||||||
if toolConfig := gjson.Get(template, "toolConfig"); toolConfig.Exists() && !gjson.Get(template, "request.toolConfig").Exists() {
|
template, _ = sjson.SetBytes(template, "requestId", generateImageGenRequestID())
|
||||||
template, _ = sjson.SetRaw(template, "request.toolConfig", toolConfig.Raw)
|
} else {
|
||||||
template, _ = sjson.Delete(template, "toolConfig")
|
template, _ = sjson.SetBytes(template, "requestId", generateRequestID())
|
||||||
|
template, _ = sjson.SetBytes(template, "request.sessionId", generateStableSessionID(payload))
|
||||||
}
|
}
|
||||||
return []byte(template)
|
|
||||||
|
template, _ = sjson.DeleteBytes(template, "request.safetySettings")
|
||||||
|
if toolConfig := gjson.GetBytes(template, "toolConfig"); toolConfig.Exists() && !gjson.GetBytes(template, "request.toolConfig").Exists() {
|
||||||
|
template, _ = sjson.SetRawBytes(template, "request.toolConfig", []byte(toolConfig.Raw))
|
||||||
|
template, _ = sjson.DeleteBytes(template, "toolConfig")
|
||||||
|
}
|
||||||
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRequestID() string {
|
func generateRequestID() string {
|
||||||
return "agent-" + uuid.NewString()
|
return "agent-" + uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateImageGenRequestID() string {
|
||||||
|
return fmt.Sprintf("image_gen/%d/%s/12", time.Now().UnixMilli(), uuid.NewString())
|
||||||
|
}
|
||||||
|
|
||||||
func generateSessionID() string {
|
func generateSessionID() string {
|
||||||
randSourceMutex.Lock()
|
randSourceMutex.Lock()
|
||||||
n := randSource.Int63n(9_000_000_000_000_000_000)
|
n := randSource.Int63n(9_000_000_000_000_000_000)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any
|
|||||||
"properties": {
|
"properties": {
|
||||||
"mode": {
|
"mode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"deprecated": true,
|
||||||
"enum": ["a", "b"],
|
"enum": ["a", "b"],
|
||||||
"enumTitles": ["A", "B"]
|
"enumTitles": ["A", "B"]
|
||||||
}
|
}
|
||||||
@@ -156,4 +157,7 @@ func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]a
|
|||||||
if _, ok := mode["enumTitles"]; ok {
|
if _, ok := mode["enumTitles"]; ok {
|
||||||
t.Fatalf("enumTitles should be removed from nested schema")
|
t.Fatalf("enumTitles should be removed from nested schema")
|
||||||
}
|
}
|
||||||
|
if _, ok := mode["deprecated"]; ok {
|
||||||
|
t.Fatalf("deprecated should be removed from nested schema")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package executor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
func resetAntigravityPrimaryModelsCacheForTest() {
|
|
||||||
antigravityPrimaryModelsCache.mu.Lock()
|
|
||||||
antigravityPrimaryModelsCache.models = nil
|
|
||||||
antigravityPrimaryModelsCache.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreAntigravityPrimaryModels_EmptyDoesNotOverwrite(t *testing.T) {
|
|
||||||
resetAntigravityPrimaryModelsCacheForTest()
|
|
||||||
t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)
|
|
||||||
|
|
||||||
seed := []*registry.ModelInfo{
|
|
||||||
{ID: "claude-sonnet-4-5"},
|
|
||||||
{ID: "gemini-2.5-pro"},
|
|
||||||
}
|
|
||||||
if updated := storeAntigravityPrimaryModels(seed); !updated {
|
|
||||||
t.Fatal("expected non-empty model list to update primary cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated := storeAntigravityPrimaryModels(nil); updated {
|
|
||||||
t.Fatal("expected nil model list not to overwrite primary cache")
|
|
||||||
}
|
|
||||||
if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{}); updated {
|
|
||||||
t.Fatal("expected empty model list not to overwrite primary cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
got := loadAntigravityPrimaryModels()
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("expected cached model count 2, got %d", len(got))
|
|
||||||
}
|
|
||||||
if got[0].ID != "claude-sonnet-4-5" || got[1].ID != "gemini-2.5-pro" {
|
|
||||||
t.Fatalf("unexpected cached model ids: %q, %q", got[0].ID, got[1].ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAntigravityPrimaryModels_ReturnsClone(t *testing.T) {
|
|
||||||
resetAntigravityPrimaryModelsCacheForTest()
|
|
||||||
t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)
|
|
||||||
|
|
||||||
if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{{
|
|
||||||
ID: "gpt-5",
|
|
||||||
DisplayName: "GPT-5",
|
|
||||||
SupportedGenerationMethods: []string{"generateContent"},
|
|
||||||
SupportedParameters: []string{"temperature"},
|
|
||||||
Thinking: ®istry.ThinkingSupport{
|
|
||||||
Levels: []string{"high"},
|
|
||||||
},
|
|
||||||
}}); !updated {
|
|
||||||
t.Fatal("expected model cache update")
|
|
||||||
}
|
|
||||||
|
|
||||||
got := loadAntigravityPrimaryModels()
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected one cached model, got %d", len(got))
|
|
||||||
}
|
|
||||||
got[0].ID = "mutated-id"
|
|
||||||
if len(got[0].SupportedGenerationMethods) > 0 {
|
|
||||||
got[0].SupportedGenerationMethods[0] = "mutated-method"
|
|
||||||
}
|
|
||||||
if len(got[0].SupportedParameters) > 0 {
|
|
||||||
got[0].SupportedParameters[0] = "mutated-parameter"
|
|
||||||
}
|
|
||||||
if got[0].Thinking != nil && len(got[0].Thinking.Levels) > 0 {
|
|
||||||
got[0].Thinking.Levels[0] = "mutated-level"
|
|
||||||
}
|
|
||||||
|
|
||||||
again := loadAntigravityPrimaryModels()
|
|
||||||
if len(again) != 1 {
|
|
||||||
t.Fatalf("expected one cached model after mutation, got %d", len(again))
|
|
||||||
}
|
|
||||||
if again[0].ID != "gpt-5" {
|
|
||||||
t.Fatalf("expected cached model id to remain %q, got %q", "gpt-5", again[0].ID)
|
|
||||||
}
|
|
||||||
if len(again[0].SupportedGenerationMethods) == 0 || again[0].SupportedGenerationMethods[0] != "generateContent" {
|
|
||||||
t.Fatalf("expected cached generation methods to be unmutated, got %v", again[0].SupportedGenerationMethods)
|
|
||||||
}
|
|
||||||
if len(again[0].SupportedParameters) == 0 || again[0].SupportedParameters[0] != "temperature" {
|
|
||||||
t.Fatalf("expected cached supported parameters to be unmutated, got %v", again[0].SupportedParameters)
|
|
||||||
}
|
|
||||||
if again[0].Thinking == nil || len(again[0].Thinking.Levels) == 0 || again[0].Thinking.Levels[0] != "high" {
|
|
||||||
t.Fatalf("expected cached model thinking levels to be unmutated, got %v", again[0].Thinking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
383
internal/runtime/executor/claude_device_profile.go
Normal file
383
internal/runtime/executor/claude_device_profile.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultClaudeFingerprintUserAgent = "claude-cli/2.1.63 (external, cli)"
|
||||||
|
defaultClaudeFingerprintPackageVersion = "0.74.0"
|
||||||
|
defaultClaudeFingerprintRuntimeVersion = "v24.3.0"
|
||||||
|
defaultClaudeFingerprintOS = "MacOS"
|
||||||
|
defaultClaudeFingerprintArch = "arm64"
|
||||||
|
claudeDeviceProfileTTL = 7 * 24 * time.Hour
|
||||||
|
claudeDeviceProfileCleanupPeriod = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
claudeCLIVersionPattern = regexp.MustCompile(`^claude-cli/(\d+)\.(\d+)\.(\d+)`)
|
||||||
|
|
||||||
|
claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry)
|
||||||
|
claudeDeviceProfileCacheMu sync.RWMutex
|
||||||
|
claudeDeviceProfileCacheCleanupOnce sync.Once
|
||||||
|
|
||||||
|
claudeDeviceProfileBeforeCandidateStore func(claudeDeviceProfile)
|
||||||
|
)
|
||||||
|
|
||||||
|
type claudeCLIVersion struct {
|
||||||
|
major int
|
||||||
|
minor int
|
||||||
|
patch int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v claudeCLIVersion) Compare(other claudeCLIVersion) int {
|
||||||
|
switch {
|
||||||
|
case v.major != other.major:
|
||||||
|
if v.major > other.major {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
case v.minor != other.minor:
|
||||||
|
if v.minor > other.minor {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
case v.patch != other.patch:
|
||||||
|
if v.patch > other.patch {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDeviceProfile struct {
|
||||||
|
UserAgent string
|
||||||
|
PackageVersion string
|
||||||
|
RuntimeVersion string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
Version claudeCLIVersion
|
||||||
|
HasVersion bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDeviceProfileCacheEntry struct {
|
||||||
|
profile claudeDeviceProfile
|
||||||
|
expire time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDeviceProfileStabilizationEnabled(cfg *config.Config) bool {
|
||||||
|
if cfg == nil || cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDeviceProfile(cfg *config.Config) claudeDeviceProfile {
|
||||||
|
hdrDefault := func(cfgVal, fallback string) string {
|
||||||
|
if strings.TrimSpace(cfgVal) != "" {
|
||||||
|
return strings.TrimSpace(cfgVal)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var hd config.ClaudeHeaderDefaults
|
||||||
|
if cfg != nil {
|
||||||
|
hd = cfg.ClaudeHeaderDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := claudeDeviceProfile{
|
||||||
|
UserAgent: hdrDefault(hd.UserAgent, defaultClaudeFingerprintUserAgent),
|
||||||
|
PackageVersion: hdrDefault(hd.PackageVersion, defaultClaudeFingerprintPackageVersion),
|
||||||
|
RuntimeVersion: hdrDefault(hd.RuntimeVersion, defaultClaudeFingerprintRuntimeVersion),
|
||||||
|
OS: hdrDefault(hd.OS, defaultClaudeFingerprintOS),
|
||||||
|
Arch: hdrDefault(hd.Arch, defaultClaudeFingerprintArch),
|
||||||
|
}
|
||||||
|
if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok {
|
||||||
|
profile.Version = version
|
||||||
|
profile.HasVersion = true
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapStainlessOS maps runtime.GOOS to Stainless SDK OS names.
|
||||||
|
func mapStainlessOS() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return "MacOS"
|
||||||
|
case "windows":
|
||||||
|
return "Windows"
|
||||||
|
case "linux":
|
||||||
|
return "Linux"
|
||||||
|
case "freebsd":
|
||||||
|
return "FreeBSD"
|
||||||
|
default:
|
||||||
|
return "Other::" + runtime.GOOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapStainlessArch maps runtime.GOARCH to Stainless SDK architecture names.
|
||||||
|
func mapStainlessArch() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "amd64":
|
||||||
|
return "x64"
|
||||||
|
case "arm64":
|
||||||
|
return "arm64"
|
||||||
|
case "386":
|
||||||
|
return "x86"
|
||||||
|
default:
|
||||||
|
return "other::" + runtime.GOARCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClaudeCLIVersion(userAgent string) (claudeCLIVersion, bool) {
|
||||||
|
matches := claudeCLIVersionPattern.FindStringSubmatch(strings.TrimSpace(userAgent))
|
||||||
|
if len(matches) != 4 {
|
||||||
|
return claudeCLIVersion{}, false
|
||||||
|
}
|
||||||
|
major, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return claudeCLIVersion{}, false
|
||||||
|
}
|
||||||
|
minor, err := strconv.Atoi(matches[2])
|
||||||
|
if err != nil {
|
||||||
|
return claudeCLIVersion{}, false
|
||||||
|
}
|
||||||
|
patch, err := strconv.Atoi(matches[3])
|
||||||
|
if err != nil {
|
||||||
|
return claudeCLIVersion{}, false
|
||||||
|
}
|
||||||
|
return claudeCLIVersion{major: major, minor: minor, patch: patch}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUpgradeClaudeDeviceProfile(candidate, current claudeDeviceProfile) bool {
|
||||||
|
if candidate.UserAgent == "" || !candidate.HasVersion {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if current.UserAgent == "" || !current.HasVersion {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return candidate.Version.Compare(current.Version) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinClaudeDeviceProfilePlatform(profile, baseline claudeDeviceProfile) claudeDeviceProfile {
|
||||||
|
profile.OS = baseline.OS
|
||||||
|
profile.Arch = baseline.Arch
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeClaudeDeviceProfile keeps stabilized profiles pinned to the current
|
||||||
|
// baseline platform and enforces the baseline software fingerprint as a floor.
|
||||||
|
func normalizeClaudeDeviceProfile(profile, baseline claudeDeviceProfile) claudeDeviceProfile {
|
||||||
|
profile = pinClaudeDeviceProfilePlatform(profile, baseline)
|
||||||
|
if profile.UserAgent == "" || !profile.HasVersion || shouldUpgradeClaudeDeviceProfile(baseline, profile) {
|
||||||
|
profile.UserAgent = baseline.UserAgent
|
||||||
|
profile.PackageVersion = baseline.PackageVersion
|
||||||
|
profile.RuntimeVersion = baseline.RuntimeVersion
|
||||||
|
profile.Version = baseline.Version
|
||||||
|
profile.HasVersion = baseline.HasVersion
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) {
|
||||||
|
if headers == nil {
|
||||||
|
return claudeDeviceProfile{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent := strings.TrimSpace(headers.Get("User-Agent"))
|
||||||
|
version, ok := parseClaudeCLIVersion(userAgent)
|
||||||
|
if !ok {
|
||||||
|
return claudeDeviceProfile{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
baseline := defaultClaudeDeviceProfile(cfg)
|
||||||
|
profile := claudeDeviceProfile{
|
||||||
|
UserAgent: userAgent,
|
||||||
|
PackageVersion: firstNonEmptyHeader(headers, "X-Stainless-Package-Version", baseline.PackageVersion),
|
||||||
|
RuntimeVersion: firstNonEmptyHeader(headers, "X-Stainless-Runtime-Version", baseline.RuntimeVersion),
|
||||||
|
OS: firstNonEmptyHeader(headers, "X-Stainless-Os", baseline.OS),
|
||||||
|
Arch: firstNonEmptyHeader(headers, "X-Stainless-Arch", baseline.Arch),
|
||||||
|
Version: version,
|
||||||
|
HasVersion: true,
|
||||||
|
}
|
||||||
|
return profile, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyHeader(headers http.Header, name, fallback string) string {
|
||||||
|
if headers == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDeviceProfileScopeKey(auth *cliproxyauth.Auth, apiKey string) string {
|
||||||
|
switch {
|
||||||
|
case auth != nil && strings.TrimSpace(auth.ID) != "":
|
||||||
|
return "auth:" + strings.TrimSpace(auth.ID)
|
||||||
|
case strings.TrimSpace(apiKey) != "":
|
||||||
|
return "api_key:" + strings.TrimSpace(apiKey)
|
||||||
|
default:
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDeviceProfileCacheKey(auth *cliproxyauth.Auth, apiKey string) string {
|
||||||
|
sum := sha256.Sum256([]byte(claudeDeviceProfileScopeKey(auth, apiKey)))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func startClaudeDeviceProfileCacheCleanup() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(claudeDeviceProfileCleanupPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
purgeExpiredClaudeDeviceProfiles()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeExpiredClaudeDeviceProfiles() {
|
||||||
|
now := time.Now()
|
||||||
|
claudeDeviceProfileCacheMu.Lock()
|
||||||
|
for key, entry := range claudeDeviceProfileCache {
|
||||||
|
if !entry.expire.After(now) {
|
||||||
|
delete(claudeDeviceProfileCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeDeviceProfileCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers http.Header, cfg *config.Config) claudeDeviceProfile {
|
||||||
|
claudeDeviceProfileCacheCleanupOnce.Do(startClaudeDeviceProfileCacheCleanup)
|
||||||
|
|
||||||
|
cacheKey := claudeDeviceProfileCacheKey(auth, apiKey)
|
||||||
|
now := time.Now()
|
||||||
|
baseline := defaultClaudeDeviceProfile(cfg)
|
||||||
|
candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg)
|
||||||
|
if hasCandidate {
|
||||||
|
candidate = pinClaudeDeviceProfilePlatform(candidate, baseline)
|
||||||
|
}
|
||||||
|
if hasCandidate && !shouldUpgradeClaudeDeviceProfile(candidate, baseline) {
|
||||||
|
hasCandidate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeDeviceProfileCacheMu.RLock()
|
||||||
|
entry, hasCached := claudeDeviceProfileCache[cacheKey]
|
||||||
|
cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
|
||||||
|
claudeDeviceProfileCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if hasCandidate {
|
||||||
|
if claudeDeviceProfileBeforeCandidateStore != nil {
|
||||||
|
claudeDeviceProfileBeforeCandidateStore(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeDeviceProfileCacheMu.Lock()
|
||||||
|
entry, hasCached = claudeDeviceProfileCache[cacheKey]
|
||||||
|
cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
|
||||||
|
if cachedValid {
|
||||||
|
entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline)
|
||||||
|
}
|
||||||
|
if cachedValid && !shouldUpgradeClaudeDeviceProfile(candidate, entry.profile) {
|
||||||
|
entry.expire = now.Add(claudeDeviceProfileTTL)
|
||||||
|
claudeDeviceProfileCache[cacheKey] = entry
|
||||||
|
claudeDeviceProfileCacheMu.Unlock()
|
||||||
|
return entry.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeDeviceProfileCache[cacheKey] = claudeDeviceProfileCacheEntry{
|
||||||
|
profile: candidate,
|
||||||
|
expire: now.Add(claudeDeviceProfileTTL),
|
||||||
|
}
|
||||||
|
claudeDeviceProfileCacheMu.Unlock()
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
if cachedValid {
|
||||||
|
claudeDeviceProfileCacheMu.Lock()
|
||||||
|
entry = claudeDeviceProfileCache[cacheKey]
|
||||||
|
if entry.expire.After(now) && entry.profile.UserAgent != "" {
|
||||||
|
entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline)
|
||||||
|
entry.expire = now.Add(claudeDeviceProfileTTL)
|
||||||
|
claudeDeviceProfileCache[cacheKey] = entry
|
||||||
|
claudeDeviceProfileCacheMu.Unlock()
|
||||||
|
return entry.profile
|
||||||
|
}
|
||||||
|
claudeDeviceProfileCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClaudeDeviceProfileHeaders(r *http.Request, profile claudeDeviceProfile) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, headerName := range []string{
|
||||||
|
"User-Agent",
|
||||||
|
"X-Stainless-Package-Version",
|
||||||
|
"X-Stainless-Runtime-Version",
|
||||||
|
"X-Stainless-Os",
|
||||||
|
"X-Stainless-Arch",
|
||||||
|
} {
|
||||||
|
r.Header.Del(headerName)
|
||||||
|
}
|
||||||
|
r.Header.Set("User-Agent", profile.UserAgent)
|
||||||
|
r.Header.Set("X-Stainless-Package-Version", profile.PackageVersion)
|
||||||
|
r.Header.Set("X-Stainless-Runtime-Version", profile.RuntimeVersion)
|
||||||
|
r.Header.Set("X-Stainless-Os", profile.OS)
|
||||||
|
r.Header.Set("X-Stainless-Arch", profile.Arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile := defaultClaudeDeviceProfile(cfg)
|
||||||
|
miscEnsure := func(name, fallback string) {
|
||||||
|
if strings.TrimSpace(r.Header.Get(name)) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ginHeaders.Get(name)) != "" {
|
||||||
|
r.Header.Set(name, strings.TrimSpace(ginHeaders.Get(name)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Set(name, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion)
|
||||||
|
miscEnsure("X-Stainless-Package-Version", profile.PackageVersion)
|
||||||
|
miscEnsure("X-Stainless-Os", mapStainlessOS())
|
||||||
|
miscEnsure("X-Stainless-Arch", mapStainlessArch())
|
||||||
|
|
||||||
|
// Legacy mode preserves per-auth custom header overrides. By the time we get
|
||||||
|
// here, ApplyCustomHeadersFromAttrs has already populated r.Header.
|
||||||
|
if strings.TrimSpace(r.Header.Get("User-Agent")) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientUA := ""
|
||||||
|
if ginHeaders != nil {
|
||||||
|
clientUA = strings.TrimSpace(ginHeaders.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
if isClaudeCodeClient(clientUA) {
|
||||||
|
r.Header.Set("User-Agent", clientUA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Set("User-Agent", profile.UserAgent)
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -135,6 +136,15 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
body = ensureCacheControl(body)
|
body = ensureCacheControl(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
|
||||||
|
// Cloaking and ensureCacheControl may push the total over 4 when the client
|
||||||
|
// (e.g. Amp CLI) already sends multiple cache_control blocks.
|
||||||
|
body = enforceCacheControlLimit(body, 4)
|
||||||
|
|
||||||
|
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
|
||||||
|
// A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages).
|
||||||
|
body = normalizeCacheControlTTL(body)
|
||||||
|
|
||||||
// Extract betas from body and convert to header
|
// Extract betas from body and convert to header
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
extraBetas, body = extractAndRemoveBetas(body)
|
extraBetas, body = extractAndRemoveBetas(body)
|
||||||
@@ -176,11 +186,27 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
}
|
}
|
||||||
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
b, _ := io.ReadAll(httpResp.Body)
|
// Decompress error responses — pass the Content-Encoding value (may be empty)
|
||||||
|
// and let decodeResponseBody handle both header-declared and magic-byte-detected
|
||||||
|
// compression. This keeps error-path behaviour consistent with the success path.
|
||||||
|
errBody, decErr := decodeResponseBody(httpResp.Body, httpResp.Header.Get("Content-Encoding"))
|
||||||
|
if decErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, decErr)
|
||||||
|
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
return resp, statusErr{code: httpResp.StatusCode, msg: msg}
|
||||||
|
}
|
||||||
|
b, readErr := io.ReadAll(errBody)
|
||||||
|
if readErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, readErr)
|
||||||
|
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
b = []byte(msg)
|
||||||
|
}
|
||||||
appendAPIResponseChunk(ctx, e.cfg, b)
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
if errClose := errBody.Close(); errClose != nil {
|
||||||
log.Errorf("response body close error: %v", errClose)
|
log.Errorf("response body close error: %v", errClose)
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
@@ -228,7 +254,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
data,
|
data,
|
||||||
¶m,
|
¶m,
|
||||||
)
|
)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +302,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
body = ensureCacheControl(body)
|
body = ensureCacheControl(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
|
||||||
|
body = enforceCacheControlLimit(body, 4)
|
||||||
|
|
||||||
|
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
|
||||||
|
body = normalizeCacheControlTTL(body)
|
||||||
|
|
||||||
// Extract betas from body and convert to header
|
// Extract betas from body and convert to header
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
extraBetas, body = extractAndRemoveBetas(body)
|
extraBetas, body = extractAndRemoveBetas(body)
|
||||||
@@ -317,10 +349,26 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
b, _ := io.ReadAll(httpResp.Body)
|
// Decompress error responses — pass the Content-Encoding value (may be empty)
|
||||||
|
// and let decodeResponseBody handle both header-declared and magic-byte-detected
|
||||||
|
// compression. This keeps error-path behaviour consistent with the success path.
|
||||||
|
errBody, decErr := decodeResponseBody(httpResp.Body, httpResp.Header.Get("Content-Encoding"))
|
||||||
|
if decErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, decErr)
|
||||||
|
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
return nil, statusErr{code: httpResp.StatusCode, msg: msg}
|
||||||
|
}
|
||||||
|
b, readErr := io.ReadAll(errBody)
|
||||||
|
if readErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, readErr)
|
||||||
|
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
b = []byte(msg)
|
||||||
|
}
|
||||||
appendAPIResponseChunk(ctx, e.cfg, b)
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
if errClose := errBody.Close(); errClose != nil {
|
||||||
log.Errorf("response body close error: %v", errClose)
|
log.Errorf("response body close error: %v", errClose)
|
||||||
}
|
}
|
||||||
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
@@ -394,7 +442,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
¶m,
|
¶m,
|
||||||
)
|
)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
@@ -425,6 +473,10 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
body = checkSystemInstructions(body)
|
body = checkSystemInstructions(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
|
||||||
|
body = enforceCacheControlLimit(body, 4)
|
||||||
|
body = normalizeCacheControlTTL(body)
|
||||||
|
|
||||||
// Extract betas from body and convert to header (for count_tokens too)
|
// Extract betas from body and convert to header (for count_tokens too)
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
extraBetas, body = extractAndRemoveBetas(body)
|
extraBetas, body = extractAndRemoveBetas(body)
|
||||||
@@ -464,9 +516,25 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
}
|
}
|
||||||
recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())
|
recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
b, _ := io.ReadAll(resp.Body)
|
// Decompress error responses — pass the Content-Encoding value (may be empty)
|
||||||
|
// and let decodeResponseBody handle both header-declared and magic-byte-detected
|
||||||
|
// compression. This keeps error-path behaviour consistent with the success path.
|
||||||
|
errBody, decErr := decodeResponseBody(resp.Body, resp.Header.Get("Content-Encoding"))
|
||||||
|
if decErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, decErr)
|
||||||
|
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: msg}
|
||||||
|
}
|
||||||
|
b, readErr := io.ReadAll(errBody)
|
||||||
|
if readErr != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, readErr)
|
||||||
|
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
|
||||||
|
logWithRequestID(ctx).Warn(msg)
|
||||||
|
b = []byte(msg)
|
||||||
|
}
|
||||||
appendAPIResponseChunk(ctx, e.cfg, b)
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
if errClose := resp.Body.Close(); errClose != nil {
|
if errClose := errBody.Close(); errClose != nil {
|
||||||
log.Errorf("response body close error: %v", errClose)
|
log.Errorf("response body close error: %v", errClose)
|
||||||
}
|
}
|
||||||
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
|
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
|
||||||
@@ -492,7 +560,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
count := gjson.GetBytes(data, "input_tokens").Int()
|
count := gjson.GetBytes(data, "input_tokens").Int()
|
||||||
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(out), Headers: resp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: out, Headers: resp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||||
@@ -559,6 +627,12 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte {
|
|||||||
if toolChoiceType == "any" || toolChoiceType == "tool" {
|
if toolChoiceType == "any" || toolChoiceType == "tool" {
|
||||||
// Remove thinking configuration entirely to avoid API error
|
// Remove thinking configuration entirely to avoid API error
|
||||||
body, _ = sjson.DeleteBytes(body, "thinking")
|
body, _ = sjson.DeleteBytes(body, "thinking")
|
||||||
|
// Adaptive thinking may also set output_config.effort; remove it to avoid
|
||||||
|
// leaking thinking controls when tool_choice forces tool use.
|
||||||
|
body, _ = sjson.DeleteBytes(body, "output_config.effort")
|
||||||
|
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||||
|
body, _ = sjson.DeleteBytes(body, "output_config")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -581,12 +655,61 @@ func (c *compositeReadCloser) Close() error {
|
|||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peekableBody wraps a bufio.Reader around the original ReadCloser so that
|
||||||
|
// magic bytes can be inspected without consuming them from the stream.
|
||||||
|
type peekableBody struct {
|
||||||
|
*bufio.Reader
|
||||||
|
closer io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *peekableBody) Close() error {
|
||||||
|
return p.closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {
|
func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {
|
||||||
if body == nil {
|
if body == nil {
|
||||||
return nil, fmt.Errorf("response body is nil")
|
return nil, fmt.Errorf("response body is nil")
|
||||||
}
|
}
|
||||||
if contentEncoding == "" {
|
if contentEncoding == "" {
|
||||||
return body, nil
|
// No Content-Encoding header. Attempt best-effort magic-byte detection to
|
||||||
|
// handle misbehaving upstreams that compress without setting the header.
|
||||||
|
// Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences;
|
||||||
|
// br and deflate have none and are left as-is.
|
||||||
|
// The bufio wrapper preserves unread bytes so callers always see the full
|
||||||
|
// stream regardless of whether decompression was applied.
|
||||||
|
pb := &peekableBody{Reader: bufio.NewReader(body), closer: body}
|
||||||
|
magic, peekErr := pb.Peek(4)
|
||||||
|
if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) {
|
||||||
|
switch {
|
||||||
|
case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b:
|
||||||
|
gzipReader, gzErr := gzip.NewReader(pb)
|
||||||
|
if gzErr != nil {
|
||||||
|
_ = pb.Close()
|
||||||
|
return nil, fmt.Errorf("magic-byte gzip: failed to create reader: %w", gzErr)
|
||||||
|
}
|
||||||
|
return &compositeReadCloser{
|
||||||
|
Reader: gzipReader,
|
||||||
|
closers: []func() error{
|
||||||
|
gzipReader.Close,
|
||||||
|
pb.Close,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd:
|
||||||
|
decoder, zdErr := zstd.NewReader(pb)
|
||||||
|
if zdErr != nil {
|
||||||
|
_ = pb.Close()
|
||||||
|
return nil, fmt.Errorf("magic-byte zstd: failed to create reader: %w", zdErr)
|
||||||
|
}
|
||||||
|
return &compositeReadCloser{
|
||||||
|
Reader: decoder,
|
||||||
|
closers: []func() error{
|
||||||
|
func() error { decoder.Close(); return nil },
|
||||||
|
pb.Close,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pb, nil
|
||||||
}
|
}
|
||||||
encodings := strings.Split(contentEncoding, ",")
|
encodings := strings.Split(contentEncoding, ",")
|
||||||
for _, raw := range encodings {
|
for _, raw := range encodings {
|
||||||
@@ -643,36 +766,6 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapStainlessOS maps runtime.GOOS to Stainless SDK OS names.
|
|
||||||
func mapStainlessOS() string {
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
return "MacOS"
|
|
||||||
case "windows":
|
|
||||||
return "Windows"
|
|
||||||
case "linux":
|
|
||||||
return "Linux"
|
|
||||||
case "freebsd":
|
|
||||||
return "FreeBSD"
|
|
||||||
default:
|
|
||||||
return "Other::" + runtime.GOOS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapStainlessArch maps runtime.GOARCH to Stainless SDK architecture names.
|
|
||||||
func mapStainlessArch() string {
|
|
||||||
switch runtime.GOARCH {
|
|
||||||
case "amd64":
|
|
||||||
return "x64"
|
|
||||||
case "arm64":
|
|
||||||
return "arm64"
|
|
||||||
case "386":
|
|
||||||
return "x86"
|
|
||||||
default:
|
|
||||||
return "other::" + runtime.GOARCH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config) {
|
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config) {
|
||||||
hdrDefault := func(cfgVal, fallback string) string {
|
hdrDefault := func(cfgVal, fallback string) string {
|
||||||
if cfgVal != "" {
|
if cfgVal != "" {
|
||||||
@@ -700,6 +793,11 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||||
ginHeaders = ginCtx.Request.Header
|
ginHeaders = ginCtx.Request.Header
|
||||||
}
|
}
|
||||||
|
stabilizeDeviceProfile := claudeDeviceProfileStabilizationEnabled(cfg)
|
||||||
|
var deviceProfile claudeDeviceProfile
|
||||||
|
if stabilizeDeviceProfile {
|
||||||
|
deviceProfile = resolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05"
|
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05"
|
||||||
if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" {
|
if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" {
|
||||||
@@ -709,11 +807,21 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge extra betas from request body
|
hasClaude1MHeader := false
|
||||||
if len(extraBetas) > 0 {
|
if ginHeaders != nil {
|
||||||
|
if _, ok := ginHeaders[textproto.CanonicalMIMEHeaderKey("X-CPA-CLAUDE-1M")]; ok {
|
||||||
|
hasClaude1MHeader = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge extra betas from request body and request flags.
|
||||||
|
if len(extraBetas) > 0 || hasClaude1MHeader {
|
||||||
existingSet := make(map[string]bool)
|
existingSet := make(map[string]bool)
|
||||||
for _, b := range strings.Split(baseBetas, ",") {
|
for _, b := range strings.Split(baseBetas, ",") {
|
||||||
existingSet[strings.TrimSpace(b)] = true
|
betaName := strings.TrimSpace(b)
|
||||||
|
if betaName != "" {
|
||||||
|
existingSet[betaName] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, beta := range extraBetas {
|
for _, beta := range extraBetas {
|
||||||
beta = strings.TrimSpace(beta)
|
beta = strings.TrimSpace(beta)
|
||||||
@@ -722,6 +830,9 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
existingSet[beta] = true
|
existingSet[beta] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hasClaude1MHeader && !existingSet["context-1m-2025-08-07"] {
|
||||||
|
baseBetas += ",context-1m-2025-08-07"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
r.Header.Set("Anthropic-Beta", baseBetas)
|
r.Header.Set("Anthropic-Beta", baseBetas)
|
||||||
|
|
||||||
@@ -730,39 +841,39 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
||||||
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
|
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime-Version", hdrDefault(hd.RuntimeVersion, "v24.3.0"))
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Package-Version", hdrDefault(hd.PackageVersion, "0.74.0"))
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Arch", mapStainlessArch())
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Os", mapStainlessOS())
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
||||||
// For User-Agent, only forward the client's header if it's already a Claude Code client.
|
|
||||||
// Non-Claude-Code clients (e.g. curl, OpenAI SDKs) get the default Claude Code User-Agent
|
|
||||||
// to avoid leaking the real client identity during cloaking.
|
|
||||||
clientUA := ""
|
|
||||||
if ginHeaders != nil {
|
|
||||||
clientUA = ginHeaders.Get("User-Agent")
|
|
||||||
}
|
|
||||||
if isClaudeCodeClient(clientUA) {
|
|
||||||
r.Header.Set("User-Agent", clientUA)
|
|
||||||
} else {
|
|
||||||
r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, "claude-cli/2.1.63 (external, cli)"))
|
|
||||||
}
|
|
||||||
r.Header.Set("Connection", "keep-alive")
|
r.Header.Set("Connection", "keep-alive")
|
||||||
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
|
||||||
if stream {
|
if stream {
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
|
// SSE streams must not be compressed: the downstream scanner reads
|
||||||
|
// line-delimited text and cannot parse compressed bytes. Using
|
||||||
|
// "identity" tells the upstream to send an uncompressed stream.
|
||||||
|
r.Header.Set("Accept-Encoding", "identity")
|
||||||
} else {
|
} else {
|
||||||
r.Header.Set("Accept", "application/json")
|
r.Header.Set("Accept", "application/json")
|
||||||
|
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
||||||
}
|
}
|
||||||
// Keep OS/Arch mapping dynamic (not configurable).
|
// Legacy mode keeps OS/Arch runtime-derived; stabilized mode pins OS/Arch
|
||||||
// They intentionally continue to derive from runtime.GOOS/runtime.GOARCH.
|
// to the configured baseline while still allowing newer official
|
||||||
|
// User-Agent/package/runtime tuples to upgrade the software fingerprint.
|
||||||
var attrs map[string]string
|
var attrs map[string]string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
attrs = auth.Attributes
|
attrs = auth.Attributes
|
||||||
}
|
}
|
||||||
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
||||||
|
if stabilizeDeviceProfile {
|
||||||
|
applyClaudeDeviceProfileHeaders(r, deviceProfile)
|
||||||
|
} else {
|
||||||
|
applyClaudeLegacyDeviceHeaders(r, ginHeaders, cfg)
|
||||||
|
}
|
||||||
|
// Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which
|
||||||
|
// may override it with a user-configured value. Compressed SSE breaks the line
|
||||||
|
// scanner regardless of user preference, so this is non-negotiable for streams.
|
||||||
|
if stream {
|
||||||
|
r.Header.Set("Accept-Encoding", "identity")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
||||||
@@ -1073,17 +1184,22 @@ func generateBillingHeader(payload []byte) string {
|
|||||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
|
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSystemInstructionsWithMode injects Claude Code system prompt to match
|
// checkSystemInstructionsWithMode injects Claude Code-style system blocks:
|
||||||
// the real Claude Code request format:
|
//
|
||||||
// system[0]: billing header (no cache_control)
|
// system[0]: billing header (no cache_control)
|
||||||
// system[1]: "You are a Claude agent, built on Anthropic's Claude Agent SDK." (with cache_control)
|
// system[1]: agent identifier (no cache_control)
|
||||||
// system[2..]: user's system messages (with cache_control on last)
|
// system[2..]: user system messages (cache_control added when missing)
|
||||||
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
||||||
system := gjson.GetBytes(payload, "system")
|
system := gjson.GetBytes(payload, "system")
|
||||||
|
|
||||||
billingText := generateBillingHeader(payload)
|
billingText := generateBillingHeader(payload)
|
||||||
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
||||||
agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral","ttl":"1h"}}`
|
// No cache_control on the agent block. It is a cloaking artifact with zero cache
|
||||||
|
// value (the last system block is what actually triggers caching of all system content).
|
||||||
|
// Including any cache_control here creates an intra-system TTL ordering violation
|
||||||
|
// when the client's system blocks use ttl='1h' (prompt-caching-scope-2026-01-05 beta
|
||||||
|
// forbids 1h blocks after 5m blocks, and a no-TTL block defaults to 5m).
|
||||||
|
agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK."}`
|
||||||
|
|
||||||
if strictMode {
|
if strictMode {
|
||||||
// Strict mode: billing header + agent identifier only
|
// Strict mode: billing header + agent identifier only
|
||||||
@@ -1103,16 +1219,23 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
|||||||
if system.IsArray() {
|
if system.IsArray() {
|
||||||
system.ForEach(func(_, part gjson.Result) bool {
|
system.ForEach(func(_, part gjson.Result) bool {
|
||||||
if part.Get("type").String() == "text" {
|
if part.Get("type").String() == "text" {
|
||||||
// Add cache_control with ttl to user system messages if not present
|
// Add cache_control to user system messages if not present.
|
||||||
|
// Do NOT add ttl — let it inherit the default (5m) to avoid
|
||||||
|
// TTL ordering violations with the prompt-caching-scope-2026-01-05 beta.
|
||||||
partJSON := part.Raw
|
partJSON := part.Raw
|
||||||
if !part.Get("cache_control").Exists() {
|
if !part.Get("cache_control").Exists() {
|
||||||
partJSON, _ = sjson.Set(partJSON, "cache_control.type", "ephemeral")
|
updated, _ := sjson.SetBytes([]byte(partJSON), "cache_control.type", "ephemeral")
|
||||||
partJSON, _ = sjson.Set(partJSON, "cache_control.ttl", "1h")
|
partJSON = string(updated)
|
||||||
}
|
}
|
||||||
result += "," + partJSON
|
result += "," + partJSON
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
} else if system.Type == gjson.String && system.String() != "" {
|
||||||
|
partJSON := `{"type":"text","cache_control":{"type":"ephemeral"}}`
|
||||||
|
updated, _ := sjson.SetBytes([]byte(partJSON), "text", system.String())
|
||||||
|
partJSON = string(updated)
|
||||||
|
result += "," + partJSON
|
||||||
}
|
}
|
||||||
result += "]"
|
result += "]"
|
||||||
|
|
||||||
@@ -1254,6 +1377,325 @@ func countCacheControls(payload []byte) int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePayloadObject(payload []byte) (map[string]any, bool) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var root map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &root); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return root, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalPayloadObject(original []byte, root map[string]any) []byte {
|
||||||
|
if root == nil {
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func asObject(v any) (map[string]any, bool) {
|
||||||
|
obj, ok := v.(map[string]any)
|
||||||
|
return obj, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func asArray(v any) ([]any, bool) {
|
||||||
|
arr, ok := v.([]any)
|
||||||
|
return arr, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCacheControlsMap(root map[string]any) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
if system, ok := asArray(root["system"]); ok {
|
||||||
|
for _, item := range system {
|
||||||
|
if obj, ok := asObject(item); ok {
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tools, ok := asArray(root["tools"]); ok {
|
||||||
|
for _, item := range tools {
|
||||||
|
if obj, ok := asObject(item); ok {
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages, ok := asArray(root["messages"]); ok {
|
||||||
|
for _, msg := range messages {
|
||||||
|
msgObj, ok := asObject(msg)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, ok := asArray(msgObj["content"])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range content {
|
||||||
|
if obj, ok := asObject(item); ok {
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTTLForBlock(obj map[string]any, seen5m *bool) bool {
|
||||||
|
ccRaw, exists := obj["cache_control"]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cc, ok := asObject(ccRaw)
|
||||||
|
if !ok {
|
||||||
|
*seen5m = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ttlRaw, ttlExists := cc["ttl"]
|
||||||
|
ttl, ttlIsString := ttlRaw.(string)
|
||||||
|
if !ttlExists || !ttlIsString || ttl != "1h" {
|
||||||
|
*seen5m = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if *seen5m {
|
||||||
|
delete(cc, "ttl")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLastCacheControlIndex(arr []any) int {
|
||||||
|
last := -1
|
||||||
|
for idx, item := range arr {
|
||||||
|
obj, ok := asObject(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
last = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripCacheControlExceptIndex(arr []any, preserveIdx int, excess *int) {
|
||||||
|
for idx, item := range arr {
|
||||||
|
if *excess <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
obj, ok := asObject(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := obj["cache_control"]; exists && idx != preserveIdx {
|
||||||
|
delete(obj, "cache_control")
|
||||||
|
*excess--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripAllCacheControl(arr []any, excess *int) {
|
||||||
|
for _, item := range arr {
|
||||||
|
if *excess <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
obj, ok := asObject(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
delete(obj, "cache_control")
|
||||||
|
*excess--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripMessageCacheControl(messages []any, excess *int) {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if *excess <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgObj, ok := asObject(msg)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, ok := asArray(msgObj["content"])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range content {
|
||||||
|
if *excess <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
obj, ok := asObject(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := obj["cache_control"]; exists {
|
||||||
|
delete(obj, "cache_control")
|
||||||
|
*excess--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCacheControlTTL ensures cache_control TTL values don't violate the
|
||||||
|
// prompt-caching-scope-2026-01-05 ordering constraint: a 1h-TTL block must not
|
||||||
|
// appear after a 5m-TTL block anywhere in the evaluation order.
|
||||||
|
//
|
||||||
|
// Anthropic evaluates blocks in order: tools → system (index 0..N) → messages.
|
||||||
|
// Within each section, blocks are evaluated in array order. A 5m (default) block
|
||||||
|
// followed by a 1h block at ANY later position is an error — including within
|
||||||
|
// the same section (e.g. system[1]=5m then system[3]=1h).
|
||||||
|
//
|
||||||
|
// Strategy: walk all cache_control blocks in evaluation order. Once a 5m block
|
||||||
|
// is seen, strip ttl from ALL subsequent 1h blocks (downgrading them to 5m).
|
||||||
|
func normalizeCacheControlTTL(payload []byte) []byte {
|
||||||
|
root, ok := parsePayloadObject(payload)
|
||||||
|
if !ok {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
seen5m := false
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
if tools, ok := asArray(root["tools"]); ok {
|
||||||
|
for _, tool := range tools {
|
||||||
|
if obj, ok := asObject(tool); ok {
|
||||||
|
if normalizeTTLForBlock(obj, &seen5m) {
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if system, ok := asArray(root["system"]); ok {
|
||||||
|
for _, item := range system {
|
||||||
|
if obj, ok := asObject(item); ok {
|
||||||
|
if normalizeTTLForBlock(obj, &seen5m) {
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages, ok := asArray(root["messages"]); ok {
|
||||||
|
for _, msg := range messages {
|
||||||
|
msgObj, ok := asObject(msg)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, ok := asArray(msgObj["content"])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range content {
|
||||||
|
if obj, ok := asObject(item); ok {
|
||||||
|
if normalizeTTLForBlock(obj, &seen5m) {
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCacheControlLimit removes excess cache_control blocks from a payload
|
||||||
|
// so the total does not exceed the Anthropic API limit (currently 4).
|
||||||
|
//
|
||||||
|
// Anthropic evaluates cache breakpoints in order: tools → system → messages.
|
||||||
|
// The most valuable breakpoints are:
|
||||||
|
// 1. Last tool — caches ALL tool definitions
|
||||||
|
// 2. Last system block — caches ALL system content
|
||||||
|
// 3. Recent messages — cache conversation context
|
||||||
|
//
|
||||||
|
// Removal priority (strip lowest-value first):
|
||||||
|
//
|
||||||
|
// Phase 1: system blocks earliest-first, preserving the last one.
|
||||||
|
// Phase 2: tool blocks earliest-first, preserving the last one.
|
||||||
|
// Phase 3: message content blocks earliest-first.
|
||||||
|
// Phase 4: remaining system blocks (last system).
|
||||||
|
// Phase 5: remaining tool blocks (last tool).
|
||||||
|
func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte {
|
||||||
|
root, ok := parsePayloadObject(payload)
|
||||||
|
if !ok {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
total := countCacheControlsMap(root)
|
||||||
|
if total <= maxBlocks {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
excess := total - maxBlocks
|
||||||
|
|
||||||
|
var system []any
|
||||||
|
if arr, ok := asArray(root["system"]); ok {
|
||||||
|
system = arr
|
||||||
|
}
|
||||||
|
var tools []any
|
||||||
|
if arr, ok := asArray(root["tools"]); ok {
|
||||||
|
tools = arr
|
||||||
|
}
|
||||||
|
var messages []any
|
||||||
|
if arr, ok := asArray(root["messages"]); ok {
|
||||||
|
messages = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(system) > 0 {
|
||||||
|
stripCacheControlExceptIndex(system, findLastCacheControlIndex(system), &excess)
|
||||||
|
}
|
||||||
|
if excess <= 0 {
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
stripCacheControlExceptIndex(tools, findLastCacheControlIndex(tools), &excess)
|
||||||
|
}
|
||||||
|
if excess <= 0 {
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) > 0 {
|
||||||
|
stripMessageCacheControl(messages, &excess)
|
||||||
|
}
|
||||||
|
if excess <= 0 {
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(system) > 0 {
|
||||||
|
stripAllCacheControl(system, &excess)
|
||||||
|
}
|
||||||
|
if excess <= 0 {
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
stripAllCacheControl(tools, &excess)
|
||||||
|
}
|
||||||
|
|
||||||
|
return marshalPayloadObject(payload, root)
|
||||||
|
}
|
||||||
|
|
||||||
// injectMessagesCacheControl adds cache_control to the second-to-last user turn for multi-turn caching.
|
// injectMessagesCacheControl adds cache_control to the second-to-last user turn for multi-turn caching.
|
||||||
// Per Anthropic docs: "Place cache_control on the second-to-last User message to let the model reuse the earlier cache."
|
// Per Anthropic docs: "Place cache_control on the second-to-last User message to let the model reuse the earlier cache."
|
||||||
// This enables caching of conversation history, which is especially beneficial for long multi-turn conversations.
|
// This enables caching of conversation history, which is especially beneficial for long multi-turn conversations.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
343
internal/runtime/executor/codebuddy_executor.go
Normal file
343
internal/runtime/executor/codebuddy_executor.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codebuddy"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
codeBuddyChatPath = "/v2/chat/completions"
|
||||||
|
codeBuddyAuthType = "codebuddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeBuddyExecutor handles requests to the CodeBuddy API.
|
||||||
|
type CodeBuddyExecutor struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCodeBuddyExecutor creates a new CodeBuddy executor instance.
|
||||||
|
func NewCodeBuddyExecutor(cfg *config.Config) *CodeBuddyExecutor {
|
||||||
|
return &CodeBuddyExecutor{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier returns the unique identifier for this executor.
|
||||||
|
func (e *CodeBuddyExecutor) Identifier() string { return codeBuddyAuthType }
|
||||||
|
|
||||||
|
// codeBuddyCredentials extracts the access token and domain from auth metadata.
|
||||||
|
func codeBuddyCredentials(auth *cliproxyauth.Auth) (accessToken, userID, domain string) {
|
||||||
|
if auth == nil {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
accessToken = metaStringValue(auth.Metadata, "access_token")
|
||||||
|
userID = metaStringValue(auth.Metadata, "user_id")
|
||||||
|
domain = metaStringValue(auth.Metadata, "domain")
|
||||||
|
if domain == "" {
|
||||||
|
domain = codebuddy.DefaultDomain
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareRequest prepares the HTTP request before execution.
|
||||||
|
func (e *CodeBuddyExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
accessToken, userID, domain := codeBuddyCredentials(auth)
|
||||||
|
if accessToken == "" {
|
||||||
|
return fmt.Errorf("codebuddy: missing access token")
|
||||||
|
}
|
||||||
|
e.applyHeaders(req, accessToken, userID, domain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpRequest executes a raw HTTP request.
|
||||||
|
func (e *CodeBuddyExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy executor: request is nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = req.Context()
|
||||||
|
}
|
||||||
|
httpReq := req.WithContext(ctx)
|
||||||
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
return httpClient.Do(httpReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs a non-streaming request.
|
||||||
|
func (e *CodeBuddyExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
|
accessToken, userID, domain := codeBuddyCredentials(auth)
|
||||||
|
if accessToken == "" {
|
||||||
|
return resp, fmt.Errorf("codebuddy: missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
from := opts.SourceFormat
|
||||||
|
to := sdktranslator.FromString("openai")
|
||||||
|
|
||||||
|
originalPayloadSource := req.Payload
|
||||||
|
if len(opts.OriginalRequest) > 0 {
|
||||||
|
originalPayloadSource = opts.OriginalRequest
|
||||||
|
}
|
||||||
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayloadSource, false)
|
||||||
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
||||||
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
|
||||||
|
|
||||||
|
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := codebuddy.BaseURL + codeBuddyChatPath
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
e.applyHeaders(httpReq, accessToken, userID, domain)
|
||||||
|
|
||||||
|
var authID, authLabel, authType, authValue string
|
||||||
|
if auth != nil {
|
||||||
|
authID = auth.ID
|
||||||
|
authLabel = auth.Label
|
||||||
|
authType, authValue = auth.AccountInfo()
|
||||||
|
}
|
||||||
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
|
URL: url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: httpReq.Header.Clone(),
|
||||||
|
Body: translated,
|
||||||
|
Provider: e.Identifier(),
|
||||||
|
AuthID: authID,
|
||||||
|
AuthLabel: authLabel,
|
||||||
|
AuthType: authType,
|
||||||
|
AuthValue: authValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("codebuddy executor: close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
|
if !isHTTPSuccess(httpResp.StatusCode) {
|
||||||
|
b, _ := io.ReadAll(httpResp.Body)
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
|
log.Debugf("codebuddy executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, body)
|
||||||
|
reporter.publish(ctx, parseOpenAIUsage(body))
|
||||||
|
reporter.ensurePublished(ctx)
|
||||||
|
|
||||||
|
var param any
|
||||||
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, ¶m)
|
||||||
|
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteStream performs a streaming request.
|
||||||
|
func (e *CodeBuddyExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
|
accessToken, userID, domain := codeBuddyCredentials(auth)
|
||||||
|
if accessToken == "" {
|
||||||
|
return nil, fmt.Errorf("codebuddy: missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
from := opts.SourceFormat
|
||||||
|
to := sdktranslator.FromString("openai")
|
||||||
|
|
||||||
|
originalPayloadSource := req.Payload
|
||||||
|
if len(opts.OriginalRequest) > 0 {
|
||||||
|
originalPayloadSource = opts.OriginalRequest
|
||||||
|
}
|
||||||
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayloadSource, true)
|
||||||
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
||||||
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
|
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
|
||||||
|
|
||||||
|
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := codebuddy.BaseURL + codeBuddyChatPath
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.applyHeaders(httpReq, accessToken, userID, domain)
|
||||||
|
httpReq.Header.Set("Accept", "text/event-stream")
|
||||||
|
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
var authID, authLabel, authType, authValue string
|
||||||
|
if auth != nil {
|
||||||
|
authID = auth.ID
|
||||||
|
authLabel = auth.Label
|
||||||
|
authType, authValue = auth.AccountInfo()
|
||||||
|
}
|
||||||
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
|
URL: url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: httpReq.Header.Clone(),
|
||||||
|
Body: translated,
|
||||||
|
Provider: e.Identifier(),
|
||||||
|
AuthID: authID,
|
||||||
|
AuthLabel: authLabel,
|
||||||
|
AuthType: authType,
|
||||||
|
AuthValue: authValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
|
if !isHTTPSuccess(httpResp.StatusCode) {
|
||||||
|
b, _ := io.ReadAll(httpResp.Body)
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
|
httpResp.Body.Close()
|
||||||
|
log.Debugf("codebuddy executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(chan cliproxyexecutor.StreamChunk)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
defer func() {
|
||||||
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("codebuddy executor: close stream body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(httpResp.Body)
|
||||||
|
scanner.Buffer(nil, maxScannerBufferSize)
|
||||||
|
var param any
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, line)
|
||||||
|
if detail, ok := parseOpenAIStreamUsage(line); ok {
|
||||||
|
reporter.publish(ctx, detail)
|
||||||
|
}
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(line, []byte("data:")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m)
|
||||||
|
for i := range chunks {
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
|
reporter.publishFailure(ctx)
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
||||||
|
}
|
||||||
|
reporter.ensurePublished(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &cliproxyexecutor.StreamResult{
|
||||||
|
Headers: httpResp.Header.Clone(),
|
||||||
|
Chunks: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh exchanges the CodeBuddy refresh token for a new access token.
|
||||||
|
func (e *CodeBuddyExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||||
|
if auth == nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: missing auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := metaStringValue(auth.Metadata, "refresh_token")
|
||||||
|
if refreshToken == "" {
|
||||||
|
log.Debugf("codebuddy executor: no refresh token available, skipping refresh")
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, userID, domain := codeBuddyCredentials(auth)
|
||||||
|
|
||||||
|
authSvc := codebuddy.NewCodeBuddyAuth(e.cfg)
|
||||||
|
storage, err := authSvc.RefreshToken(ctx, accessToken, refreshToken, userID, domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codebuddy: token refresh failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := auth.Clone()
|
||||||
|
updated.Metadata["access_token"] = storage.AccessToken
|
||||||
|
if storage.RefreshToken != "" {
|
||||||
|
updated.Metadata["refresh_token"] = storage.RefreshToken
|
||||||
|
}
|
||||||
|
updated.Metadata["expires_in"] = storage.ExpiresIn
|
||||||
|
updated.Metadata["domain"] = storage.Domain
|
||||||
|
if storage.UserID != "" {
|
||||||
|
updated.Metadata["user_id"] = storage.UserID
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updated.UpdatedAt = now
|
||||||
|
updated.LastRefreshedAt = now
|
||||||
|
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTokens is not supported for CodeBuddy.
|
||||||
|
func (e *CodeBuddyExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
return cliproxyexecutor.Response{}, fmt.Errorf("codebuddy: count tokens not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyHeaders sets required headers for CodeBuddy API requests.
|
||||||
|
func (e *CodeBuddyExecutor) applyHeaders(req *http.Request, accessToken, userID, domain string) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", codebuddy.UserAgent)
|
||||||
|
req.Header.Set("X-User-Id", userID)
|
||||||
|
req.Header.Set("X-Domain", domain)
|
||||||
|
req.Header.Set("X-Product", "SaaS")
|
||||||
|
req.Header.Set("X-IDE-Type", "CLI")
|
||||||
|
req.Header.Set("X-IDE-Name", "CLI")
|
||||||
|
req.Header.Set("X-IDE-Version", "2.63.2")
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
}
|
||||||
125
internal/runtime/executor/codex_continuity.go
Normal file
125
internal/runtime/executor/codex_continuity.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type codexContinuity struct {
|
||||||
|
Key string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataString(meta map[string]any, key string) string {
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, ok := meta[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case []byte:
|
||||||
|
return strings.TrimSpace(string(v))
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalString(raw any) string {
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case fmt.Stringer:
|
||||||
|
return strings.TrimSpace(v.String())
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCodexContinuity(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) codexContinuity {
|
||||||
|
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(req.Payload, "prompt_cache_key").String()); promptCacheKey != "" {
|
||||||
|
return codexContinuity{Key: promptCacheKey, Source: "prompt_cache_key"}
|
||||||
|
}
|
||||||
|
if executionSession := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); executionSession != "" {
|
||||||
|
return codexContinuity{Key: executionSession, Source: "execution_session"}
|
||||||
|
}
|
||||||
|
if ginCtx := ginContextFrom(ctx); ginCtx != nil {
|
||||||
|
if ginCtx.Request != nil {
|
||||||
|
if v := strings.TrimSpace(ginCtx.GetHeader("Idempotency-Key")); v != "" {
|
||||||
|
return codexContinuity{Key: v, Source: "idempotency_key"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, exists := ginCtx.Get("apiKey"); exists && v != nil {
|
||||||
|
if trimmed := principalString(v); trimmed != "" {
|
||||||
|
return codexContinuity{Key: uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+trimmed)).String(), Source: "client_principal"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if auth != nil {
|
||||||
|
if authID := strings.TrimSpace(auth.ID); authID != "" {
|
||||||
|
return codexContinuity{Key: uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:auth:"+authID)).String(), Source: "auth_id"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codexContinuity{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCodexContinuityBody(rawJSON []byte, continuity codexContinuity) []byte {
|
||||||
|
if continuity.Key == "" {
|
||||||
|
return rawJSON
|
||||||
|
}
|
||||||
|
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", continuity.Key)
|
||||||
|
return rawJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCodexContinuityHeaders(headers http.Header, continuity codexContinuity) {
|
||||||
|
if headers == nil || continuity.Key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headers.Set("session_id", continuity.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCodexRequestDiagnostics(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, headers http.Header, body []byte, continuity codexContinuity) {
|
||||||
|
if !log.IsLevelEnabled(log.DebugLevel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := logWithRequestID(ctx)
|
||||||
|
authID := ""
|
||||||
|
authFile := ""
|
||||||
|
if auth != nil {
|
||||||
|
authID = strings.TrimSpace(auth.ID)
|
||||||
|
authFile = strings.TrimSpace(auth.FileName)
|
||||||
|
}
|
||||||
|
selectedAuthID := metadataString(opts.Metadata, cliproxyexecutor.SelectedAuthMetadataKey)
|
||||||
|
executionSessionID := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey)
|
||||||
|
entry.Debugf(
|
||||||
|
"codex request diagnostics auth_id=%s selected_auth_id=%s auth_file=%s exec_session=%s continuity_source=%s session_id=%s prompt_cache_key=%s prompt_cache_retention=%s store=%t has_instructions=%t reasoning_effort=%s reasoning_summary=%s chatgpt_account_id=%t originator=%s model=%s source_format=%s",
|
||||||
|
authID,
|
||||||
|
selectedAuthID,
|
||||||
|
authFile,
|
||||||
|
executionSessionID,
|
||||||
|
continuity.Source,
|
||||||
|
strings.TrimSpace(headers.Get("session_id")),
|
||||||
|
gjson.GetBytes(body, "prompt_cache_key").String(),
|
||||||
|
gjson.GetBytes(body, "prompt_cache_retention").String(),
|
||||||
|
gjson.GetBytes(body, "store").Bool(),
|
||||||
|
gjson.GetBytes(body, "instructions").Exists(),
|
||||||
|
gjson.GetBytes(body, "reasoning.effort").String(),
|
||||||
|
gjson.GetBytes(body, "reasoning.summary").String(),
|
||||||
|
strings.TrimSpace(headers.Get("Chatgpt-Account-Id")) != "",
|
||||||
|
strings.TrimSpace(headers.Get("Originator")),
|
||||||
|
req.Model,
|
||||||
|
opts.SourceFormat.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexClientVersion = "0.101.0"
|
codexUserAgent = "codex_cli_rs/0.116.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
||||||
codexUserAgent = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
codexOriginator = "codex_cli_rs"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dataTag = []byte("data:")
|
var dataTag = []byte("data:")
|
||||||
@@ -111,18 +111,19 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
body, _ = sjson.SetBytes(body, "instructions", "")
|
body, _ = sjson.SetBytes(body, "instructions", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||||
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
|
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, true)
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -183,7 +184,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
|
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, line, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, line, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
err = statusErr{code: 408, msg: "stream error: stream disconnected before completion: stream closed before response.completed"}
|
err = statusErr{code: 408, msg: "stream error: stream disconnected before completion: stream closed before response.completed"}
|
||||||
@@ -222,11 +223,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
|
|||||||
body, _ = sjson.DeleteBytes(body, "stream")
|
body, _ = sjson.DeleteBytes(body, "stream")
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
|
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
|
||||||
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
|
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, false)
|
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -273,7 +275,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
|
|||||||
reporter.ensurePublished(ctx)
|
reporter.ensurePublished(ctx)
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,19 +311,20 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
body, _ = sjson.SetBytes(body, "instructions", "")
|
body, _ = sjson.SetBytes(body, "instructions", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||||
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
|
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, true)
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -387,7 +390,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
|
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
@@ -415,6 +418,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
||||||
body, _ = sjson.SetBytes(body, "stream", false)
|
body, _ = sjson.SetBytes(body, "stream", false)
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
body, _ = sjson.SetBytes(body, "instructions", "")
|
body, _ = sjson.SetBytes(body, "instructions", "")
|
||||||
@@ -432,7 +436,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
|
|
||||||
usageJSON := fmt.Sprintf(`{"response":{"usage":{"input_tokens":%d,"output_tokens":0,"total_tokens":%d}}}`, count, count)
|
usageJSON := fmt.Sprintf(`{"response":{"usage":{"input_tokens":%d,"output_tokens":0,"total_tokens":%d}}}`, count, count)
|
||||||
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, []byte(usageJSON))
|
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, []byte(usageJSON))
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil
|
return cliproxyexecutor.Response{Payload: translated}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenizerForCodexModel(model string) (tokenizer.Codec, error) {
|
func tokenizerForCodexModel(model string) (tokenizer.Codec, error) {
|
||||||
@@ -596,8 +600,9 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
|
|||||||
return auth, nil
|
return auth, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {
|
func (e *CodexExecutor) cacheHelper(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, url string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, rawJSON []byte) (*http.Request, codexContinuity, error) {
|
||||||
var cache codexCache
|
var cache codexCache
|
||||||
|
continuity := codexContinuity{}
|
||||||
if from == "claude" {
|
if from == "claude" {
|
||||||
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
||||||
if userIDResult.Exists() {
|
if userIDResult.Exists() {
|
||||||
@@ -610,29 +615,29 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
|
|||||||
}
|
}
|
||||||
setCodexCache(key, cache)
|
setCodexCache(key, cache)
|
||||||
}
|
}
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "claude_user_cache"}
|
||||||
}
|
}
|
||||||
} else if from == "openai-response" {
|
} else if from == "openai-response" {
|
||||||
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
|
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
|
||||||
if promptCacheKey.Exists() {
|
if promptCacheKey.Exists() {
|
||||||
cache.ID = promptCacheKey.String()
|
cache.ID = promptCacheKey.String()
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "prompt_cache_key"}
|
||||||
}
|
}
|
||||||
|
} else if from == "openai" {
|
||||||
|
continuity = resolveCodexContinuity(ctx, auth, req, opts)
|
||||||
|
cache.ID = continuity.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.ID != "" {
|
rawJSON = applyCodexContinuityBody(rawJSON, continuity)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
|
|
||||||
}
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, continuity, err
|
||||||
}
|
}
|
||||||
if cache.ID != "" {
|
applyCodexContinuityHeaders(httpReq.Header, continuity)
|
||||||
httpReq.Header.Set("Conversation_id", cache.ID)
|
return httpReq, continuity, nil
|
||||||
httpReq.Header.Set("Session_id", cache.ID)
|
|
||||||
}
|
|
||||||
return httpReq, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool) {
|
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
|
||||||
r.Header.Set("Content-Type", "application/json")
|
r.Header.Set("Content-Type", "application/json")
|
||||||
r.Header.Set("Authorization", "Bearer "+token)
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
@@ -641,9 +646,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
ginHeaders = ginCtx.Request.Header
|
ginHeaders = ginCtx.Request.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)
|
misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
misc.EnsureHeader(r.Header, ginHeaders, "session_id", uuid.NewString())
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", codexUserAgent)
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
|
||||||
|
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
||||||
|
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
||||||
|
|
||||||
if stream {
|
if stream {
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
@@ -658,8 +666,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
r.Header.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
r.Header.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
r.Header.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
r.Header.Set("Chatgpt-Account-Id", accountID)
|
r.Header.Set("Chatgpt-Account-Id", accountID)
|
||||||
@@ -674,13 +686,39 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newCodexStatusErr(statusCode int, body []byte) statusErr {
|
func newCodexStatusErr(statusCode int, body []byte) statusErr {
|
||||||
err := statusErr{code: statusCode, msg: string(body)}
|
errCode := statusCode
|
||||||
if retryAfter := parseCodexRetryAfter(statusCode, body, time.Now()); retryAfter != nil {
|
if isCodexModelCapacityError(body) {
|
||||||
|
errCode = http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
err := statusErr{code: errCode, msg: string(body)}
|
||||||
|
if retryAfter := parseCodexRetryAfter(errCode, body, time.Now()); retryAfter != nil {
|
||||||
err.retryAfter = retryAfter
|
err.retryAfter = retryAfter
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCodexModelCapacityError(errorBody []byte) bool {
|
||||||
|
if len(errorBody) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
gjson.GetBytes(errorBody, "error.message").String(),
|
||||||
|
gjson.GetBytes(errorBody, "message").String(),
|
||||||
|
string(errorBody),
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(candidate))
|
||||||
|
if lower == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "selected model is at capacity") ||
|
||||||
|
strings.Contains(lower, "model is at capacity. please try a different model") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
|
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
|
||||||
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
|
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
180
internal/runtime/executor/codex_executor_cache_test.go
Normal file
180
internal/runtime/executor/codex_executor_cache_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFromAPIKey(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ginCtx.Set("apiKey", "test-api-key")
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), "gin", ginCtx)
|
||||||
|
executor := &CodexExecutor{}
|
||||||
|
rawJSON := []byte(`{"model":"gpt-5.3-codex","stream":true}`)
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5.3-codex",
|
||||||
|
Payload: []byte(`{"model":"gpt-5.3-codex"}`),
|
||||||
|
}
|
||||||
|
url := "https://example.com/responses"
|
||||||
|
|
||||||
|
httpReq, _, err := executor.cacheHelper(ctx, nil, sdktranslator.FromString("openai"), url, req, cliproxyexecutor.Options{}, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, errRead := io.ReadAll(httpReq.Body)
|
||||||
|
if errRead != nil {
|
||||||
|
t.Fatalf("read request body: %v", errRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedKey := uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:test-api-key")).String()
|
||||||
|
gotKey := gjson.GetBytes(body, "prompt_cache_key").String()
|
||||||
|
if gotKey != expectedKey {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedKey)
|
||||||
|
}
|
||||||
|
if gotSession := httpReq.Header.Get("session_id"); gotSession != expectedKey {
|
||||||
|
t.Fatalf("session_id = %q, want %q", gotSession, expectedKey)
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("Conversation_id"); got != "" {
|
||||||
|
t.Fatalf("Conversation_id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq2, _, err := executor.cacheHelper(ctx, nil, sdktranslator.FromString("openai"), url, req, cliproxyexecutor.Options{}, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error (second call): %v", err)
|
||||||
|
}
|
||||||
|
body2, errRead2 := io.ReadAll(httpReq2.Body)
|
||||||
|
if errRead2 != nil {
|
||||||
|
t.Fatalf("read request body (second call): %v", errRead2)
|
||||||
|
}
|
||||||
|
gotKey2 := gjson.GetBytes(body2, "prompt_cache_key").String()
|
||||||
|
if gotKey2 != expectedKey {
|
||||||
|
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexExecutorCacheHelper_OpenAIResponses_PreservesPromptCacheRetention(t *testing.T) {
|
||||||
|
executor := &CodexExecutor{}
|
||||||
|
url := "https://example.com/responses"
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5.3-codex",
|
||||||
|
Payload: []byte(`{"model":"gpt-5.3-codex","prompt_cache_key":"cache-key-1","prompt_cache_retention":"persistent"}`),
|
||||||
|
}
|
||||||
|
rawJSON := []byte(`{"model":"gpt-5.3-codex","stream":true,"prompt_cache_retention":"persistent"}`)
|
||||||
|
|
||||||
|
httpReq, _, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("openai-response"), url, req, cliproxyexecutor.Options{}, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(httpReq.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "cache-key-1" {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(body, "prompt_cache_retention").String(); got != "persistent" {
|
||||||
|
t.Fatalf("prompt_cache_retention = %q, want %q", got, "persistent")
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("session_id"); got != "cache-key-1" {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("Conversation_id"); got != "" {
|
||||||
|
t.Fatalf("Conversation_id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexExecutorCacheHelper_OpenAIChatCompletions_UsesExecutionSessionForContinuity(t *testing.T) {
|
||||||
|
executor := &CodexExecutor{}
|
||||||
|
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5.4",
|
||||||
|
Payload: []byte(`{"model":"gpt-5.4"}`),
|
||||||
|
}
|
||||||
|
opts := cliproxyexecutor.Options{Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "exec-session-1"}}
|
||||||
|
|
||||||
|
httpReq, _, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("openai"), "https://example.com/responses", req, opts, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(httpReq.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "exec-session-1" {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, "exec-session-1")
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("session_id"); got != "exec-session-1" {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, "exec-session-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexExecutorCacheHelper_OpenAIChatCompletions_FallsBackToStableAuthID(t *testing.T) {
|
||||||
|
executor := &CodexExecutor{}
|
||||||
|
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5.4",
|
||||||
|
Payload: []byte(`{"model":"gpt-5.4"}`),
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{ID: "codex-auth-1", Provider: "codex"}
|
||||||
|
|
||||||
|
httpReq, _, err := executor.cacheHelper(context.Background(), auth, sdktranslator.FromString("openai"), "https://example.com/responses", req, cliproxyexecutor.Options{}, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(httpReq.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:auth:codex-auth-1")).String()
|
||||||
|
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != expected {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("session_id"); got != expected {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexExecutorCacheHelper_ClaudePreservesCacheContinuity(t *testing.T) {
|
||||||
|
executor := &CodexExecutor{}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Payload: []byte(`{"metadata":{"user_id":"user-1"}}`),
|
||||||
|
}
|
||||||
|
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||||
|
|
||||||
|
httpReq, continuity, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("claude"), "https://example.com/responses", req, cliproxyexecutor.Options{}, rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
|
}
|
||||||
|
if continuity.Key == "" {
|
||||||
|
t.Fatal("continuity.Key = empty, want non-empty")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(httpReq.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != continuity.Key {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
if got := httpReq.Header.Get("session_id"); got != continuity.Key {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,19 @@ func TestParseCodexRetryAfter(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) {
|
||||||
|
body := []byte(`{"error":{"message":"Selected model is at capacity. Please try a different model."}}`)
|
||||||
|
|
||||||
|
err := newCodexStatusErr(http.StatusBadRequest, body)
|
||||||
|
|
||||||
|
if got := err.StatusCode(); got != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
if err.RetryAfter() != nil {
|
||||||
|
t.Fatalf("expected nil explicit retryAfter for capacity fallback, got %v", *err.RetryAfter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func itoa(v int64) string {
|
func itoa(v int64) string {
|
||||||
return strconv.FormatInt(v, 10)
|
return strconv.FormatInt(v, 10)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -31,7 +32,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexResponsesWebsocketBetaHeaderValue = "responses_websockets=2026-02-04"
|
codexResponsesWebsocketBetaHeaderValue = "responses_websockets=2026-02-06"
|
||||||
codexResponsesWebsocketIdleTimeout = 5 * time.Minute
|
codexResponsesWebsocketIdleTimeout = 5 * time.Minute
|
||||||
codexResponsesWebsocketHandshakeTO = 30 * time.Second
|
codexResponsesWebsocketHandshakeTO = 30 * time.Second
|
||||||
)
|
)
|
||||||
@@ -57,11 +58,6 @@ type codexWebsocketSession struct {
|
|||||||
wsURL string
|
wsURL string
|
||||||
authID string
|
authID string
|
||||||
|
|
||||||
// connCreateSent tracks whether a `response.create` message has been successfully sent
|
|
||||||
// on the current websocket connection. The upstream expects the first message on each
|
|
||||||
// connection to be `response.create`.
|
|
||||||
connCreateSent bool
|
|
||||||
|
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
|
|
||||||
activeMu sync.Mutex
|
activeMu sync.Mutex
|
||||||
@@ -182,7 +178,6 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
body, _ = sjson.SetBytes(body, "instructions", "")
|
body, _ = sjson.SetBytes(body, "instructions", "")
|
||||||
@@ -194,8 +189,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
|
body, wsHeaders, continuity := applyCodexPromptCacheHeaders(ctx, auth, from, req, opts, body)
|
||||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey)
|
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||||
|
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
@@ -212,13 +207,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
defer sess.reqMu.Unlock()
|
defer sess.reqMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
allowAppend := true
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
||||||
if sess != nil {
|
logCodexRequestDiagnostics(ctx, auth, req, opts, wsHeaders, body, continuity)
|
||||||
sess.connMu.Lock()
|
|
||||||
allowAppend = sess.connCreateSent
|
|
||||||
sess.connMu.Unlock()
|
|
||||||
}
|
|
||||||
wsReqBody := buildCodexWebsocketRequestBody(body, allowAppend)
|
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -280,10 +270,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
// execution session.
|
// execution session.
|
||||||
connRetry, _, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)
|
connRetry, _, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)
|
||||||
if errDialRetry == nil && connRetry != nil {
|
if errDialRetry == nil && connRetry != nil {
|
||||||
sess.connMu.Lock()
|
wsReqBodyRetry := buildCodexWebsocketRequestBody(body)
|
||||||
allowAppend = sess.connCreateSent
|
|
||||||
sess.connMu.Unlock()
|
|
||||||
wsReqBodyRetry := buildCodexWebsocketRequestBody(body, allowAppend)
|
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -312,7 +299,6 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
return resp, errSend
|
return resp, errSend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markCodexWebsocketCreateSent(sess, conn, wsReqBody)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if ctx != nil && ctx.Err() != nil {
|
if ctx != nil && ctx.Err() != nil {
|
||||||
@@ -357,7 +343,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
}
|
}
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, payload, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, payload, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
resp = cliproxyexecutor.Response{Payload: out}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,30 +385,25 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
|
body, wsHeaders, continuity := applyCodexPromptCacheHeaders(ctx, auth, from, req, opts, body)
|
||||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey)
|
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||||
|
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
authLabel = auth.Label
|
authLabel = auth.Label
|
||||||
authType, authValue = auth.AccountInfo()
|
authType, authValue = auth.AccountInfo()
|
||||||
}
|
|
||||||
|
|
||||||
executionSessionID := executionSessionIDFromOptions(opts)
|
executionSessionID := executionSessionIDFromOptions(opts)
|
||||||
var sess *codexWebsocketSession
|
var sess *codexWebsocketSession
|
||||||
if executionSessionID != "" {
|
if executionSessionID != "" {
|
||||||
sess = e.getOrCreateSession(executionSessionID)
|
sess = e.getOrCreateSession(executionSessionID)
|
||||||
|
if sess != nil {
|
||||||
sess.reqMu.Lock()
|
sess.reqMu.Lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
allowAppend := true
|
|
||||||
if sess != nil {
|
|
||||||
sess.connMu.Lock()
|
|
||||||
allowAppend = sess.connCreateSent
|
|
||||||
sess.connMu.Unlock()
|
|
||||||
}
|
}
|
||||||
wsReqBody := buildCodexWebsocketRequestBody(body, allowAppend)
|
|
||||||
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, wsHeaders, body, continuity)
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -483,10 +464,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
sess.reqMu.Unlock()
|
sess.reqMu.Unlock()
|
||||||
return nil, errDialRetry
|
return nil, errDialRetry
|
||||||
}
|
}
|
||||||
sess.connMu.Lock()
|
wsReqBodyRetry := buildCodexWebsocketRequestBody(body)
|
||||||
allowAppend = sess.connCreateSent
|
|
||||||
sess.connMu.Unlock()
|
|
||||||
wsReqBodyRetry := buildCodexWebsocketRequestBody(body, allowAppend)
|
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -515,7 +493,6 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
return nil, errSend
|
return nil, errSend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markCodexWebsocketCreateSent(sess, conn, wsReqBody)
|
|
||||||
|
|
||||||
out := make(chan cliproxyexecutor.StreamChunk)
|
out := make(chan cliproxyexecutor.StreamChunk)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -616,7 +593,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
line := encodeCodexWebsocketAsSSE(payload)
|
line := encodeCodexWebsocketAsSSE(payload)
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, body, body, line, ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, body, body, line, ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
if !send(cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}) {
|
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
|
||||||
terminateReason = "context_done"
|
terminateReason = "context_done"
|
||||||
terminateErr = ctx.Err()
|
terminateErr = ctx.Err()
|
||||||
return
|
return
|
||||||
@@ -657,31 +634,14 @@ func writeCodexWebsocketMessage(sess *codexWebsocketSession, conn *websocket.Con
|
|||||||
return conn.WriteMessage(websocket.TextMessage, payload)
|
return conn.WriteMessage(websocket.TextMessage, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCodexWebsocketRequestBody(body []byte, allowAppend bool) []byte {
|
func buildCodexWebsocketRequestBody(body []byte) []byte {
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex CLI websocket v2 uses `response.create` with `previous_response_id` for incremental turns.
|
// Match codex-rs websocket v2 semantics: every request is `response.create`.
|
||||||
// The upstream ChatGPT Codex websocket currently rejects that with close 1008 (policy violation).
|
// Incremental follow-up turns continue on the same websocket using
|
||||||
// Fall back to v1 `response.append` semantics on the same websocket connection to keep the session alive.
|
// `previous_response_id` + incremental `input`, not `response.append`.
|
||||||
//
|
|
||||||
// NOTE: The upstream expects the first websocket event on each connection to be `response.create`,
|
|
||||||
// so we only use `response.append` after we have initialized the current connection.
|
|
||||||
if allowAppend {
|
|
||||||
if prev := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()); prev != "" {
|
|
||||||
inputNode := gjson.GetBytes(body, "input")
|
|
||||||
wsReqBody := []byte(`{}`)
|
|
||||||
wsReqBody, _ = sjson.SetBytes(wsReqBody, "type", "response.append")
|
|
||||||
if inputNode.Exists() && inputNode.IsArray() && strings.TrimSpace(inputNode.Raw) != "" {
|
|
||||||
wsReqBody, _ = sjson.SetRawBytes(wsReqBody, "input", []byte(inputNode.Raw))
|
|
||||||
return wsReqBody
|
|
||||||
}
|
|
||||||
wsReqBody, _ = sjson.SetRawBytes(wsReqBody, "input", []byte("[]"))
|
|
||||||
return wsReqBody
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wsReqBody, errSet := sjson.SetBytes(bytes.Clone(body), "type", "response.create")
|
wsReqBody, errSet := sjson.SetBytes(bytes.Clone(body), "type", "response.create")
|
||||||
if errSet == nil && len(wsReqBody) > 0 {
|
if errSet == nil && len(wsReqBody) > 0 {
|
||||||
return wsReqBody
|
return wsReqBody
|
||||||
@@ -725,21 +685,6 @@ func readCodexWebsocketMessage(ctx context.Context, sess *codexWebsocketSession,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markCodexWebsocketCreateSent(sess *codexWebsocketSession, conn *websocket.Conn, payload []byte) {
|
|
||||||
if sess == nil || conn == nil || len(payload) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "response.create" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sess.connMu.Lock()
|
|
||||||
if sess.conn == conn {
|
|
||||||
sess.connCreateSent = true
|
|
||||||
}
|
|
||||||
sess.connMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *websocket.Dialer {
|
func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *websocket.Dialer {
|
||||||
dialer := &websocket.Dialer{
|
dialer := &websocket.Dialer{
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
@@ -762,21 +707,30 @@ func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *
|
|||||||
return dialer
|
return dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, errParse := url.Parse(proxyURL)
|
setting, errParse := proxyutil.Parse(proxyURL)
|
||||||
if errParse != nil {
|
if errParse != nil {
|
||||||
log.Errorf("codex websockets executor: parse proxy URL failed: %v", errParse)
|
log.Errorf("codex websockets executor: %v", errParse)
|
||||||
return dialer
|
return dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
switch parsedURL.Scheme {
|
switch setting.Mode {
|
||||||
|
case proxyutil.ModeDirect:
|
||||||
|
dialer.Proxy = nil
|
||||||
|
return dialer
|
||||||
|
case proxyutil.ModeProxy:
|
||||||
|
default:
|
||||||
|
return dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
switch setting.URL.Scheme {
|
||||||
case "socks5":
|
case "socks5":
|
||||||
var proxyAuth *proxy.Auth
|
var proxyAuth *proxy.Auth
|
||||||
if parsedURL.User != nil {
|
if setting.URL.User != nil {
|
||||||
username := parsedURL.User.Username()
|
username := setting.URL.User.Username()
|
||||||
password, _ := parsedURL.User.Password()
|
password, _ := setting.URL.User.Password()
|
||||||
proxyAuth = &proxy.Auth{User: username, Password: password}
|
proxyAuth = &proxy.Auth{User: username, Password: password}
|
||||||
}
|
}
|
||||||
socksDialer, errSOCKS5 := proxy.SOCKS5("tcp", parsedURL.Host, proxyAuth, proxy.Direct)
|
socksDialer, errSOCKS5 := proxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, proxy.Direct)
|
||||||
if errSOCKS5 != nil {
|
if errSOCKS5 != nil {
|
||||||
log.Errorf("codex websockets executor: create SOCKS5 dialer failed: %v", errSOCKS5)
|
log.Errorf("codex websockets executor: create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||||
return dialer
|
return dialer
|
||||||
@@ -786,9 +740,9 @@ func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *
|
|||||||
return socksDialer.Dial(network, addr)
|
return socksDialer.Dial(network, addr)
|
||||||
}
|
}
|
||||||
case "http", "https":
|
case "http", "https":
|
||||||
dialer.Proxy = http.ProxyURL(parsedURL)
|
dialer.Proxy = http.ProxyURL(setting.URL)
|
||||||
default:
|
default:
|
||||||
log.Errorf("codex websockets executor: unsupported proxy scheme: %s", parsedURL.Scheme)
|
log.Errorf("codex websockets executor: unsupported proxy scheme: %s", setting.URL.Scheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialer
|
return dialer
|
||||||
@@ -808,13 +762,14 @@ func buildCodexResponsesWebsocketURL(httpURL string) (string, error) {
|
|||||||
return parsed.String(), nil
|
return parsed.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header) {
|
func applyCodexPromptCacheHeaders(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, rawJSON []byte) ([]byte, http.Header, codexContinuity) {
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
if len(rawJSON) == 0 {
|
if len(rawJSON) == 0 {
|
||||||
return rawJSON, headers
|
return rawJSON, headers, codexContinuity{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cache codexCache
|
var cache codexCache
|
||||||
|
continuity := codexContinuity{}
|
||||||
if from == "claude" {
|
if from == "claude" {
|
||||||
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
||||||
if userIDResult.Exists() {
|
if userIDResult.Exists() {
|
||||||
@@ -828,23 +783,25 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
|
|||||||
}
|
}
|
||||||
setCodexCache(key, cache)
|
setCodexCache(key, cache)
|
||||||
}
|
}
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "claude_user_cache"}
|
||||||
}
|
}
|
||||||
} else if from == "openai-response" {
|
} else if from == "openai-response" {
|
||||||
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
|
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
|
||||||
cache.ID = promptCacheKey.String()
|
cache.ID = promptCacheKey.String()
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "prompt_cache_key"}
|
||||||
}
|
}
|
||||||
|
} else if from == "openai" {
|
||||||
|
continuity = resolveCodexContinuity(ctx, auth, req, opts)
|
||||||
|
cache.ID = continuity.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.ID != "" {
|
rawJSON = applyCodexContinuityBody(rawJSON, continuity)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
|
applyCodexContinuityHeaders(headers, continuity)
|
||||||
headers.Set("Conversation_id", cache.ID)
|
|
||||||
headers.Set("Session_id", cache.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawJSON, headers
|
return rawJSON, headers, continuity
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string) http.Header {
|
func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {
|
||||||
if headers == nil {
|
if headers == nil {
|
||||||
headers = http.Header{}
|
headers = http.Header{}
|
||||||
}
|
}
|
||||||
@@ -857,12 +814,14 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
ginHeaders = ginCtx.Request.Header
|
ginHeaders = ginCtx.Request.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-beta-features", "")
|
cfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth)
|
||||||
|
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "Version", "")
|
||||||
|
|
||||||
misc.EnsureHeader(headers, ginHeaders, "Version", codexClientVersion)
|
|
||||||
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
||||||
if betaHeader == "" && ginHeaders != nil {
|
if betaHeader == "" && ginHeaders != nil {
|
||||||
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
||||||
@@ -871,8 +830,8 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
betaHeader = codexResponsesWebsocketBetaHeaderValue
|
betaHeader = codexResponsesWebsocketBetaHeaderValue
|
||||||
}
|
}
|
||||||
headers.Set("OpenAI-Beta", betaHeader)
|
headers.Set("OpenAI-Beta", betaHeader)
|
||||||
misc.EnsureHeader(headers, ginHeaders, "Session_id", uuid.NewString())
|
misc.EnsureHeader(headers, ginHeaders, "session_id", uuid.NewString())
|
||||||
misc.EnsureHeader(headers, ginHeaders, "User-Agent", codexUserAgent)
|
ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
||||||
|
|
||||||
isAPIKey := false
|
isAPIKey := false
|
||||||
if auth != nil && auth.Attributes != nil {
|
if auth != nil && auth.Attributes != nil {
|
||||||
@@ -880,8 +839,12 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
headers.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
headers.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
headers.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
||||||
@@ -900,6 +863,62 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) {
|
||||||
|
if cfg == nil || auth == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent), strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureHeaderWithPriority(target http.Header, source http.Header, key, configValue, fallbackValue string) {
|
||||||
|
if target == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.Get(key)) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
if val := strings.TrimSpace(source.Get(key)); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(configValue); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(fallbackValue); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureHeaderWithConfigPrecedence(target http.Header, source http.Header, key, configValue, fallbackValue string) {
|
||||||
|
if target == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.Get(key)) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(configValue); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
if val := strings.TrimSpace(source.Get(key)); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(fallbackValue); val != "" {
|
||||||
|
target.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type statusErrWithHeaders struct {
|
type statusErrWithHeaders struct {
|
||||||
statusErr
|
statusErr
|
||||||
headers http.Header
|
headers http.Header
|
||||||
@@ -1017,36 +1036,6 @@ func closeHTTPResponseBody(resp *http.Response, logPrefix string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeOnContextDone(ctx context.Context, conn *websocket.Conn) chan struct{} {
|
|
||||||
done := make(chan struct{})
|
|
||||||
if ctx == nil || conn == nil {
|
|
||||||
return done
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-ctx.Done():
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return done
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelReadOnContextDone(ctx context.Context, conn *websocket.Conn) chan struct{} {
|
|
||||||
done := make(chan struct{})
|
|
||||||
if ctx == nil || conn == nil {
|
|
||||||
return done
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-ctx.Done():
|
|
||||||
_ = conn.SetReadDeadline(time.Now())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return done
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionSessionIDFromOptions(opts cliproxyexecutor.Options) string {
|
func executionSessionIDFromOptions(opts cliproxyexecutor.Options) string {
|
||||||
if len(opts.Metadata) == 0 {
|
if len(opts.Metadata) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -1120,7 +1109,6 @@ func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth *
|
|||||||
sess.conn = conn
|
sess.conn = conn
|
||||||
sess.wsURL = wsURL
|
sess.wsURL = wsURL
|
||||||
sess.authID = authID
|
sess.authID = authID
|
||||||
sess.connCreateSent = false
|
|
||||||
sess.readerConn = conn
|
sess.readerConn = conn
|
||||||
sess.connMu.Unlock()
|
sess.connMu.Unlock()
|
||||||
|
|
||||||
@@ -1206,7 +1194,6 @@ func (e *CodexWebsocketsExecutor) invalidateUpstreamConn(sess *codexWebsocketSes
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess.conn = nil
|
sess.conn = nil
|
||||||
sess.connCreateSent = false
|
|
||||||
if sess.readerConn == conn {
|
if sess.readerConn == conn {
|
||||||
sess.readerConn = nil
|
sess.readerConn = nil
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1260,6 @@ func (e *CodexWebsocketsExecutor) closeExecutionSession(sess *codexWebsocketSess
|
|||||||
authID := sess.authID
|
authID := sess.authID
|
||||||
wsURL := sess.wsURL
|
wsURL := sess.wsURL
|
||||||
sess.conn = nil
|
sess.conn = nil
|
||||||
sess.connCreateSent = false
|
|
||||||
if sess.readerConn == conn {
|
if sess.readerConn == conn {
|
||||||
sess.readerConn = nil
|
sess.readerConn = nil
|
||||||
}
|
}
|
||||||
|
|||||||
336
internal/runtime/executor/codex_websockets_executor_test.go
Normal file
336
internal/runtime/executor/codex_websockets_executor_test.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) {
|
||||||
|
body := []byte(`{"model":"gpt-5-codex","previous_response_id":"resp-1","input":[{"type":"message","id":"msg-1"}]}`)
|
||||||
|
|
||||||
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(wsReqBody, "type").String(); got != "response.create" {
|
||||||
|
t.Fatalf("type = %s, want response.create", got)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(wsReqBody, "previous_response_id").String(); got != "resp-1" {
|
||||||
|
t.Fatalf("previous_response_id = %s, want resp-1", got)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(wsReqBody, "input.0.id").String() != "msg-1" {
|
||||||
|
t.Fatalf("input item id mismatch")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(wsReqBody, "type").String(); got == "response.append" {
|
||||||
|
t.Fatalf("unexpected websocket request type: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexPromptCacheHeaders_PreservesPromptCacheRetention(t *testing.T) {
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5-codex",
|
||||||
|
Payload: []byte(`{"prompt_cache_key":"cache-key-1","prompt_cache_retention":"persistent"}`),
|
||||||
|
}
|
||||||
|
body := []byte(`{"model":"gpt-5-codex","stream":true,"prompt_cache_retention":"persistent"}`)
|
||||||
|
|
||||||
|
updatedBody, headers, _ := applyCodexPromptCacheHeaders(context.Background(), nil, sdktranslator.FromString("openai-response"), req, cliproxyexecutor.Options{}, body)
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_key").String(); got != "cache-key-1" {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_retention").String(); got != "persistent" {
|
||||||
|
t.Fatalf("prompt_cache_retention = %q, want %q", got, "persistent")
|
||||||
|
}
|
||||||
|
if got := headers.Get("session_id"); got != "cache-key-1" {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := headers.Get("Conversation_id"); got != "" {
|
||||||
|
t.Fatalf("Conversation_id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexPromptCacheHeaders_ClaudePreservesContinuity(t *testing.T) {
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Payload: []byte(`{"metadata":{"user_id":"user-1"}}`),
|
||||||
|
}
|
||||||
|
body := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||||
|
|
||||||
|
updatedBody, headers, continuity := applyCodexPromptCacheHeaders(context.Background(), nil, sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{}, body)
|
||||||
|
|
||||||
|
if continuity.Key == "" {
|
||||||
|
t.Fatal("continuity.Key = empty, want non-empty")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_key").String(); got != continuity.Key {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
if got := headers.Get("session_id"); got != continuity.Key {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {
|
||||||
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)
|
||||||
|
|
||||||
|
if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue {
|
||||||
|
t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue)
|
||||||
|
}
|
||||||
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
||||||
|
}
|
||||||
|
if got := headers.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
ctx := contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
|
||||||
|
|
||||||
|
if got := headers.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
||||||
|
UserAgent: "my-codex-client/1.0",
|
||||||
|
BetaFeatures: "feature-a,feature-b",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", cfg)
|
||||||
|
|
||||||
|
if got := headers.Get("User-Agent"); got != "my-codex-client/1.0" {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", got, "my-codex-client/1.0")
|
||||||
|
}
|
||||||
|
if got := headers.Get("x-codex-beta-features"); got != "feature-a,feature-b" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %s, want %s", got, "feature-a,feature-b")
|
||||||
|
}
|
||||||
|
if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue {
|
||||||
|
t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
||||||
|
UserAgent: "config-ua",
|
||||||
|
BetaFeatures: "config-beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
ctx := contextWithGinHeaders(map[string]string{
|
||||||
|
"User-Agent": "client-ua",
|
||||||
|
"X-Codex-Beta-Features": "client-beta",
|
||||||
|
})
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("User-Agent", "existing-ua")
|
||||||
|
headers.Set("X-Codex-Beta-Features", "existing-beta")
|
||||||
|
|
||||||
|
got := applyCodexWebsocketHeaders(ctx, headers, auth, "", cfg)
|
||||||
|
|
||||||
|
if gotVal := got.Get("User-Agent"); gotVal != "existing-ua" {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", gotVal, "existing-ua")
|
||||||
|
}
|
||||||
|
if gotVal := got.Get("x-codex-beta-features"); gotVal != "existing-beta" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %s, want %s", gotVal, "existing-beta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
||||||
|
UserAgent: "config-ua",
|
||||||
|
BetaFeatures: "config-beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
ctx := contextWithGinHeaders(map[string]string{
|
||||||
|
"User-Agent": "client-ua",
|
||||||
|
"X-Codex-Beta-Features": "client-beta",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", cfg)
|
||||||
|
|
||||||
|
if got := headers.Get("User-Agent"); got != "config-ua" {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", got, "config-ua")
|
||||||
|
}
|
||||||
|
if got := headers.Get("x-codex-beta-features"); got != "client-beta" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %s, want %s", got, "client-beta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
||||||
|
UserAgent: "config-ua",
|
||||||
|
BetaFeatures: "config-beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Attributes: map[string]string{"api_key": "sk-test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "sk-test", cfg)
|
||||||
|
|
||||||
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
||||||
|
}
|
||||||
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
||||||
|
UserAgent: "config-ua",
|
||||||
|
BetaFeatures: "config-beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
||||||
|
"User-Agent": "client-ua",
|
||||||
|
}))
|
||||||
|
|
||||||
|
applyCodexHeaders(req, auth, "oauth-token", true, cfg)
|
||||||
|
|
||||||
|
if got := req.Header.Get("User-Agent"); got != "config-ua" {
|
||||||
|
t.Fatalf("User-Agent = %s, want %s", got, "config-ua")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("x-codex-beta-features"); got != "" {
|
||||||
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
}))
|
||||||
|
|
||||||
|
applyCodexHeaders(req, auth, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersDoesNotInjectClientOnlyHeadersByDefault(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCodexHeaders(req, nil, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextWithGinHeaders(headers map[string]string) context.Context {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ginCtx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
ginCtx.Request.Header = make(http.Header, len(headers))
|
||||||
|
for key, value := range headers {
|
||||||
|
ginCtx.Request.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
return context.WithValue(context.Background(), "gin", ginCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewProxyAwareWebsocketDialerDirectDisablesProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dialer := newProxyAwareWebsocketDialer(
|
||||||
|
&config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"}},
|
||||||
|
&cliproxyauth.Auth{ProxyURL: "direct"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if dialer.Proxy != nil {
|
||||||
|
t.Fatal("expected websocket proxy function to be nil for direct mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
1719
internal/runtime/executor/cursor_executor.go
Normal file
1719
internal/runtime/executor/cursor_executor.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||||
@@ -81,7 +80,7 @@ func (e *GeminiCLIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth
|
|||||||
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
applyGeminiCLIHeaders(req)
|
applyGeminiCLIHeaders(req, "unknown")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +188,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
}
|
}
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
applyGeminiCLIHeaders(reqHTTP)
|
applyGeminiCLIHeaders(reqHTTP, attemptModel)
|
||||||
reqHTTP.Header.Set("Accept", "application/json")
|
reqHTTP.Header.Set("Accept", "application/json")
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -225,7 +224,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
reporter.publish(ctx, parseGeminiCLIUsage(data))
|
reporter.publish(ctx, parseGeminiCLIUsage(data))
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, opts.OriginalRequest, payload, data, ¶m)
|
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, opts.OriginalRequest, payload, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +333,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
}
|
}
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
applyGeminiCLIHeaders(reqHTTP)
|
applyGeminiCLIHeaders(reqHTTP, attemptModel)
|
||||||
reqHTTP.Header.Set("Accept", "text/event-stream")
|
reqHTTP.Header.Set("Accept", "text/event-stream")
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -402,14 +401,14 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
if bytes.HasPrefix(line, dataTag) {
|
if bytes.HasPrefix(line, dataTag) {
|
||||||
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), ¶m)
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), ¶m)
|
||||||
for i := range segments {
|
for i := range segments {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
|
||||||
for i := range segments {
|
for i := range segments {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
@@ -431,12 +430,12 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
var param any
|
var param any
|
||||||
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, ¶m)
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, ¶m)
|
||||||
for i := range segments {
|
for i := range segments {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
|
||||||
}
|
}
|
||||||
|
|
||||||
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
|
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
|
||||||
for i := range segments {
|
for i := range segments {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
|
||||||
}
|
}
|
||||||
}(httpResp, append([]byte(nil), payload...), attemptModel)
|
}(httpResp, append([]byte(nil), payload...), attemptModel)
|
||||||
|
|
||||||
@@ -515,7 +514,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
|||||||
}
|
}
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
applyGeminiCLIHeaders(reqHTTP)
|
applyGeminiCLIHeaders(reqHTTP, baseModel)
|
||||||
reqHTTP.Header.Set("Accept", "application/json")
|
reqHTTP.Header.Set("Accept", "application/json")
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -545,7 +544,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
|||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)
|
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated), Headers: resp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: translated, Headers: resp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
lastStatus = resp.StatusCode
|
lastStatus = resp.StatusCode
|
||||||
lastBody = append([]byte(nil), data...)
|
lastBody = append([]byte(nil), data...)
|
||||||
@@ -738,21 +737,11 @@ func stringValue(m map[string]any, key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyGeminiCLIHeaders sets required headers for the Gemini CLI upstream.
|
// applyGeminiCLIHeaders sets required headers for the Gemini CLI upstream.
|
||||||
func applyGeminiCLIHeaders(r *http.Request) {
|
// User-Agent is always forced to the GeminiCLI format regardless of the client's value,
|
||||||
var ginHeaders http.Header
|
// so that upstream identifies the request as a native GeminiCLI client.
|
||||||
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
func applyGeminiCLIHeaders(r *http.Request, model string) {
|
||||||
ginHeaders = ginCtx.Request.Header
|
r.Header.Set("User-Agent", misc.GeminiCLIUserAgent(model))
|
||||||
}
|
r.Header.Set("X-Goog-Api-Client", misc.GeminiCLIApiClientHeader)
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", "google-api-nodejs-client/9.15.1")
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Goog-Api-Client", "gl-node/22.17.0")
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Client-Metadata", geminiCLIClientMetadata())
|
|
||||||
}
|
|
||||||
|
|
||||||
// geminiCLIClientMetadata returns a compact metadata string required by upstream.
|
|
||||||
func geminiCLIClientMetadata() string {
|
|
||||||
// Keep parity with CLI client defaults
|
|
||||||
return "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cliPreviewFallbackOrder returns preview model candidates for a base model.
|
// cliPreviewFallbackOrder returns preview model candidates for a base model.
|
||||||
@@ -822,18 +811,18 @@ func fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte {
|
|||||||
|
|
||||||
if !hasInlineData {
|
if !hasInlineData {
|
||||||
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
||||||
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
|
emptyImagePart := []byte(`{"inlineData":{"mime_type":"image/png","data":""}}`)
|
||||||
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
emptyImagePart, _ = sjson.SetBytes(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
||||||
newPartsJson := `[]`
|
newPartsJson := []byte(`[]`)
|
||||||
newPartsJson, _ = 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."}`)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(`{"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."}`))
|
||||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", emptyImagePart)
|
||||||
|
|
||||||
parts := contentArray[0].Get("parts").Array()
|
parts := contentArray[0].Get("parts").Array()
|
||||||
for j := 0; j < len(parts); j++ {
|
for j := 0; j < len(parts); j++ {
|
||||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(parts[j].Raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents.0.parts", []byte(newPartsJson))
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents.0.parts", newPartsJson)
|
||||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
reporter.publish(ctx, parseGeminiUsage(data))
|
reporter.publish(ctx, parseGeminiUsage(data))
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,12 +321,12 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
@@ -415,7 +415,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
|
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)
|
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated), Headers: resp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: translated, Headers: resp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh refreshes the authentication credentials (no-op for Gemini API key).
|
// Refresh refreshes the authentication credentials (no-op for Gemini API key).
|
||||||
@@ -527,18 +527,18 @@ func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
|
|||||||
|
|
||||||
if !hasInlineData {
|
if !hasInlineData {
|
||||||
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
||||||
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
|
emptyImagePart := []byte(`{"inlineData":{"mime_type":"image/png","data":""}}`)
|
||||||
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
emptyImagePart, _ = sjson.SetBytes(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
||||||
newPartsJson := `[]`
|
newPartsJson := []byte(`[]`)
|
||||||
newPartsJson, _ = 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."}`)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(`{"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."}`))
|
||||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", emptyImagePart)
|
||||||
|
|
||||||
parts := contentArray[0].Get("parts").Array()
|
parts := contentArray[0].Get("parts").Array()
|
||||||
for j := 0; j < len(parts); j++ {
|
for j := 0; j < len(parts); j++ {
|
||||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
|
newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(parts[j].Raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "contents.0.parts", []byte(newPartsJson))
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "contents.0.parts", newPartsJson)
|
||||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
|
|||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
|
|||||||
|
|
||||||
// For API key auth, use simpler URL format without project/location
|
// For API key auth, use simpler URL format without project/location
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://aiplatform.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
|
||||||
if opts.Alt != "" && action != "countTokens" {
|
if opts.Alt != "" && action != "countTokens" {
|
||||||
@@ -524,7 +524,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
|
|||||||
reporter.publish(ctx, parseGeminiUsage(data))
|
reporter.publish(ctx, parseGeminiUsage(data))
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,12 +636,12 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
|
|||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
@@ -683,7 +683,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
|
|||||||
action := getVertexAction(baseModel, true)
|
action := getVertexAction(baseModel, true)
|
||||||
// For API key auth, use simpler URL format without project/location
|
// For API key auth, use simpler URL format without project/location
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://aiplatform.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
|
||||||
// Imagen models don't support streaming, skip SSE params
|
// Imagen models don't support streaming, skip SSE params
|
||||||
@@ -760,12 +760,12 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
|
|||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
@@ -857,7 +857,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
|
|||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countTokensWithAPIKey handles token counting using API key credentials.
|
// countTokensWithAPIKey handles token counting using API key credentials.
|
||||||
@@ -883,7 +883,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
|
|||||||
|
|
||||||
// For API key auth, use simpler URL format without project/location
|
// For API key auth, use simpler URL format without project/location
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://generativelanguage.googleapis.com"
|
baseURL = "https://aiplatform.googleapis.com"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens")
|
url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens")
|
||||||
|
|
||||||
@@ -941,7 +941,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
|
|||||||
appendAPIResponseChunk(ctx, e.cfg, data)
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
count := gjson.GetBytes(data, "totalTokens").Int()
|
count := gjson.GetBytes(data, "totalTokens").Int()
|
||||||
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}, nil
|
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vertexCreds extracts project, location and raw service account JSON from auth metadata.
|
// vertexCreds extracts project, location and raw service account JSON from auth metadata.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
|
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -220,13 +221,13 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
|
|||||||
}
|
}
|
||||||
|
|
||||||
var param any
|
var param any
|
||||||
converted := ""
|
var converted []byte
|
||||||
if useResponses && from.String() == "claude" {
|
if useResponses && from.String() == "claude" {
|
||||||
converted = translateGitHubCopilotResponsesNonStreamToClaude(data)
|
converted = translateGitHubCopilotResponsesNonStreamToClaude(data)
|
||||||
} else {
|
} else {
|
||||||
converted = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
converted = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
||||||
}
|
}
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(converted)}
|
resp = cliproxyexecutor.Response{Payload: converted}
|
||||||
reporter.ensurePublished(ctx)
|
reporter.ensurePublished(ctx)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -373,14 +374,14 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunks []string
|
var chunks [][]byte
|
||||||
if useResponses && from.String() == "claude" {
|
if useResponses && from.String() == "claude" {
|
||||||
chunks = translateGitHubCopilotResponsesStreamToClaude(bytes.Clone(line), ¶m)
|
chunks = translateGitHubCopilotResponsesStreamToClaude(bytes.Clone(line), ¶m)
|
||||||
} else {
|
} else {
|
||||||
chunks = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m)
|
chunks = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m)
|
||||||
}
|
}
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(chunks[i])}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,20 +491,48 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, b
|
|||||||
r.Header.Set("X-Request-Id", uuid.NewString())
|
r.Header.Set("X-Request-Id", uuid.NewString())
|
||||||
|
|
||||||
initiator := "user"
|
initiator := "user"
|
||||||
if len(body) > 0 {
|
if role := detectLastConversationRole(body); role == "assistant" || role == "tool" {
|
||||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
|
||||||
for _, msg := range messages.Array() {
|
|
||||||
role := msg.Get("role").String()
|
|
||||||
if role == "assistant" || role == "tool" {
|
|
||||||
initiator = "agent"
|
initiator = "agent"
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
r.Header.Set("X-Initiator", initiator)
|
r.Header.Set("X-Initiator", initiator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectLastConversationRole(body []byte) string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||||
|
arr := messages.Array()
|
||||||
|
for i := len(arr) - 1; i >= 0; i-- {
|
||||||
|
if role := arr[i].Get("role").String(); role != "" {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inputs := gjson.GetBytes(body, "input"); inputs.Exists() && inputs.IsArray() {
|
||||||
|
arr := inputs.Array()
|
||||||
|
for i := len(arr) - 1; i >= 0; i-- {
|
||||||
|
item := arr[i]
|
||||||
|
|
||||||
|
// Most Responses input items carry a top-level role.
|
||||||
|
if role := item.Get("role").String(); role != "" {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Get("type").String() {
|
||||||
|
case "function_call", "function_call_arguments", "computer_call":
|
||||||
|
return "assistant"
|
||||||
|
case "function_call_output", "function_call_response", "tool_result", "computer_call_output":
|
||||||
|
return "tool"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// detectVisionContent checks if the request body contains vision/image content.
|
// detectVisionContent checks if the request body contains vision/image content.
|
||||||
// Returns true if the request includes image_url or image type content blocks.
|
// Returns true if the request includes image_url or image type content blocks.
|
||||||
func detectVisionContent(body []byte) bool {
|
func detectVisionContent(body []byte) bool {
|
||||||
@@ -548,9 +577,33 @@ func useGitHubCopilotResponsesEndpoint(sourceFormat sdktranslator.Format, model
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
baseModel := strings.ToLower(thinking.ParseSuffix(model).ModelName)
|
baseModel := strings.ToLower(thinking.ParseSuffix(model).ModelName)
|
||||||
|
if info := registry.GetGlobalRegistry().GetModelInfo(baseModel, githubCopilotAuthType); info != nil {
|
||||||
|
return len(info.SupportedEndpoints) > 0 && !containsEndpoint(info.SupportedEndpoints, githubCopilotChatPath) && containsEndpoint(info.SupportedEndpoints, githubCopilotResponsesPath)
|
||||||
|
}
|
||||||
|
if info := lookupGitHubCopilotStaticModelInfo(baseModel); info != nil {
|
||||||
|
return len(info.SupportedEndpoints) > 0 && !containsEndpoint(info.SupportedEndpoints, githubCopilotChatPath) && containsEndpoint(info.SupportedEndpoints, githubCopilotResponsesPath)
|
||||||
|
}
|
||||||
return strings.Contains(baseModel, "codex")
|
return strings.Contains(baseModel, "codex")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lookupGitHubCopilotStaticModelInfo(model string) *registry.ModelInfo {
|
||||||
|
for _, info := range registry.GetStaticModelDefinitionsByChannel(githubCopilotAuthType) {
|
||||||
|
if info != nil && strings.EqualFold(info.ID, model) {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsEndpoint(endpoints []string, endpoint string) bool {
|
||||||
|
for _, item := range endpoints {
|
||||||
|
if item == endpoint {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// flattenAssistantContent converts assistant message content from array format
|
// flattenAssistantContent converts assistant message content from array format
|
||||||
// to a joined string. GitHub Copilot requires assistant content as a string;
|
// to a joined string. GitHub Copilot requires assistant content as a string;
|
||||||
// sending it as an array causes Claude models to re-answer all previous prompts.
|
// sending it as an array causes Claude models to re-answer all previous prompts.
|
||||||
@@ -624,6 +677,7 @@ func normalizeGitHubCopilotChatTools(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
|
func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
|
||||||
|
body = stripGitHubCopilotResponsesUnsupportedFields(body)
|
||||||
input := gjson.GetBytes(body, "input")
|
input := gjson.GetBytes(body, "input")
|
||||||
if input.Exists() {
|
if input.Exists() {
|
||||||
// If input is already a string or array, keep it as-is.
|
// If input is already a string or array, keep it as-is.
|
||||||
@@ -796,6 +850,12 @@ func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stripGitHubCopilotResponsesUnsupportedFields(body []byte) []byte {
|
||||||
|
// GitHub Copilot /responses rejects service_tier, so always remove it.
|
||||||
|
body, _ = sjson.DeleteBytes(body, "service_tier")
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
||||||
tools := gjson.GetBytes(body, "tools")
|
tools := gjson.GetBytes(body, "tools")
|
||||||
if tools.Exists() {
|
if tools.Exists() {
|
||||||
@@ -803,6 +863,10 @@ func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
|||||||
if tools.IsArray() {
|
if tools.IsArray() {
|
||||||
for _, tool := range tools.Array() {
|
for _, tool := range tools.Array() {
|
||||||
toolType := tool.Get("type").String()
|
toolType := tool.Get("type").String()
|
||||||
|
if isGitHubCopilotResponsesBuiltinTool(toolType) {
|
||||||
|
filtered, _ = sjson.SetRaw(filtered, "-1", tool.Raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Accept OpenAI format (type="function") and Claude format
|
// Accept OpenAI format (type="function") and Claude format
|
||||||
// (no type field, but has top-level name + input_schema).
|
// (no type field, but has top-level name + input_schema).
|
||||||
if toolType != "" && toolType != "function" {
|
if toolType != "" && toolType != "function" {
|
||||||
@@ -850,6 +914,10 @@ func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
if toolChoice.Type == gjson.JSON {
|
if toolChoice.Type == gjson.JSON {
|
||||||
choiceType := toolChoice.Get("type").String()
|
choiceType := toolChoice.Get("type").String()
|
||||||
|
if isGitHubCopilotResponsesBuiltinTool(choiceType) {
|
||||||
|
body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(toolChoice.Raw))
|
||||||
|
return body
|
||||||
|
}
|
||||||
if choiceType == "function" {
|
if choiceType == "function" {
|
||||||
name := toolChoice.Get("name").String()
|
name := toolChoice.Get("name").String()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -867,6 +935,15 @@ func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isGitHubCopilotResponsesBuiltinTool(toolType string) bool {
|
||||||
|
switch strings.TrimSpace(toolType) {
|
||||||
|
case "computer", "computer_use_preview":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func collectTextFromNode(node gjson.Result) string {
|
func collectTextFromNode(node gjson.Result) string {
|
||||||
if !node.Exists() {
|
if !node.Exists() {
|
||||||
return ""
|
return ""
|
||||||
@@ -924,7 +1001,7 @@ type githubCopilotResponsesStreamState struct {
|
|||||||
ItemIDToTool map[string]*githubCopilotResponsesStreamToolState
|
ItemIDToTool map[string]*githubCopilotResponsesStreamToolState
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) string {
|
func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) []byte {
|
||||||
root := gjson.ParseBytes(data)
|
root := gjson.ParseBytes(data)
|
||||||
out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`
|
out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`
|
||||||
out, _ = sjson.Set(out, "id", root.Get("id").String())
|
out, _ = sjson.Set(out, "id", root.Get("id").String())
|
||||||
@@ -1014,10 +1091,10 @@ func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) string {
|
|||||||
} else {
|
} else {
|
||||||
out, _ = sjson.Set(out, "stop_reason", "end_turn")
|
out, _ = sjson.Set(out, "stop_reason", "end_turn")
|
||||||
}
|
}
|
||||||
return out
|
return []byte(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []string {
|
func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) [][]byte {
|
||||||
if *param == nil {
|
if *param == nil {
|
||||||
*param = &githubCopilotResponsesStreamState{
|
*param = &githubCopilotResponsesStreamState{
|
||||||
TextBlockIndex: -1,
|
TextBlockIndex: -1,
|
||||||
@@ -1039,7 +1116,10 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
}
|
}
|
||||||
|
|
||||||
event := gjson.GetBytes(payload, "type").String()
|
event := gjson.GetBytes(payload, "type").String()
|
||||||
results := make([]string, 0, 4)
|
results := make([][]byte, 0, 4)
|
||||||
|
appendResult := func(chunk string) {
|
||||||
|
results = append(results, []byte(chunk))
|
||||||
|
}
|
||||||
ensureMessageStart := func() {
|
ensureMessageStart := func() {
|
||||||
if state.MessageStarted {
|
if state.MessageStarted {
|
||||||
return
|
return
|
||||||
@@ -1047,7 +1127,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
messageStart := `{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`
|
messageStart := `{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`
|
||||||
messageStart, _ = sjson.Set(messageStart, "message.id", gjson.GetBytes(payload, "response.id").String())
|
messageStart, _ = sjson.Set(messageStart, "message.id", gjson.GetBytes(payload, "response.id").String())
|
||||||
messageStart, _ = sjson.Set(messageStart, "message.model", gjson.GetBytes(payload, "response.model").String())
|
messageStart, _ = sjson.Set(messageStart, "message.model", gjson.GetBytes(payload, "response.model").String())
|
||||||
results = append(results, "event: message_start\ndata: "+messageStart+"\n\n")
|
appendResult("event: message_start\ndata: " + messageStart + "\n\n")
|
||||||
state.MessageStarted = true
|
state.MessageStarted = true
|
||||||
}
|
}
|
||||||
startTextBlockIfNeeded := func() {
|
startTextBlockIfNeeded := func() {
|
||||||
@@ -1060,7 +1140,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
}
|
}
|
||||||
contentBlockStart := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
|
contentBlockStart := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
|
||||||
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", state.TextBlockIndex)
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", state.TextBlockIndex)
|
||||||
results = append(results, "event: content_block_start\ndata: "+contentBlockStart+"\n\n")
|
appendResult("event: content_block_start\ndata: " + contentBlockStart + "\n\n")
|
||||||
state.TextBlockStarted = true
|
state.TextBlockStarted = true
|
||||||
}
|
}
|
||||||
stopTextBlockIfNeeded := func() {
|
stopTextBlockIfNeeded := func() {
|
||||||
@@ -1069,7 +1149,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
}
|
}
|
||||||
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
||||||
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", state.TextBlockIndex)
|
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", state.TextBlockIndex)
|
||||||
results = append(results, "event: content_block_stop\ndata: "+contentBlockStop+"\n\n")
|
appendResult("event: content_block_stop\ndata: " + contentBlockStop + "\n\n")
|
||||||
state.TextBlockStarted = false
|
state.TextBlockStarted = false
|
||||||
state.TextBlockIndex = -1
|
state.TextBlockIndex = -1
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1179,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
contentDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
|
contentDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
|
||||||
contentDelta, _ = sjson.Set(contentDelta, "index", state.TextBlockIndex)
|
contentDelta, _ = sjson.Set(contentDelta, "index", state.TextBlockIndex)
|
||||||
contentDelta, _ = sjson.Set(contentDelta, "delta.text", delta)
|
contentDelta, _ = sjson.Set(contentDelta, "delta.text", delta)
|
||||||
results = append(results, "event: content_block_delta\ndata: "+contentDelta+"\n\n")
|
appendResult("event: content_block_delta\ndata: " + contentDelta + "\n\n")
|
||||||
}
|
}
|
||||||
case "response.reasoning_summary_part.added":
|
case "response.reasoning_summary_part.added":
|
||||||
ensureMessageStart()
|
ensureMessageStart()
|
||||||
@@ -1108,7 +1188,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
state.NextContentIndex++
|
state.NextContentIndex++
|
||||||
thinkingStart := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
thinkingStart := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
||||||
thinkingStart, _ = sjson.Set(thinkingStart, "index", state.ReasoningIndex)
|
thinkingStart, _ = sjson.Set(thinkingStart, "index", state.ReasoningIndex)
|
||||||
results = append(results, "event: content_block_start\ndata: "+thinkingStart+"\n\n")
|
appendResult("event: content_block_start\ndata: " + thinkingStart + "\n\n")
|
||||||
case "response.reasoning_summary_text.delta":
|
case "response.reasoning_summary_text.delta":
|
||||||
if state.ReasoningActive {
|
if state.ReasoningActive {
|
||||||
delta := gjson.GetBytes(payload, "delta").String()
|
delta := gjson.GetBytes(payload, "delta").String()
|
||||||
@@ -1116,14 +1196,14 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
thinkingDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
thinkingDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
||||||
thinkingDelta, _ = sjson.Set(thinkingDelta, "index", state.ReasoningIndex)
|
thinkingDelta, _ = sjson.Set(thinkingDelta, "index", state.ReasoningIndex)
|
||||||
thinkingDelta, _ = sjson.Set(thinkingDelta, "delta.thinking", delta)
|
thinkingDelta, _ = sjson.Set(thinkingDelta, "delta.thinking", delta)
|
||||||
results = append(results, "event: content_block_delta\ndata: "+thinkingDelta+"\n\n")
|
appendResult("event: content_block_delta\ndata: " + thinkingDelta + "\n\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "response.reasoning_summary_part.done":
|
case "response.reasoning_summary_part.done":
|
||||||
if state.ReasoningActive {
|
if state.ReasoningActive {
|
||||||
thinkingStop := `{"type":"content_block_stop","index":0}`
|
thinkingStop := `{"type":"content_block_stop","index":0}`
|
||||||
thinkingStop, _ = sjson.Set(thinkingStop, "index", state.ReasoningIndex)
|
thinkingStop, _ = sjson.Set(thinkingStop, "index", state.ReasoningIndex)
|
||||||
results = append(results, "event: content_block_stop\ndata: "+thinkingStop+"\n\n")
|
appendResult("event: content_block_stop\ndata: " + thinkingStop + "\n\n")
|
||||||
state.ReasoningActive = false
|
state.ReasoningActive = false
|
||||||
}
|
}
|
||||||
case "response.output_item.added":
|
case "response.output_item.added":
|
||||||
@@ -1151,7 +1231,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", tool.Index)
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", tool.Index)
|
||||||
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.id", tool.ID)
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.id", tool.ID)
|
||||||
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.name", tool.Name)
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.name", tool.Name)
|
||||||
results = append(results, "event: content_block_start\ndata: "+contentBlockStart+"\n\n")
|
appendResult("event: content_block_start\ndata: " + contentBlockStart + "\n\n")
|
||||||
case "response.output_item.delta":
|
case "response.output_item.delta":
|
||||||
item := gjson.GetBytes(payload, "item")
|
item := gjson.GetBytes(payload, "item")
|
||||||
if item.Get("type").String() != "function_call" {
|
if item.Get("type").String() != "function_call" {
|
||||||
@@ -1171,7 +1251,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
||||||
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
||||||
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
||||||
results = append(results, "event: content_block_delta\ndata: "+inputDelta+"\n\n")
|
appendResult("event: content_block_delta\ndata: " + inputDelta + "\n\n")
|
||||||
case "response.function_call_arguments.delta":
|
case "response.function_call_arguments.delta":
|
||||||
// Copilot sends tool call arguments via this event type (not response.output_item.delta).
|
// Copilot sends tool call arguments via this event type (not response.output_item.delta).
|
||||||
// Data format: {"delta":"...", "item_id":"...", "output_index":N, ...}
|
// Data format: {"delta":"...", "item_id":"...", "output_index":N, ...}
|
||||||
@@ -1188,7 +1268,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
||||||
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
||||||
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
||||||
results = append(results, "event: content_block_delta\ndata: "+inputDelta+"\n\n")
|
appendResult("event: content_block_delta\ndata: " + inputDelta + "\n\n")
|
||||||
case "response.output_item.done":
|
case "response.output_item.done":
|
||||||
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
||||||
break
|
break
|
||||||
@@ -1199,7 +1279,7 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
}
|
}
|
||||||
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
||||||
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", tool.Index)
|
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", tool.Index)
|
||||||
results = append(results, "event: content_block_stop\ndata: "+contentBlockStop+"\n\n")
|
appendResult("event: content_block_stop\ndata: " + contentBlockStop + "\n\n")
|
||||||
case "response.completed":
|
case "response.completed":
|
||||||
ensureMessageStart()
|
ensureMessageStart()
|
||||||
stopTextBlockIfNeeded()
|
stopTextBlockIfNeeded()
|
||||||
@@ -1223,8 +1303,8 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
if cachedTokens > 0 {
|
if cachedTokens > 0 {
|
||||||
messageDelta, _ = sjson.Set(messageDelta, "usage.cache_read_input_tokens", cachedTokens)
|
messageDelta, _ = sjson.Set(messageDelta, "usage.cache_read_input_tokens", cachedTokens)
|
||||||
}
|
}
|
||||||
results = append(results, "event: message_delta\ndata: "+messageDelta+"\n\n")
|
appendResult("event: message_delta\ndata: " + messageDelta + "\n\n")
|
||||||
results = append(results, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
appendResult("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||||
state.MessageStopSent = true
|
state.MessageStopSent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1236,3 +1316,99 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
|||||||
func isHTTPSuccess(statusCode int) bool {
|
func isHTTPSuccess(statusCode int) bool {
|
||||||
return statusCode >= 200 && statusCode < 300
|
return statusCode >= 200 && statusCode < 300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultCopilotContextLength is the default context window for unknown Copilot models.
|
||||||
|
defaultCopilotContextLength = 128000
|
||||||
|
// defaultCopilotMaxCompletionTokens is the default max output tokens for unknown Copilot models.
|
||||||
|
defaultCopilotMaxCompletionTokens = 16384
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API.
|
||||||
|
// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token,
|
||||||
|
// then queries the /models endpoint. Falls back to the static registry on any failure.
|
||||||
|
func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
|
||||||
|
if auth == nil {
|
||||||
|
log.Debug("github-copilot: auth is nil, using static models")
|
||||||
|
return registry.GetGitHubCopilotModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||||
|
if accessToken == "" {
|
||||||
|
log.Debug("github-copilot: no access_token in auth metadata, using static models")
|
||||||
|
return registry.GetGitHubCopilotModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
copilotAuth := copilotauth.NewCopilotAuth(cfg)
|
||||||
|
|
||||||
|
entries, err := copilotAuth.ListModelsWithGitHubToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err)
|
||||||
|
return registry.GetGitHubCopilotModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
log.Debug("github-copilot: API returned no models, using static models")
|
||||||
|
return registry.GetGitHubCopilotModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a lookup from the static definitions so we can enrich dynamic entries
|
||||||
|
// with known context lengths, thinking support, etc.
|
||||||
|
staticMap := make(map[string]*registry.ModelInfo)
|
||||||
|
for _, m := range registry.GetGitHubCopilotModels() {
|
||||||
|
staticMap[m.ID] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
models := make([]*registry.ModelInfo, 0, len(entries))
|
||||||
|
seen := make(map[string]struct{}, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Deduplicate model IDs to avoid incorrect reference counting.
|
||||||
|
if _, dup := seen[entry.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[entry.ID] = struct{}{}
|
||||||
|
|
||||||
|
m := ®istry.ModelInfo{
|
||||||
|
ID: entry.ID,
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "github-copilot",
|
||||||
|
Type: "github-copilot",
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Created > 0 {
|
||||||
|
m.Created = entry.Created
|
||||||
|
}
|
||||||
|
if entry.Name != "" {
|
||||||
|
m.DisplayName = entry.Name
|
||||||
|
} else {
|
||||||
|
m.DisplayName = entry.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge known metadata from the static fallback list
|
||||||
|
if static, ok := staticMap[entry.ID]; ok {
|
||||||
|
if m.DisplayName == entry.ID && static.DisplayName != "" {
|
||||||
|
m.DisplayName = static.DisplayName
|
||||||
|
}
|
||||||
|
m.Description = static.Description
|
||||||
|
m.ContextLength = static.ContextLength
|
||||||
|
m.MaxCompletionTokens = static.MaxCompletionTokens
|
||||||
|
m.SupportedEndpoints = static.SupportedEndpoints
|
||||||
|
m.Thinking = static.Thinking
|
||||||
|
} else {
|
||||||
|
// Sensible defaults for models not in the static list
|
||||||
|
m.Description = entry.ID + " via GitHub Copilot"
|
||||||
|
m.ContextLength = defaultCopilotContextLength
|
||||||
|
m.MaxCompletionTokens = defaultCopilotMaxCompletionTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
models = append(models, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("github-copilot: fetched %d models from API", len(models))
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
@@ -70,6 +71,29 @@ func TestUseGitHubCopilotResponsesEndpoint_CodexModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUseGitHubCopilotResponsesEndpoint_RegistryResponsesOnlyModel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if !useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "gpt-5.4") {
|
||||||
|
t.Fatal("expected responses-only registry model to use /responses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUseGitHubCopilotResponsesEndpoint_DynamicRegistryWinsOverStatic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reg := registry.GetGlobalRegistry()
|
||||||
|
clientID := "github-copilot-test-client"
|
||||||
|
reg.RegisterClient(clientID, "github-copilot", []*registry.ModelInfo{{
|
||||||
|
ID: "gpt-5.4",
|
||||||
|
SupportedEndpoints: []string{"/chat/completions", "/responses"},
|
||||||
|
}})
|
||||||
|
defer reg.UnregisterClient(clientID)
|
||||||
|
|
||||||
|
if useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "gpt-5.4") {
|
||||||
|
t.Fatal("expected dynamic registry definition to take precedence over static fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUseGitHubCopilotResponsesEndpoint_DefaultChat(t *testing.T) {
|
func TestUseGitHubCopilotResponsesEndpoint_DefaultChat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "claude-3-5-sonnet") {
|
if useGitHubCopilotResponsesEndpoint(sdktranslator.FromString("openai"), "claude-3-5-sonnet") {
|
||||||
@@ -132,6 +156,19 @@ func TestNormalizeGitHubCopilotResponsesInput_NonStringInputStringified(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeGitHubCopilotResponsesInput_StripsServiceTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
body := []byte(`{"input":"user text","service_tier":"default"}`)
|
||||||
|
got := normalizeGitHubCopilotResponsesInput(body)
|
||||||
|
|
||||||
|
if gjson.GetBytes(got, "service_tier").Exists() {
|
||||||
|
t.Fatalf("service_tier should be removed, got %s", gjson.GetBytes(got, "service_tier").Raw)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(got, "input").String() != "user text" {
|
||||||
|
t.Fatalf("input = %q, want %q", gjson.GetBytes(got, "input").String(), "user text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeGitHubCopilotResponsesTools_FlattenFunctionTools(t *testing.T) {
|
func TestNormalizeGitHubCopilotResponsesTools_FlattenFunctionTools(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
body := []byte(`{"tools":[{"type":"function","function":{"name":"sum","description":"d","parameters":{"type":"object"}}},{"type":"web_search"}]}`)
|
body := []byte(`{"tools":[{"type":"function","function":{"name":"sum","description":"d","parameters":{"type":"object"}}},{"type":"web_search"}]}`)
|
||||||
@@ -262,15 +299,15 @@ func TestApplyHeaders_XInitiator_UserOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyHeaders_XInitiator_AgentWithAssistantAndUserToolResult(t *testing.T) {
|
func TestApplyHeaders_XInitiator_UserWhenLastRoleIsUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
e := &GitHubCopilotExecutor{}
|
e := &GitHubCopilotExecutor{}
|
||||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||||
// Claude Code typical flow: last message is user (tool result), but has assistant in history
|
// Last role governs the initiator decision.
|
||||||
body := []byte(`{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"I will read the file"},{"role":"user","content":"tool result here"}]}`)
|
body := []byte(`{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"I will read the file"},{"role":"user","content":"tool result here"}]}`)
|
||||||
e.applyHeaders(req, "token", body)
|
e.applyHeaders(req, "token", body)
|
||||||
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
if got := req.Header.Get("X-Initiator"); got != "user" {
|
||||||
t.Fatalf("X-Initiator = %q, want agent (assistant exists in messages)", got)
|
t.Fatalf("X-Initiator = %q, want user (last role is user)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +322,39 @@ func TestApplyHeaders_XInitiator_AgentWithToolRole(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyHeaders_XInitiator_InputArrayLastAssistantMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := &GitHubCopilotExecutor{}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||||
|
body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}]}`)
|
||||||
|
e.applyHeaders(req, "token", body)
|
||||||
|
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
||||||
|
t.Fatalf("X-Initiator = %q, want agent (last role is assistant)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyHeaders_XInitiator_InputArrayLastUserMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := &GitHubCopilotExecutor{}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||||
|
body := []byte(`{"input":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I can help"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"Do X"}]}]}`)
|
||||||
|
e.applyHeaders(req, "token", body)
|
||||||
|
if got := req.Header.Get("X-Initiator"); got != "user" {
|
||||||
|
t.Fatalf("X-Initiator = %q, want user (last role is user)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyHeaders_XInitiator_InputArrayLastFunctionCallOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := &GitHubCopilotExecutor{}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||||
|
body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Use tool"}]},{"type":"function_call","call_id":"c1","name":"Read","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"ok"}]}`)
|
||||||
|
e.applyHeaders(req, "token", body)
|
||||||
|
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
||||||
|
t.Fatalf("X-Initiator = %q, want agent (last item maps to tool role)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests for x-github-api-version header (Problem M) ---
|
// --- Tests for x-github-api-version header (Problem M) ---
|
||||||
|
|
||||||
func TestApplyHeaders_GitHubAPIVersion(t *testing.T) {
|
func TestApplyHeaders_GitHubAPIVersion(t *testing.T) {
|
||||||
|
|||||||
1374
internal/runtime/executor/gitlab_executor.go
Normal file
1374
internal/runtime/executor/gitlab_executor.go
Normal file
File diff suppressed because it is too large
Load Diff
539
internal/runtime/executor/gitlab_executor_test.go
Normal file
539
internal/runtime/executor/gitlab_executor_test.go
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteUsesChatEndpoint(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != gitLabChatEndpoint {
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`"chat response"`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"access_token": "oauth-access",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"hello"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "chat response" {
|
||||||
|
t.Fatalf("expected chat response, got %q", got)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "model").String(); got != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("expected resolved model, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteFallsBackToCodeSuggestions(t *testing.T) {
|
||||||
|
chatCalls := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case gitLabChatEndpoint:
|
||||||
|
chatCalls++
|
||||||
|
http.Error(w, "feature unavailable", http.StatusForbidden)
|
||||||
|
case gitLabCodeSuggestionsEndpoint:
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"text": "fallback response",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"personal_access_token": "glpat-token",
|
||||||
|
"auth_method": "pat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"write code"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if chatCalls != 1 {
|
||||||
|
t.Fatalf("expected chat endpoint to be tried once, got %d", chatCalls)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "fallback response" {
|
||||||
|
t.Fatalf("expected fallback response, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteUsesAnthropicGateway(t *testing.T) {
|
||||||
|
var gotAuthHeader, gotRealmHeader string
|
||||||
|
var gotPath string
|
||||||
|
var gotModel string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuthHeader = r.Header.Get("Authorization")
|
||||||
|
gotRealmHeader = r.Header.Get("X-Gitlab-Realm")
|
||||||
|
gotModel = gjson.GetBytes(readBody(t, r), "model").String()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"cmd":"ls"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":4}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"duo_gateway_base_url": srv.URL,
|
||||||
|
"duo_gateway_token": "gateway-token",
|
||||||
|
"duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{
|
||||||
|
"model":"gitlab-duo",
|
||||||
|
"messages":[{"role":"user","content":[{"type":"text","text":"list files"}]}],
|
||||||
|
"tools":[{"name":"Bash","description":"run bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}],
|
||||||
|
"max_tokens":128
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("claude"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/proxy/anthropic/v1/messages" {
|
||||||
|
t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages")
|
||||||
|
}
|
||||||
|
if gotAuthHeader != "Bearer gateway-token" {
|
||||||
|
t.Fatalf("Authorization = %q, want Bearer gateway-token", gotAuthHeader)
|
||||||
|
}
|
||||||
|
if gotRealmHeader != "saas" {
|
||||||
|
t.Fatalf("X-Gitlab-Realm = %q, want saas", gotRealmHeader)
|
||||||
|
}
|
||||||
|
if gotModel != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("model = %q, want claude-sonnet-4-5", gotModel)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "content.0.type").String(); got != "tool_use" {
|
||||||
|
t.Fatalf("expected tool_use response, got %q", got)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "Bash" {
|
||||||
|
t.Fatalf("expected tool name Bash, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteUsesOpenAIGateway(t *testing.T) {
|
||||||
|
var gotAuthHeader, gotRealmHeader string
|
||||||
|
var gotPath string
|
||||||
|
var gotModel string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuthHeader = r.Header.Get("Authorization")
|
||||||
|
gotRealmHeader = r.Header.Get("X-Gitlab-Realm")
|
||||||
|
gotModel = gjson.GetBytes(readBody(t, r), "model").String()
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello from openai gateway\"}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"gpt-5-codex\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello from openai gateway\"}]}],\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"total_tokens\":15}}}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"duo_gateway_base_url": srv.URL,
|
||||||
|
"duo_gateway_token": "gateway-token",
|
||||||
|
"duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_provider": "openai",
|
||||||
|
"model_name": "gpt-5-codex",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":"hello"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/proxy/openai/v1/responses" {
|
||||||
|
t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/openai/v1/responses")
|
||||||
|
}
|
||||||
|
if gotAuthHeader != "Bearer gateway-token" {
|
||||||
|
t.Fatalf("Authorization = %q, want Bearer gateway-token", gotAuthHeader)
|
||||||
|
}
|
||||||
|
if gotRealmHeader != "saas" {
|
||||||
|
t.Fatalf("X-Gitlab-Realm = %q, want saas", gotRealmHeader)
|
||||||
|
}
|
||||||
|
if gotModel != "gpt-5-codex" {
|
||||||
|
t.Fatalf("model = %q, want gpt-5-codex", gotModel)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "hello from openai gateway" {
|
||||||
|
t.Fatalf("expected openai gateway response, got %q payload=%s", got, string(resp.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteUsesRequestedModelToSelectOpenAIGateway(t *testing.T) {
|
||||||
|
var gotAuthHeader, gotRealmHeader, gotBetaHeader, gotUserAgent string
|
||||||
|
var gotPath string
|
||||||
|
var gotModel string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuthHeader = r.Header.Get("Authorization")
|
||||||
|
gotRealmHeader = r.Header.Get("X-Gitlab-Realm")
|
||||||
|
gotBetaHeader = r.Header.Get("anthropic-beta")
|
||||||
|
gotUserAgent = r.Header.Get("User-Agent")
|
||||||
|
gotModel = gjson.GetBytes(readBody(t, r), "model").String()
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"duo-chat-gpt-5-codex\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello from explicit openai model\"}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1710000000,\"model\":\"duo-chat-gpt-5-codex\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello from explicit openai model\"}]}],\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"total_tokens\":15}}}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"duo_gateway_base_url": srv.URL,
|
||||||
|
"duo_gateway_token": "gateway-token",
|
||||||
|
"duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "duo-chat-gpt-5-codex",
|
||||||
|
Payload: []byte(`{"model":"duo-chat-gpt-5-codex","messages":[{"role":"user","content":"hello"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/proxy/openai/v1/responses" {
|
||||||
|
t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/openai/v1/responses")
|
||||||
|
}
|
||||||
|
if gotAuthHeader != "Bearer gateway-token" {
|
||||||
|
t.Fatalf("Authorization = %q, want Bearer gateway-token", gotAuthHeader)
|
||||||
|
}
|
||||||
|
if gotRealmHeader != "saas" {
|
||||||
|
t.Fatalf("X-Gitlab-Realm = %q, want saas", gotRealmHeader)
|
||||||
|
}
|
||||||
|
if gotBetaHeader != gitLabContext1MBeta {
|
||||||
|
t.Fatalf("anthropic-beta = %q, want %q", gotBetaHeader, gitLabContext1MBeta)
|
||||||
|
}
|
||||||
|
if gotUserAgent != gitLabNativeUserAgent {
|
||||||
|
t.Fatalf("User-Agent = %q, want %q", gotUserAgent, gitLabNativeUserAgent)
|
||||||
|
}
|
||||||
|
if gotModel != "duo-chat-gpt-5-codex" {
|
||||||
|
t.Fatalf("model = %q, want duo-chat-gpt-5-codex", gotModel)
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "hello from explicit openai model" {
|
||||||
|
t.Fatalf("expected explicit openai model response, got %q payload=%s", got, string(resp.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorRefreshUpdatesMetadata(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "oauth-refreshed",
|
||||||
|
"refresh_token": "oauth-refresh",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api read_user",
|
||||||
|
"created_at": 1710000000,
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
case "/api/v4/code_suggestions/direct_access":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"base_url": "https://cloud.gitlab.example.com",
|
||||||
|
"token": "gateway-token",
|
||||||
|
"expires_at": 1710003600,
|
||||||
|
"headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_details": map[string]any{
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
ID: "gitlab-auth.json",
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"access_token": "oauth-access",
|
||||||
|
"refresh_token": "oauth-refresh",
|
||||||
|
"oauth_client_id": "client-id",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"oauth_expires_at": "2000-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := exec.Refresh(context.Background(), auth)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := updated.Metadata["access_token"]; got != "oauth-refreshed" {
|
||||||
|
t.Fatalf("expected refreshed access token, got %#v", got)
|
||||||
|
}
|
||||||
|
if got := updated.Metadata["model_name"]; got != "claude-sonnet-4-5" {
|
||||||
|
t.Fatalf("expected refreshed model metadata, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteStreamUsesCodeSuggestionsSSE(t *testing.T) {
|
||||||
|
var gotAccept, gotStreamingHeader, gotEncoding string
|
||||||
|
var gotStreamFlag bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != gitLabCodeSuggestionsEndpoint {
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
gotStreamingHeader = r.Header.Get(gitLabSSEStreamingHeader)
|
||||||
|
gotEncoding = r.Header.Get("Accept-Encoding")
|
||||||
|
gotStreamFlag = gjson.GetBytes(readBody(t, r), "stream").Bool()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("event: stream_start\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"model\":{\"name\":\"claude-sonnet-4-5\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: content_chunk\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"content\":\"hello\"}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: content_chunk\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"content\":\" world\"}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: stream_end\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"access_token": "oauth-access",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","stream":true,"messages":[{"role":"user","content":"hello"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteStream() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := collectStreamLines(t, result)
|
||||||
|
if gotAccept != "text/event-stream" {
|
||||||
|
t.Fatalf("Accept = %q, want text/event-stream", gotAccept)
|
||||||
|
}
|
||||||
|
if gotStreamingHeader != "true" {
|
||||||
|
t.Fatalf("%s = %q, want true", gitLabSSEStreamingHeader, gotStreamingHeader)
|
||||||
|
}
|
||||||
|
if gotEncoding != "identity" {
|
||||||
|
t.Fatalf("Accept-Encoding = %q, want identity", gotEncoding)
|
||||||
|
}
|
||||||
|
if !gotStreamFlag {
|
||||||
|
t.Fatalf("expected upstream request to set stream=true")
|
||||||
|
}
|
||||||
|
if len(lines) < 4 {
|
||||||
|
t.Fatalf("expected translated stream chunks, got %d", len(lines))
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), `"content":"hello"`) {
|
||||||
|
t.Fatalf("expected hello delta in stream, got %q", strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), `"content":" world"`) {
|
||||||
|
t.Fatalf("expected world delta in stream, got %q", strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
last := lines[len(lines)-1]
|
||||||
|
if last != "data: [DONE]" && !strings.Contains(last, `"finish_reason":"stop"`) {
|
||||||
|
t.Fatalf("expected stream terminator, got %q", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteStreamFallsBackToSyntheticChat(t *testing.T) {
|
||||||
|
chatCalls := 0
|
||||||
|
streamCalls := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case gitLabCodeSuggestionsEndpoint:
|
||||||
|
streamCalls++
|
||||||
|
http.Error(w, "feature unavailable", http.StatusForbidden)
|
||||||
|
case gitLabChatEndpoint:
|
||||||
|
chatCalls++
|
||||||
|
_, _ = w.Write([]byte(`"chat fallback response"`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"base_url": srv.URL,
|
||||||
|
"access_token": "oauth-access",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","stream":true,"messages":[{"role":"user","content":"hello"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("openai"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteStream() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := collectStreamLines(t, result)
|
||||||
|
if streamCalls != 1 {
|
||||||
|
t.Fatalf("expected streaming endpoint once, got %d", streamCalls)
|
||||||
|
}
|
||||||
|
if chatCalls != 1 {
|
||||||
|
t.Fatalf("expected chat fallback once, got %d", chatCalls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), `"content":"chat fallback response"`) {
|
||||||
|
t.Fatalf("expected fallback content in stream, got %q", strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitLabExecutorExecuteStreamUsesAnthropicGateway(t *testing.T) {
|
||||||
|
var gotPath, gotBetaHeader, gotUserAgent string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotBetaHeader = r.Header.Get("Anthropic-Beta")
|
||||||
|
gotUserAgent = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("event: message_start\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: content_block_start\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello from gateway\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: message_delta\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":3}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("event: message_stop\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
exec := NewGitLabExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "gitlab",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"duo_gateway_base_url": srv.URL,
|
||||||
|
"duo_gateway_token": "gateway-token",
|
||||||
|
"duo_gateway_headers": map[string]string{"X-Gitlab-Realm": "saas"},
|
||||||
|
"model_provider": "anthropic",
|
||||||
|
"model_name": "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gitlab-duo",
|
||||||
|
Payload: []byte(`{"model":"gitlab-duo","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"max_tokens":64}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("claude"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteStream() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := collectStreamLines(t, result)
|
||||||
|
if gotPath != "/v1/proxy/anthropic/v1/messages" {
|
||||||
|
t.Fatalf("Path = %q, want %q", gotPath, "/v1/proxy/anthropic/v1/messages")
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBetaHeader, gitLabContext1MBeta) {
|
||||||
|
t.Fatalf("Anthropic-Beta = %q, want to contain %q", gotBetaHeader, gitLabContext1MBeta)
|
||||||
|
}
|
||||||
|
if gotUserAgent != gitLabNativeUserAgent {
|
||||||
|
t.Fatalf("User-Agent = %q, want %q", gotUserAgent, gitLabNativeUserAgent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), "hello from gateway") {
|
||||||
|
t.Fatalf("expected anthropic gateway stream, got %q", strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectStreamLines(t *testing.T, result *cliproxyexecutor.StreamResult) []string {
|
||||||
|
t.Helper()
|
||||||
|
lines := make([]string, 0, 8)
|
||||||
|
for chunk := range result.Chunks {
|
||||||
|
if chunk.Err != nil {
|
||||||
|
t.Fatalf("unexpected stream error: %v", chunk.Err)
|
||||||
|
}
|
||||||
|
lines = append(lines, string(chunk.Payload))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(t *testing.T, r *http.Request) []byte {
|
||||||
|
t.Helper()
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll() error = %v", err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
// the original model name in the response for client compatibility.
|
// the original model name in the response for client compatibility.
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
}
|
}
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
@@ -315,7 +315,7 @@ func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
|
|
||||||
usageJSON := buildOpenAIUsageJSON(count)
|
usageJSON := buildOpenAIUsageJSON(count)
|
||||||
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil
|
return cliproxyexecutor.Response{Payload: translated}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key.
|
// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key.
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
// the original model name in the response for client compatibility.
|
// the original model name in the response for client compatibility.
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,12 +271,12 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
}
|
}
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
for i := range doneChunks {
|
for i := range doneChunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
recordAPIResponseError(ctx, e.cfg, errScan)
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ var endpointAliases = map[string]string{
|
|||||||
"cli": "amazonq",
|
"cli": "amazonq",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enqueueTranslatedSSE(out chan<- cliproxyexecutor.StreamChunk, chunk []byte) {
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Payload: append(bytes.Clone(chunk), '\n', '\n')}
|
||||||
|
}
|
||||||
|
|
||||||
// retryConfig holds configuration for socket retry logic.
|
// retryConfig holds configuration for socket retry logic.
|
||||||
// Based on kiro2Api Python implementation patterns.
|
// Based on kiro2Api Python implementation patterns.
|
||||||
type retryConfig struct {
|
type retryConfig struct {
|
||||||
@@ -2458,7 +2465,6 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
|
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
|
||||||
var totalUsage usage.Detail
|
var totalUsage usage.Detail
|
||||||
var hasToolUses bool // Track if any tool uses were emitted
|
var hasToolUses bool // Track if any tool uses were emitted
|
||||||
var hasTruncatedTools bool // Track if any tool uses were truncated
|
|
||||||
var upstreamStopReason string // Track stop_reason from upstream events
|
var upstreamStopReason string // Track stop_reason from upstream events
|
||||||
|
|
||||||
// Tool use state tracking for input buffering and deduplication
|
// Tool use state tracking for input buffering and deduplication
|
||||||
@@ -2574,9 +2580,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", currentToolUse.ToolUseID, currentToolUse.Name)
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", currentToolUse.ToolUseID, currentToolUse.Name)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send tool input as delta
|
// Send tool input as delta
|
||||||
@@ -2584,18 +2588,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputBytes), contentBlockIndex)
|
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputBytes), contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close block
|
// Close block
|
||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasToolUses = true
|
hasToolUses = true
|
||||||
@@ -2665,9 +2665,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
msgStart := kiroclaude.BuildClaudeMessageStartEvent(model, totalUsage.InputTokens)
|
msgStart := kiroclaude.BuildClaudeMessageStartEvent(model, totalUsage.InputTokens)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
messageStartSent = true
|
messageStartSent = true
|
||||||
}
|
}
|
||||||
@@ -2917,9 +2915,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
pingEvent := kiroclaude.BuildClaudePingEventWithUsage(totalUsage.InputTokens, currentOutputTokens)
|
pingEvent := kiroclaude.BuildClaudePingEventWithUsage(totalUsage.InputTokens, currentOutputTokens)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, pingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, pingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastReportedOutputTokens = currentOutputTokens
|
lastReportedOutputTokens = currentOutputTokens
|
||||||
@@ -2940,17 +2936,13 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processText, contentBlockIndex)
|
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processText, contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -2979,18 +2971,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Send thinking delta
|
// Send thinking delta
|
||||||
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
|
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
accumulatedThinkingContent.WriteString(thinkingText)
|
accumulatedThinkingContent.WriteString(thinkingText)
|
||||||
}
|
}
|
||||||
@@ -2999,9 +2987,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isThinkingBlockOpen = false
|
isThinkingBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3030,17 +3016,13 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
|
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
accumulatedThinkingContent.WriteString(processContent)
|
accumulatedThinkingContent.WriteString(processContent)
|
||||||
}
|
}
|
||||||
@@ -3059,9 +3041,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isThinkingBlockOpen = false
|
isThinkingBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3072,18 +3052,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Send text delta
|
// Send text delta
|
||||||
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
|
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Close text block before entering thinking
|
// Close text block before entering thinking
|
||||||
@@ -3091,9 +3067,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isTextBlockOpen = false
|
isTextBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3121,17 +3095,13 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
|
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3159,9 +3129,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isTextBlockOpen = false
|
isTextBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3172,9 +3140,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", toolUseID, toolName)
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", toolUseID, toolName)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send input_json_delta with the tool input
|
// Send input_json_delta with the tool input
|
||||||
@@ -3187,9 +3153,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3198,9 +3162,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3240,9 +3202,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isTextBlockOpen = false
|
isTextBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3255,9 +3215,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3265,9 +3223,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
|
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accumulate for token counting
|
// Accumulate for token counting
|
||||||
@@ -3286,59 +3242,9 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
|
|
||||||
// Emit completed tool uses
|
// Emit completed tool uses
|
||||||
for _, tu := range completedToolUses {
|
for _, tu := range completedToolUses {
|
||||||
// Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker
|
// Skip truncated tools - don't emit fake marker tool_use
|
||||||
if tu.IsTruncated {
|
if tu.IsTruncated {
|
||||||
hasTruncatedTools = true
|
log.Warnf("kiro: streamToChannel skipping truncated tool: %s (ID: %s)", tu.Name, tu.ToolUseID)
|
||||||
log.Infof("kiro: streamToChannel emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", tu.Name, tu.ToolUseID)
|
|
||||||
|
|
||||||
// Close text block if open
|
|
||||||
if isTextBlockOpen && contentBlockIndex >= 0 {
|
|
||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
|
||||||
for _, chunk := range sseData {
|
|
||||||
if chunk != "" {
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isTextBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
contentBlockIndex++
|
|
||||||
|
|
||||||
// Emit tool_use with SOFT_LIMIT_REACHED marker input
|
|
||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
|
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
|
||||||
for _, chunk := range sseData {
|
|
||||||
if chunk != "" {
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SOFT_LIMIT_REACHED marker input
|
|
||||||
markerInput := map[string]interface{}{
|
|
||||||
"_status": "SOFT_LIMIT_REACHED",
|
|
||||||
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
|
|
||||||
}
|
|
||||||
|
|
||||||
markerJSON, _ := json.Marshal(markerInput)
|
|
||||||
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(markerJSON), contentBlockIndex)
|
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
|
||||||
for _, chunk := range sseData {
|
|
||||||
if chunk != "" {
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close tool_use block
|
|
||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
|
||||||
for _, chunk := range sseData {
|
|
||||||
if chunk != "" {
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasToolUses = true // Keep this so stop_reason = tool_use
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3349,9 +3255,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isTextBlockOpen = false
|
isTextBlockOpen = false
|
||||||
}
|
}
|
||||||
@@ -3361,9 +3265,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
|
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tu.Input != nil {
|
if tu.Input != nil {
|
||||||
@@ -3374,9 +3276,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3384,9 +3284,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3573,9 +3471,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3640,12 +3536,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
|
// Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
|
||||||
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
|
|
||||||
stopReason := upstreamStopReason
|
stopReason := upstreamStopReason
|
||||||
if hasTruncatedTools {
|
|
||||||
// Log that we're using SOFT_LIMIT_REACHED approach
|
|
||||||
log.Infof("kiro: streamToChannel using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use for truncated tools")
|
|
||||||
}
|
|
||||||
if stopReason == "" {
|
if stopReason == "" {
|
||||||
if hasToolUses {
|
if hasToolUses {
|
||||||
stopReason = "tool_use"
|
stopReason = "tool_use"
|
||||||
@@ -3665,18 +3556,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
msgDelta := kiroclaude.BuildClaudeMessageDeltaEvent(stopReason, totalUsage)
|
msgDelta := kiroclaude.BuildClaudeMessageDeltaEvent(stopReason, totalUsage)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgDelta, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgDelta, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send message_stop event separately
|
// Send message_stop event separately
|
||||||
msgStop := kiroclaude.BuildClaudeMessageStopOnlyEvent()
|
msgStop := kiroclaude.BuildClaudeMessageStopOnlyEvent()
|
||||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStop, &translatorParam)
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStop, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
enqueueTranslatedSSE(out, chunk)
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// reporter.publish is called via defer
|
// reporter.publish is called via defer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
|||||||
// Translate response back to source format when needed
|
// Translate response back to source format when needed
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()}
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +205,10 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request usage data in the final streaming chunk so that token statistics
|
||||||
|
// are captured even when the upstream is an OpenAI-compatible provider.
|
||||||
|
translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true)
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -286,7 +290,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
|||||||
// Pass through translator; it yields one or more chunks for the target schema.
|
// Pass through translator; it yields one or more chunks for the target schema.
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
@@ -326,7 +330,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
|
|||||||
|
|
||||||
usageJSON := buildOpenAIUsageJSON(count)
|
usageJSON := buildOpenAIUsageJSON(count)
|
||||||
translatedUsage := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
translatedUsage := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
||||||
return cliproxyexecutor.Response{Payload: []byte(translatedUsage)}, nil
|
return cliproxyexecutor.Response{Payload: translatedUsage}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh is a no-op for API-key based compatibility providers.
|
// Refresh is a no-op for API-key based compatibility providers.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user