Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 37 additions & 0 deletions .vscode/指北.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# RPM 令牌桶维护指北

## 限流语义

- `unifyChatProvider.networkSettings.rateLimit.rpm` 是当前 VS Code 窗口中每个供应商的默认 RPM。
- `unifyChatProvider.endpoints[].rateLimit.rpm` 覆盖该供应商的全局默认值;显式设置 `0` 可关闭该供应商的限流。
- RPM 限制的是**逻辑聊天请求**,而不是单次底层 HTTP 调用。每个请求在 `UnifyChatService.provideLanguageModelChatResponse()` 进入传输层前消耗一个令牌。
- 因此 HTTP、SSE 和 OpenAI Responses WebSocket 共用同一个限制;同一逻辑请求的内部 HTTP 重试、流重试或 WebSocket 重连不会重复消耗令牌。
- 限流器按 provider、按 VS Code 窗口独立创建,不跨窗口共享。若未来需要跨窗口共享,必须通过 main-instance IPC 单独设计共享状态契约;不要仅因扩展版本升级而改动兼容性版本。

## 令牌桶算法

- 桶容量:$\max(1, \lceil 0.8 \times RPM \rceil)$。
- 补充速度:$RPM / 60{,}000$ token/ms。
- 桶初始为满桶,允许保守突发;长期平均速率不超过配置的 RPM。
- `RateLimiter.acquire()` 使用 FIFO Promise 队列串行化“补充、检查、等待、消费”,防止并发等待者消费同一个新补充的令牌或把令牌数扣为负数。

## 可观测性

请求开始日志会在启用 RPM 时包含消费后的快照,例如:

`Rate Limit: 47.23/48.00 tokens`

两侧均固定保留两位小数;异常数值会显示为 `N/A`,避免日志本身影响请求。

## 维护边界

- 不要把逻辑聊天请求的限流迁移到 `createCustomFetch()`:Responses WebSocket 不经过该 HTTP 包装器,会导致漏限流或双重扣桶。
- 保留 `ApiProvider.acquireRateLimitToken()` 与 `getRateLimitStatus()`,由服务层统一调用。
- 模型刷新、余额刷新和 OAuth 流程不属于聊天 RPM 的计费边界。

## 回归验证

1. 运行 `npm run compile`。
2. 使用 `RateLimiter(60)` 并发申请至少 52 个令牌:前 48 个应立即完成,后续应大约每秒完成一个,完成顺序保持 FIFO,任何状态快照都不得为负。
3. 在扩展开发宿主为 provider 设置 `rpm: 60`,发送请求并确认输出通道显示两位小数;删除 RPM 或将其设为 `0` 时,不显示 `Rate Limit:` 后缀。
4. 分别回归普通 HTTP/SSE 聊天与 OpenAI Responses WebSocket 聊天,确认都只在逻辑请求入口消耗一个令牌。
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v7.12.4 - 2026-07-14

### Fixes
- make provider RPM token buckets FIFO-safe under concurrent chat requests and show available tokens with two decimal places
- clarify per-window logical chat request RPM behavior and preserve explicit per-provider `rpm: 0` overrides

## v7.12.3 - 2026-07-12

