Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ blob-report
docs
*.md
!README.md
!docs/openapi.yaml

# Electron (separate build)
electron
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"

jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50 changes: 50 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Claude Code

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]

jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'

# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM node:24.15.0-trixie-slim AS builder
WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& apt-get install -y --no-install-recommends libsecret-1-0 libsecret-1-dev pkg-config ca-certificates python3 make g++ \
&& rm -rf /var/lib/apt/lists/*

COPY package*.json ./
Expand Down
59 changes: 55 additions & 4 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ import {
buildAccountSemaphoreKey,
markBlocked as markAccountSemaphoreBlocked,
} from "../services/accountSemaphore.ts";
import { lockModelIfPerModelQuota } from "../services/accountFallback.ts";
import { lockModel, lockModelIfPerModelQuota } from "../services/accountFallback.ts";
import {
generateSignature,
getCachedResponse,
Expand Down Expand Up @@ -178,6 +178,11 @@ import {
import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts";
import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits";
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
import {
classifyModelScope429,
getModelScopeRetryDelayMs,
isModelScopeProvider,
} from "../services/modelscopePolicy.ts";

const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
const CHAT_LOG_TEXT_LIMIT = 64 * 1024;
Expand Down Expand Up @@ -989,6 +994,7 @@ export async function handleChatCore({
let { provider, model, extendedContext } = modelInfo;
const requestedModel =
typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model;
const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData);
const startTime = Date.now();
// Per-request trace id + checkpoint helper. Lets us see exactly which await
// a hung request was sitting on in `[STAGE_TRACE]` log lines.
Expand Down Expand Up @@ -2799,7 +2805,14 @@ export async function handleChatCore({
async () => {
trace("inside_rate_limit");
let attempts = 0;
const maxAttempts = provider === "qwen" ? 3 : provider === "codex" ? 3 : 1;
const isModelScopeForRequest = isModelScope();
const maxAttempts = isModelScopeForRequest
? 3
: provider === "qwen"
? 3
: provider === "codex"
? 3
: 1;

// ── Codex 429 account-rotation state ─────────────────────────────────
// Track excluded connection IDs for codex failover across attempts.
Expand Down Expand Up @@ -2845,6 +2858,28 @@ export async function handleChatCore({
}
}

if (
isModelScope() &&
res.response.status === 429 &&
attempts < maxAttempts - 1
) {
const bodyPeek = await res.response
.clone()
.text()
.catch(() => "");
Comment on lines +3037 to +3040

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Cloning the response and reading the full body as text for every 429 error can be inefficient, especially if the upstream response is large. While 429 bodies are typically small, it's safer to limit the read size or ensure this only happens for ModelScope. Since isModelScope() is already checked, this is acceptable, but consider adding a timeout or size limit to the text() call if possible in this environment.

const decision = classifyModelScope429(bodyPeek, res.response.headers);
if (decision.retryable) {
const delay = getModelScopeRetryDelayMs(res.response.headers, attempts);
log?.warn?.(
"MODELSCOPE_RETRY",
`429 ${decision.kind}; retrying in ${delay}ms (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"})`
);
await new Promise((r) => setTimeout(r, delay));
attempts++;
continue;
}
}

// Codex 429 account-rotation failover (disabled for context-relay so combo.ts can inject handoff)
if (
provider === "codex" &&
Expand Down Expand Up @@ -3234,7 +3269,18 @@ export async function handleChatCore({
}

// T06/T10/T36: classify provider errors and persist terminal account states.
const errorType = classifyProviderError(statusCode, message, provider);
let errorType = classifyProviderError(statusCode, message, provider);
if (statusCode === 429 && isModelScope()) {
const decision = classifyModelScope429(message, providerResponse.headers);
errorType =
decision.kind === "quota_exhausted"
? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED
: PROVIDER_ERROR_TYPES.RATE_LIMITED;
log?.warn?.(
"MODELSCOPE_429",
`${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})`
);
}
if (connectionId && errorType) {
try {
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
Expand Down Expand Up @@ -3271,7 +3317,12 @@ export async function handleChatCore({
if (accountSemaphoreKey) {
markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
}
if (
if (isModelScope() && connectionId) {
lockModel(provider, connectionId, model, "quota_exhausted", quotaCooldownMs);
console.warn(
`[provider] Node ${connectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
);
Comment on lines +3508 to +3512

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The explicit check for isModelScope() here to trigger lockModel bypasses the hasPerModelQuota logic in accountFallback.ts. While necessary because hasPerModelQuota lacks access to credentials for URL-based detection, this creates a slight fragmentation of model-locking logic. Consider if hasPerModelQuota could be updated to accept providerSpecificData in a future refactor to centralize this policy.

} else if (
lockModelIfPerModelQuota(
provider,
connectionId,
Expand Down
90 changes: 90 additions & 0 deletions open-sse/services/modelscopePolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
type ModelScopeRateLimitSnapshot = {
modelRemaining: number | null;
modelLimit: number | null;
totalRemaining: number | null;
totalLimit: number | null;
};

type ModelScope429Decision =
| { kind: "quota_exhausted"; retryable: false; snapshot: ModelScopeRateLimitSnapshot }
| { kind: "rate_limited"; retryable: true; snapshot: ModelScopeRateLimitSnapshot };

const MODELSCOPE_HOST_MARKERS = ["modelscope.cn", "modelscope.aliyuncs.com"];
const MODELSCOPE_QUOTA_EXHAUSTED_SIGNALS = ["free allocated quota exceeded"];
const MODELSCOPE_THROTTLE_SIGNALS = [
"throttling",
"throttled",
"rate limit",
"too many requests",
"batch requests",
"allocated quota exceeded",
"exceeded your current quota",
];

function parseHeaderInteger(value: string | null): number | null {
if (value === null || value.trim() === "") return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}

function getProviderBaseUrl(providerSpecificData?: unknown): string {
if (!providerSpecificData || typeof providerSpecificData !== "object") return "";
const data = providerSpecificData as Record<string, unknown>;
const value = data.baseUrl ?? data.baseURL ?? data.url ?? data.endpoint;
return typeof value === "string" ? value.toLowerCase() : "";
}

export function isModelScopeProvider(
provider: string | null | undefined,
providerSpecificData?: unknown
): boolean {
if (String(provider || "").trim().toLowerCase() === "modelscope") return true;
const baseUrl = getProviderBaseUrl(providerSpecificData);
return MODELSCOPE_HOST_MARKERS.some((marker) => baseUrl.includes(marker));
}

export function parseModelScopeRateLimitHeaders(headers: Headers): ModelScopeRateLimitSnapshot {
return {
modelRemaining: parseHeaderInteger(
headers.get("modelscope-ratelimit-model-requests-remaining")
),
modelLimit: parseHeaderInteger(headers.get("modelscope-ratelimit-model-requests-limit")),
totalRemaining: parseHeaderInteger(headers.get("modelscope-ratelimit-requests-remaining")),
totalLimit: parseHeaderInteger(headers.get("modelscope-ratelimit-requests-limit")),
};
}

export function classifyModelScope429(
errorText: string,
headers: Headers
): ModelScope429Decision {
const snapshot = parseModelScopeRateLimitHeaders(headers);
const lower = String(errorText || "").toLowerCase();

if (MODELSCOPE_QUOTA_EXHAUSTED_SIGNALS.some((signal) => lower.includes(signal))) {
return { kind: "quota_exhausted", retryable: false, snapshot };
}

if (snapshot.totalRemaining === 0 || snapshot.modelRemaining === 0) {
return { kind: "quota_exhausted", retryable: false, snapshot };
}

if (snapshot.modelRemaining !== null || snapshot.totalRemaining !== null) {
return { kind: "rate_limited", retryable: true, snapshot };
}

if (MODELSCOPE_THROTTLE_SIGNALS.some((signal) => lower.includes(signal))) {
return { kind: "rate_limited", retryable: true, snapshot };
}

return { kind: "rate_limited", retryable: true, snapshot };
}

export function getModelScopeRetryDelayMs(headers: Headers, attempt: number): number {
const retryAfter = headers.get("retry-after");
if (retryAfter) {
const parsed = Number.parseFloat(retryAfter);
if (Number.isFinite(parsed) && parsed > 0) return parsed * 1000;
}
return 3000 * (attempt + 1);
}
Loading