Skip to content

Commit a279192

Browse files
authored
Merge pull request router-for-me#498 from router-for-me/plus
v6.9.17
2 parents 6bb9bf3 + 6a43d72 commit a279192

17 files changed

Lines changed: 663 additions & 160 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: agents-md-guard
2+
3+
on:
4+
pull_request_target:
5+
types:
6+
- opened
7+
- synchronize
8+
- reopened
9+
10+
permissions:
11+
contents: read
12+
issues: write
13+
pull-requests: write
14+
15+
jobs:
16+
close-when-agents-md-changed:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Detect AGENTS.md changes and close PR
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
const prNumber = context.payload.pull_request.number;
24+
const { owner, repo } = context.repo;
25+
26+
const files = await github.paginate(github.rest.pulls.listFiles, {
27+
owner,
28+
repo,
29+
pull_number: prNumber,
30+
per_page: 100,
31+
});
32+
33+
const touchesAgentsMd = (path) =>
34+
typeof path === "string" &&
35+
(path === "AGENTS.md" || path.endsWith("/AGENTS.md"));
36+
37+
const touched = files.filter(
38+
(f) => touchesAgentsMd(f.filename) || touchesAgentsMd(f.previous_filename),
39+
);
40+
41+
if (touched.length === 0) {
42+
core.info("No AGENTS.md changes detected.");
43+
return;
44+
}
45+
46+
const changedList = touched
47+
.map((f) =>
48+
f.previous_filename && f.previous_filename !== f.filename
49+
? `- ${f.previous_filename} -> ${f.filename}`
50+
: `- ${f.filename}`,
51+
)
52+
.join("\n");
53+
54+
const body = [
55+
"This repository does not allow modifying `AGENTS.md` in pull requests.",
56+
"",
57+
"Detected changes:",
58+
changedList,
59+
"",
60+
"Please revert these changes and open a new PR without touching `AGENTS.md`.",
61+
].join("\n");
62+
63+
try {
64+
await github.rest.issues.createComment({
65+
owner,
66+
repo,
67+
issue_number: prNumber,
68+
body,
69+
});
70+
} catch (error) {
71+
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
72+
}
73+
74+
await github.rest.pulls.update({
75+
owner,
76+
repo,
77+
pull_number: prNumber,
78+
state: "closed",
79+
});
80+
81+
core.setFailed("PR modifies AGENTS.md");
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: auto-retarget-main-pr-to-dev
2+
3+
on:
4+
pull_request_target:
5+
types:
6+
- opened
7+
- reopened
8+
- edited
9+
branches:
10+
- main
11+
12+
permissions:
13+
contents: read
14+
issues: write
15+
pull-requests: write
16+
17+
jobs:
18+
retarget:
19+
if: github.actor != 'github-actions[bot]'
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Retarget PR base to dev
23+
uses: actions/github-script@v7
24+
with:
25+
script: |
26+
const pr = context.payload.pull_request;
27+
const prNumber = pr.number;
28+
const { owner, repo } = context.repo;
29+
30+
const baseRef = pr.base?.ref;
31+
const headRef = pr.head?.ref;
32+
const desiredBase = "dev";
33+
34+
if (baseRef !== "main") {
35+
core.info(`PR #${prNumber} base is ${baseRef}; nothing to do.`);
36+
return;
37+
}
38+
39+
if (headRef === desiredBase) {
40+
core.info(`PR #${prNumber} is ${desiredBase} -> main; skipping retarget.`);
41+
return;
42+
}
43+
44+
core.info(`Retargeting PR #${prNumber} base from ${baseRef} to ${desiredBase}.`);
45+
46+
try {
47+
await github.rest.pulls.update({
48+
owner,
49+
repo,
50+
pull_number: prNumber,
51+
base: desiredBase,
52+
});
53+
} catch (error) {
54+
core.setFailed(`Failed to retarget PR #${prNumber} to ${desiredBase}: ${error.message}`);
55+
return;
56+
}
57+
58+
const body = [
59+
`This pull request targeted \`${baseRef}\`.`,
60+
"",
61+
`The base branch has been automatically changed to \`${desiredBase}\`.`,
62+
].join("\n");
63+
64+
try {
65+
await github.rest.issues.createComment({
66+
owner,
67+
repo,
68+
issue_number: prNumber,
69+
body,
70+
});
71+
} catch (error) {
72+
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
73+
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ GEMINI.md
4646
.agents/*
4747
.opencode/*
4848
.idea/*
49+
.beads/*
4950
.bmad/*
5051
_bmad/*
5152
_bmad-output/*

AGENTS.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# AGENTS.md
2+
3+
Go 1.26+ proxy server providing OpenAI/Gemini/Claude/Codex compatible APIs with OAuth and round-robin load balancing.
4+
5+
## Repository
6+
- GitHub: https://github.com/router-for-me/CLIProxyAPI
7+
8+
## Commands
9+
```bash
10+
gofmt -w . # Format (required after Go changes)
11+
go build -o cli-proxy-api ./cmd/server # Build
12+
go run ./cmd/server # Run dev server
13+
go test ./... # Run all tests
14+
go test -v -run TestName ./path/to/pkg # Run single test
15+
go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRED after changes)
16+
```
17+
- Common flags: `--config <path>`, `--tui`, `--standalone`, `--local-model`, `--no-browser`, `--oauth-callback-port <port>`
18+
19+
## Config
20+
- Default config: `config.yaml` (template: `config.example.yaml`)
21+
- `.env` is auto-loaded from the working directory
22+
- Auth material defaults under `auths/`
23+
- Storage backends: file-based default; optional Postgres/git/object store (`PGSTORE_*`, `GITSTORE_*`, `OBJECTSTORE_*`)
24+
25+
## Architecture
26+
- `cmd/server/` — Server entrypoint
27+
- `internal/api/` — Gin HTTP API (routes, middleware, modules)
28+
- `internal/api/modules/amp/` — Amp integration (Amp-style routes + reverse proxy)
29+
- `internal/thinking/` — Main thinking/reasoning pipeline. `ApplyThinking()` (apply.go) parses suffixes (`suffix.go`, suffix overrides body), normalizes config to canonical `ThinkingConfig` (`types.go`), normalizes and validates centrally (`validate.go`/`convert.go`), then applies provider-specific output via `ProviderApplier`. Do not break this "canonical representation → per-provider translation" architecture.
30+
- `internal/runtime/executor/` — Per-provider runtime executors (incl. Codex WebSocket)
31+
- `internal/translator/` — Provider protocol translators (and shared `common`)
32+
- `internal/registry/` — Model registry + remote updater (`StartModelsUpdater`); `--local-model` disables remote updates
33+
- `internal/store/` — Storage implementations and secret resolution
34+
- `internal/managementasset/` — Config snapshots and management assets
35+
- `internal/cache/` — Request signature caching
36+
- `internal/watcher/` — Config hot-reload and watchers
37+
- `internal/wsrelay/` — WebSocket relay sessions
38+
- `internal/usage/` — Usage and token accounting
39+
- `internal/tui/` — Bubbletea terminal UI (`--tui`, `--standalone`)
40+
- `sdk/cliproxy/` — Embeddable SDK entry (service/builder/watchers/pipeline)
41+
- `test/` — Cross-module integration tests
42+
43+
## Code Conventions
44+
- Keep changes small and simple (KISS)
45+
- Comments in English only
46+
- If editing code that already contains non-English comments, translate them to English (don’t add new non-English comments)
47+
- For user-visible strings, keep the existing language used in that file/area
48+
- New Markdown docs should be in English unless the file is explicitly language-specific (e.g. `README_CN.md`)
49+
- As a rule, do not make standalone changes to `internal/translator/`. You may modify it only as part of broader changes elsewhere.
50+
- If a task requires changing only `internal/translator/`, run `gh repo view --json viewerPermission -q .viewerPermission` to confirm you have `WRITE`, `MAINTAIN`, or `ADMIN`. If you do, you may proceed; otherwise, file a GitHub issue including the goal, rationale, and the intended implementation code, then stop further work.
51+
- `internal/runtime/executor/` should contain executors and their unit tests only. Place any helper/supporting files under `internal/runtime/executor/helps/`.
52+
- Follow `gofmt`; keep imports goimports-style; wrap errors with context where helpful
53+
- Do not use `log.Fatal`/`log.Fatalf` (terminates the process); prefer returning errors and logging via logrus
54+
- Shadowed variables: use method suffix (`errStart := server.Start()`)
55+
- Wrap defer errors: `defer func() { if err := f.Close(); err != nil { log.Errorf(...) } }()`
56+
- Use logrus structured logging; avoid leaking secrets/tokens in logs
57+
- Avoid panics in HTTP handlers; prefer logged errors and meaningful HTTP status codes
58+
- Timeouts are allowed only during credential acquisition; after an upstream connection is established, do not set timeouts for any subsequent network behavior. Intentional exceptions that must remain allowed are the Codex websocket liveness deadlines in `internal/runtime/executor/codex_websockets_executor.go`, the wsrelay session deadlines in `internal/wsrelay/session.go`, the management APICall timeout in `internal/api/handlers/management/api_tools.go`, and the `cmd/fetch_antigravity_models` utility timeouts

internal/api/modules/amp/proxy_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,11 @@ func TestModifyResponse_GzipScenarios(t *testing.T) {
129129
wantCE: "",
130130
},
131131
{
132-
name: "skips_non_2xx_status",
132+
name: "decompresses_non_2xx_status_when_gzip_detected",
133133
header: http.Header{},
134134
body: good,
135135
status: 404,
136-
wantBody: good,
136+
wantBody: goodJSON,
137137
wantCE: "",
138138
},
139139
}

internal/runtime/executor/antigravity_executor.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,28 @@ func classifyAntigravity429(body []byte) antigravity429Category {
261261
return antigravity429Unknown
262262
}
263263

264+
func antigravityHasQuotaResetDelayOrModelInfo(body []byte) bool {
265+
if len(body) == 0 {
266+
return false
267+
}
268+
details := gjson.GetBytes(body, "error.details")
269+
if !details.Exists() || !details.IsArray() {
270+
return false
271+
}
272+
for _, detail := range details.Array() {
273+
if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
274+
continue
275+
}
276+
if strings.TrimSpace(detail.Get("metadata.quotaResetDelay").String()) != "" {
277+
return true
278+
}
279+
if strings.TrimSpace(detail.Get("metadata.model").String()) != "" {
280+
return true
281+
}
282+
}
283+
return false
284+
}
285+
264286
func antigravityCreditsRetryEnabled(cfg *config.Config) bool {
265287
return cfg != nil && cfg.QuotaExceeded.AntigravityCredits
266288
}
@@ -362,6 +384,12 @@ func shouldMarkAntigravityCreditsExhausted(statusCode int, body []byte, reqErr e
362384
lowerBody := strings.ToLower(string(body))
363385
for _, keyword := range antigravityCreditsExhaustedKeywords {
364386
if strings.Contains(lowerBody, keyword) {
387+
if keyword == "resource has been exhausted" &&
388+
statusCode == http.StatusTooManyRequests &&
389+
classifyAntigravity429(body) == antigravity429Unknown &&
390+
!antigravityHasQuotaResetDelayOrModelInfo(body) {
391+
return false
392+
}
365393
return true
366394
}
367395
}
@@ -575,6 +603,14 @@ attemptLoop:
575603
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
576604
continue
577605
}
606+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
607+
delay := antigravityTransient429RetryDelay(attempt)
608+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
609+
if errWait := antigravityWait(ctx, delay); errWait != nil {
610+
return resp, errWait
611+
}
612+
continue attemptLoop
613+
}
578614
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
579615
if idx+1 < len(baseURLs) {
580616
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -742,6 +778,14 @@ attemptLoop:
742778
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
743779
continue
744780
}
781+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
782+
delay := antigravityTransient429RetryDelay(attempt)
783+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
784+
if errWait := antigravityWait(ctx, delay); errWait != nil {
785+
return resp, errWait
786+
}
787+
continue attemptLoop
788+
}
745789
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
746790
if idx+1 < len(baseURLs) {
747791
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -1158,6 +1202,14 @@ attemptLoop:
11581202
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
11591203
continue
11601204
}
1205+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
1206+
delay := antigravityTransient429RetryDelay(attempt)
1207+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
1208+
if errWait := antigravityWait(ctx, delay); errWait != nil {
1209+
return nil, errWait
1210+
}
1211+
continue attemptLoop
1212+
}
11611213
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
11621214
if idx+1 < len(baseURLs) {
11631215
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -1774,6 +1826,24 @@ func antigravityShouldRetryNoCapacity(statusCode int, body []byte) bool {
17741826
return strings.Contains(msg, "no capacity available")
17751827
}
17761828

1829+
func antigravityShouldRetryTransientResourceExhausted429(statusCode int, body []byte) bool {
1830+
if statusCode != http.StatusTooManyRequests {
1831+
return false
1832+
}
1833+
if len(body) == 0 {
1834+
return false
1835+
}
1836+
if classifyAntigravity429(body) != antigravity429Unknown {
1837+
return false
1838+
}
1839+
status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String())
1840+
if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") {
1841+
return false
1842+
}
1843+
msg := strings.ToLower(string(body))
1844+
return strings.Contains(msg, "resource has been exhausted")
1845+
}
1846+
17771847
func antigravityNoCapacityRetryDelay(attempt int) time.Duration {
17781848
if attempt < 0 {
17791849
attempt = 0
@@ -1785,6 +1855,17 @@ func antigravityNoCapacityRetryDelay(attempt int) time.Duration {
17851855
return delay
17861856
}
17871857

1858+
func antigravityTransient429RetryDelay(attempt int) time.Duration {
1859+
if attempt < 0 {
1860+
attempt = 0
1861+
}
1862+
delay := time.Duration(attempt+1) * 100 * time.Millisecond
1863+
if delay > 500*time.Millisecond {
1864+
delay = 500 * time.Millisecond
1865+
}
1866+
return delay
1867+
}
1868+
17881869
func antigravityWait(ctx context.Context, wait time.Duration) error {
17891870
if wait <= 0 {
17901871
return nil

0 commit comments

Comments
 (0)