### Features
Expand Down
4 changes: 4 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
"Extra body parameters must be configured in VS Code settings (JSON).": "Extra body parameters must be configured in VS Code settings (JSON).",
"{0} properties": "{0} properties",
"Network Settings": "Network Settings",
"Rate Limit (RPM)": "Rate Limit (RPM)",
"Maximum requests per minute (0 = disabled)": "Maximum requests per minute (0 = disabled)",
"Enter maximum requests per minute (0 = disabled)": "Enter maximum requests per minute (0 = disabled)",
"rpm: {0}": "rpm: {0}",
"default": "default",
"conn: {0}ms": "conn: {0}ms",
"resp: {0}ms": "resp: {0}ms",
Expand Down
4 changes: 4 additions & 0 deletions l10n/bundle.l10n.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
"Extra body parameters must be configured in VS Code settings (JSON).": "额外 Body 参数必须在 VS Code 设置(JSON)中配置。",
"{0} properties": "{0} 个属性",
"Network Settings": "网络设置",
"Rate Limit (RPM)": "RPM 限流",
"Maximum requests per minute (0 = disabled)": "每分钟最大请求数(0 = 禁用)",
"Enter maximum requests per minute (0 = disabled)": "输入每分钟最大请求数(0 = 禁用)",
"rpm: {0}": "RPM: {0}",
"default": "默认",
"conn: {0}ms": "连接:{0}ms",
"resp: {0}ms": "响应:{0}ms",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "vscode-unify-chat-provider",
"displayName": "%displayName%",
"description": "%description%",
"version": "7.12.3",
"version": "7.12.4",
"categories": [
"AI",
"Chat"
Expand Down Expand Up @@ -294,6 +294,18 @@
"scope": "application",
"description": "%configuration.networkSettings.description%",
"properties": {
"rateLimit": {
"type": "object",
"description": "%configuration.networkSettings.rateLimit.description%",
"properties": {
"rpm": {
"type": "integer",
"description": "%configuration.networkSettings.rateLimit.rpm.description%",
"default": 0,
"minimum": 0
}
}
},
"timeout": {
"type": "object",
"description": "%configuration.networkSettings.timeout.description%",
Expand Down Expand Up @@ -800,6 +812,18 @@
"description": "%configuration.endpoints.extraBody.description%",
"additionalProperties": true
},
"rateLimit": {
"type": "object",
"description": "%configuration.endpoints.rateLimit.description%",
"properties": {
"rpm": {
"type": "integer",
"description": "%configuration.endpoints.rateLimit.rpm.description%",
"default": 0,
"minimum": 0
}
}
},
"timeout": {
"type": "object",
"description": "%configuration.endpoints.timeout.description%",
Expand Down
6 changes: 5 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"configuration.balanceWarning.timeThresholdDays.description": "Time threshold in days for expiration warnings (supports decimals). Default: 1.",
"configuration.balanceWarning.amountThreshold.description": "Balance amount threshold for warnings (unitless; currency ignored). Default: 1.",
"configuration.balanceWarning.tokenThresholdMillions.description": "Token remaining threshold in millions for warnings. Default: 1.",
"configuration.networkSettings.description": "Global network settings. Timeout and retry affect chat requests; proxy affects provider HTTP requests.",
"configuration.networkSettings.description": "Global network settings. Rate limits, timeouts, and retries affect logical chat requests; proxy affects provider HTTP requests.",
"configuration.networkSettings.timeout.description": "Timeout settings for chat requests (milliseconds).",
"configuration.networkSettings.timeout.connection.description": "TCP connection timeout in milliseconds (chat requests).",
"configuration.networkSettings.timeout.response.description": "Maximum time between SSE data chunks in milliseconds (chat requests).",
Expand All @@ -45,6 +45,8 @@
"configuration.networkSettings.retry.jitterFactor.description": "Jitter factor (0-1) to randomize retry delay (chat requests).",
"configuration.networkSettings.retry.statusCodes.description": "HTTP status codes that should trigger retries for chat requests. When set, this overrides the default retryable status codes.",
"configuration.networkSettings.proxy.description": "Global proxy settings for provider requests.",
"configuration.networkSettings.rateLimit.description": "Default rate-limit settings for each provider in this VS Code window. Provider-level values override these settings.",
"configuration.networkSettings.rateLimit.rpm.description": "Maximum logical chat requests per minute for each provider in this VS Code window. Set to 0 (default) to disable rate limiting.",
"configuration.proxy.type.description": "Proxy mode.",
"configuration.proxy.type.enumDescriptions.0": "Use VS Code HTTP proxy settings.",
"configuration.proxy.type.enumDescriptions.1": "Connect directly without using a proxy.",
Expand Down Expand Up @@ -138,6 +140,8 @@
"configuration.endpoints.retry.jitterFactor.description": "Jitter factor (0-1) to randomize retry delay (chat requests).",
"configuration.endpoints.proxy.description": "Proxy settings for this provider. Overrides global proxy settings.",
"configuration.endpoints.autoFetchOfficialModels.description": "Automatically fetch and sync official models from the provider API.",
"configuration.endpoints.rateLimit.description": "Rate-limit settings for logical chat requests in this VS Code window.",
"configuration.endpoints.rateLimit.rpm.description": "Maximum logical chat requests per minute for this provider in this VS Code window. Set to 0 (default) to disable rate limiting.",
"configuration.endpoints.models.description": "List of available models.",
"configuration.endpoints.models.string.description": "Model ID",
"configuration.endpoints.models.id.description": "Model ID (e.g., claude-sonnet-4-20250514).",
Expand Down
6 changes: 5 additions & 1 deletion package.nls.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"configuration.balanceWarning.timeThresholdDays.description": "时间阈值(天),用于到期提醒(支持小数)。默认值:1。",
"configuration.balanceWarning.amountThreshold.description": "余额阈值(无单位,忽略货币)。默认值:1。",
"configuration.balanceWarning.tokenThresholdMillions.description": "令牌数阈值(单位:百万),用于剩余令牌提醒。默认值:1。",
"configuration.networkSettings.description": "全局网络设置。超时与重试影响聊天请求;代理影响供应商 HTTP 请求。",
"configuration.networkSettings.description": "全局网络设置。限流、超时与重试影响逻辑聊天请求;代理影响供应商 HTTP 请求。",
"configuration.networkSettings.timeout.description": "聊天请求的超时设置(毫秒)。",
"configuration.networkSettings.timeout.connection.description": "TCP 连接超时(毫秒)(聊天请求)。",
"configuration.networkSettings.timeout.response.description": "SSE 流式传输期间,两次数据分片之间的最大等待时间(毫秒)(聊天请求)。",
Expand All @@ -45,6 +45,8 @@
"configuration.networkSettings.retry.jitterFactor.description": "聊天请求的抖动因子(0-1),用于随机化重试延迟。",
"configuration.networkSettings.retry.statusCodes.description": "聊天请求中触发重试的 HTTP 状态码。设置后会覆盖默认可重试状态码。",
"configuration.networkSettings.proxy.description": "供应商请求的全局代理设置。",
"configuration.networkSettings.rateLimit.description": "此 VS Code 窗口内每个供应商的默认主动限流设置;供应商级设置可覆盖。",
"configuration.networkSettings.rateLimit.rpm.description": "此 VS Code 窗口内每个供应商的逻辑聊天请求每分钟最大数量。设为 0(默认)表示不限流。",
"configuration.proxy.type.description": "代理模式。",
"configuration.proxy.type.enumDescriptions.0": "使用 VS Code HTTP 代理设置。",
"configuration.proxy.type.enumDescriptions.1": "直连,不使用代理。",
Expand Down Expand Up @@ -138,6 +140,8 @@
"configuration.endpoints.retry.jitterFactor.description": "聊天请求的抖动因子(0-1),用于随机化重试延迟。",
"configuration.endpoints.proxy.description": "此供应商的代理设置。会覆盖全局代理设置。",
"configuration.endpoints.autoFetchOfficialModels.description": "自动从供应商 API 拉取并同步官方模型。",
"configuration.endpoints.rateLimit.description": "此 VS Code 窗口内逻辑聊天请求的主动限流设置。",
"configuration.endpoints.rateLimit.rpm.description": "此 VS Code 窗口内该供应商的逻辑聊天请求每分钟最大数量。设为 0(默认)表示不限流。",
"configuration.endpoints.models.description": "可用模型列表。",
"configuration.endpoints.models.string.description": "模型 ID",
"configuration.endpoints.models.id.description": "模型 ID(例如:claude-sonnet-4-20250514)。",
Expand Down
11 changes: 11 additions & 0 deletions src/client/anthropic/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
setUserAgentHeader,
} from '../utils';
import type { AuthTokenInfo } from '../../auth/types';
import { createRateLimiter } from '../../rate-limit';

type AnthropicMarkerData = {
raw: BetaMessage;
Expand Down Expand Up @@ -112,8 +113,10 @@ const ANTHROPIC_ABNORMAL_STOP_REASONS: ReadonlySet<string> = new Set([

export class AnthropicProvider implements ApiProvider {
private readonly baseUrl: string;
private readonly rateLimiter: ReturnType<typeof createRateLimiter>;

constructor(protected readonly config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
this.baseUrl = buildBaseUrl(config.baseUrl, {
stripPattern: /\/v1$/i,
useRawBaseUrl: isRawBaseUrlEnabled(config),
Expand All @@ -132,6 +135,14 @@ export class AnthropicProvider implements ApiProvider {
* Create an Anthropic client with custom fetch for retry support.
* A new client is created per request to enable per-request logging.
*/
getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

protected createClient(
logger: ProviderHttpLogger | undefined,
stream: boolean,
Expand Down
11 changes: 11 additions & 0 deletions src/client/github-copilot/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../../utils';
import type { ApiProvider } from '../interface';
import { buildBaseUrl, createCustomFetch, getToken } from '../utils';
import { createRateLimiter } from '../../rate-limit';
import { OpenAIChatCompletionProvider } from '../openai/chat-completion-client';
import { OpenAIResponsesProvider } from '../openai/responses-client';
import type { ChatCompletionChunk } from 'openai/resources/chat/completions';
Expand Down Expand Up @@ -669,6 +670,7 @@ export class GitHubCopilotProvider implements ApiProvider {
private readonly chatProvider: GitHubCopilotChatCompletionProvider;
private readonly responsesProvider: GitHubCopilotResponsesProvider;
private readonly messagesProvider: GitHubCopilotMessagesProvider;
private readonly rateLimiter: ReturnType<typeof createRateLimiter>;

private assertCopilotAuth(): void {
if (this.providerConfig.auth?.method !== 'github-copilot') {
Expand All @@ -679,6 +681,7 @@ export class GitHubCopilotProvider implements ApiProvider {
}

constructor(config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
const extraBody = config.extraBody ?? {};
const hasStore = Object.prototype.hasOwnProperty.call(extraBody, 'store');
const configWithDefaults: ProviderConfig = hasStore
Expand All @@ -699,6 +702,14 @@ export class GitHubCopilotProvider implements ApiProvider {
});
}

getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

async *streamChat(
encodedModelId: string,
model: ModelConfig,
Expand Down
11 changes: 11 additions & 0 deletions src/client/google/ai-studio-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
type RetryConfig,
withIdleTimeout,
} from '../../utils';
import { createRateLimiter } from '../../rate-limit';
import { getBaseModelId } from '../../model-id-utils';
import { ThinkingBlockMetadata } from '../types';
import {
Expand Down Expand Up @@ -153,8 +154,18 @@ function getThoughtSignature(part: Part): string | undefined {
export class GoogleAIStudioProvider implements ApiProvider {
protected readonly baseUrl: string;
protected readonly apiVersion: string;
protected readonly rateLimiter: ReturnType<typeof createRateLimiter>;

getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

constructor(protected readonly config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
if (isRawBaseUrlEnabled(config)) {
this.baseUrl = normalizeRawBaseUrlInput(config.baseUrl);
this.apiVersion = 'v1beta';
Expand Down
13 changes: 13 additions & 0 deletions src/client/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,17 @@ export interface ApiProvider {
* Returns a list of model configurations supported by this API client
*/
getAvailableModels?(credential: AuthTokenInfo): Promise<ModelConfig[]>;

/**
* Get the current rate-limit status (token bucket snapshot).
* Returns undefined when no rate limiting is configured for this provider.
*/
getRateLimitStatus?(): { available: number; capacity: number } | undefined;

/**
* Acquire a token for one logical chat request, waiting if necessary.
* Called by the service before transport selection so HTTP, SSE, and
* WebSocket requests share the same limiter.
*/
acquireRateLimitToken?(): Promise<void>;
}
11 changes: 11 additions & 0 deletions src/client/ollama/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
normalizeToolInputSchema,
processUsage as sharedProcessUsage,
} from '../utils';
import { createRateLimiter } from '../../rate-limit';

const TOOL_CALL_ID_PREFIX = 'ollama-tool:';

Expand Down Expand Up @@ -100,8 +101,18 @@ const OLLAMA_ABNORMAL_DONE_REASONS: ReadonlySet<string> = new Set([

export class OllamaProvider implements ApiProvider {
private readonly baseUrl: string;
private readonly rateLimiter: ReturnType<typeof createRateLimiter>;

getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

constructor(private readonly config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
this.baseUrl = buildBaseUrl(config.baseUrl, {
stripPattern: /\/api$/i,
useRawBaseUrl: isRawBaseUrlEnabled(config),
Expand Down
11 changes: 11 additions & 0 deletions src/client/openai/chat-completion-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
resolveOpenAIServiceTier,
setUserAgentHeader,
} from '../utils';
import { createRateLimiter } from '../../rate-limit';
import * as vscode from 'vscode';
import {
ChatCompletion,
Expand Down Expand Up @@ -194,8 +195,18 @@ function isChatCompletionChunk(

export class OpenAIChatCompletionProvider implements ApiProvider {
protected readonly baseUrl: string;
private readonly rateLimiter: ReturnType<typeof createRateLimiter>;

getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

constructor(protected readonly config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
this.baseUrl = this.resolveBaseUrl(config);
}

Expand Down
11 changes: 11 additions & 0 deletions src/client/openai/responses-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
resolveOpenAIServiceTier,
setUserAgentHeader,
} from '../utils';
import { createRateLimiter } from '../../rate-limit';
import * as vscode from 'vscode';
import {
EasyInputMessage,
Expand Down Expand Up @@ -678,8 +679,18 @@ export class OpenAIResponsesProvider implements ApiProvider {
protected readonly baseUrl: string;
private websocketCapability: 'unknown' | 'supported' | 'unsupported' =
'unknown';
protected readonly rateLimiter: ReturnType<typeof createRateLimiter>;

getRateLimitStatus(): { available: number; capacity: number } | undefined {
return this.rateLimiter?.getAvailableTokens();
}

async acquireRateLimitToken(): Promise<void> {
await this.rateLimiter?.acquire();
}

constructor(protected readonly config: ProviderConfig) {
this.rateLimiter = createRateLimiter(config.rateLimit);
this.baseUrl = this.resolveBaseUrl(config);
}

Expand Down
1 change: 1 addition & 0 deletions src/config-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const PROVIDER_CONFIG_KEYS = [
'proxy',
'autoFetchOfficialModels',
'contextCache',
'rateLimit',
] as const satisfies ReadonlyArray<ProviderConfigPersistedKey>;

type AssertNever<T extends never> = T;
Expand Down
Loading