Repository navigation
feat: add ModelScope provider-specific 429 handling and retry logic #2202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
67af6c3
41946c4
0e4247f
d3817a7
24dfb4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,7 @@ blob-report | |
| docs | ||
| *.md | ||
| !README.md | ||
| !docs/openapi.yaml | ||
|
|
||
| # Electron (separate build) | ||
| electron | ||
|
|
||
| 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 | ||
|
|
| 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 *)' | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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; | ||
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
|
@@ -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(() => ""); | ||
| 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" && | ||
|
|
@@ -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) { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The explicit check for |
||
| } else if ( | ||
| lockModelIfPerModelQuota( | ||
| provider, | ||
| connectionId, | ||
|
|
||
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 thetext()call if possible in this environment.