Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
44 changes: 22 additions & 22 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,28 @@ const SETTINGS_SCHEMA = {
parentKey: 'generationConfig',
showInDialog: false,
},
retryInitialDelayMs: {
type: 'number',
label: 'Retry Initial Delay',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Initial delay in milliseconds for stream rate-limit retries.',
parentKey: 'generationConfig',
showInDialog: false,
},
retryMaxDelayMs: {
type: 'number',
label: 'Retry Max Delay',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum delay in milliseconds for stream rate-limit retries.',
parentKey: 'generationConfig',
showInDialog: false,
},
enableCacheControl: {
type: 'boolean',
label: 'Enable Cache Control',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/core/contentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export type ContentGeneratorConfig = {
// returns 200 then goes silent is otherwise unbounded. `<= 0` disables it.
streamIdleTimeoutMs?: number;
maxRetries?: number; // Maximum retries for rate-limit errors
retryInitialDelayMs?: number; // Initial delay for stream rate-limit retries
retryMaxDelayMs?: number; // Maximum delay for stream rate-limit retries
retryErrorCodes?: number[]; // Additional error codes that trigger rate-limit retry
enableCacheControl?: boolean; // Enable cache control for DashScope providers
// Force `scope: 'global'` on Anthropic cache_control entries even when the
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7977,6 +7977,95 @@ describe('GeminiChat', async () => {
}
});

it('uses configured stream rate-limit retry delays', async () => {
vi.useFakeTimers();

try {
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_OPENAI,
model: 'test-model',
maxRetries: 2,
retryInitialDelayMs: 3_000,
retryMaxDelayMs: 5_000,
});
const firstError = new StreamContentError(
'id:1\nevent:error\n:HTTP_STATUS/429\ndata:{"request_id":"req-1","code":"Throttling.AllocationQuota","message":"Allocated quota exceeded"}',
);
const secondError = new StreamContentError(
'id:2\nevent:error\n:HTTP_STATUS/429\ndata:{"request_id":"req-2","code":"Throttling.AllocationQuota","message":"Allocated quota exceeded"}',
);

vi.mocked(mockContentGenerator.generateContentStream)
.mockResolvedValueOnce(
(async function* () {
throw firstError;

yield {} as GenerateContentResponse;
})(),
)
.mockResolvedValueOnce(
(async function* () {
throw secondError;

yield {} as GenerateContentResponse;
})(),
)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Recovered' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-configured-rate-limit-delay',
);
const iterator = stream[Symbol.asyncIterator]();

const first = await iterator.next();
expect(first.value.type).toBe(StreamEventType.RETRY);
expect(first.value.retryInfo?.delayMs).toBe(3_000);

let nextPromise = iterator.next();
await vi.advanceTimersByTimeAsync(3_000);
await nextPromise;

const second = await iterator.next();
expect(second.value.type).toBe(StreamEventType.RETRY);
expect(second.value.retryInfo?.delayMs).toBe(5_000);

nextPromise = iterator.next();
await vi.advanceTimersByTimeAsync(5_000);
await nextPromise;

const events: StreamEvent[] = [];
for (;;) {
const next = await iterator.next();
if (next.done) break;
events.push(next.value);
}

expect(
events.some(
(e) =>
e.type === StreamEventType.CHUNK &&
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Recovered',
),
).toBe(true);
} finally {
vi.useRealTimers();
}
});

describe('API error retry behavior', () => {
beforeEach(() => {
// Use a more direct mock for retry testing
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2297,6 +2297,11 @@ export class GeminiChat {
: undefined;
const maxRateLimitRetries =
cgConfig?.maxRetries ?? RATE_LIMIT_RETRY_OPTIONS.maxRetries;
const retryInitialDelayMs =
cgConfig?.retryInitialDelayMs ??
RATE_LIMIT_RETRY_OPTIONS.initialDelayMs;
const retryMaxDelayMs =
cgConfig?.retryMaxDelayMs ?? RATE_LIMIT_RETRY_OPTIONS.maxDelayMs;
const extraRetryErrorCodes = cgConfig?.retryErrorCodes;

// Max output tokens escalation: when no user/env override is set and
Expand Down Expand Up @@ -2391,6 +2396,8 @@ export class GeminiChat {
rateLimitRetryCount++;
const delayMs = getRateLimitRetryDelayMs(rateLimitRetryCount, {
...RATE_LIMIT_RETRY_OPTIONS,
initialDelayMs: retryInitialDelayMs,
maxDelayMs: retryMaxDelayMs,
error,
});
const message = parseAndFormatApiError(
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/models/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
'samplingParams',
'timeout',
'maxRetries',
'retryInitialDelayMs',
'retryMaxDelayMs',
'retryErrorCodes',
'enableCacheControl',
'forceGlobalCacheScope',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/models/modelConfigResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ describe('modelConfigResolver', () => {

expect(result.config.timeout).toBe(60000);
expect(result.config.maxRetries).toBe(5);
expect(result.config.retryInitialDelayMs).toBeUndefined();
expect(result.config.retryMaxDelayMs).toBeUndefined();
expect(result.config.samplingParams?.temperature).toBe(0.7);

expect(result.sources['timeout'].kind).toBe('settings');
Expand Down Expand Up @@ -372,6 +374,28 @@ describe('modelConfigResolver', () => {
expect(result.sources['timeout'].kind).toBe('modelProviders');
});

it('resolves stream retry delay config from settings', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {
apiKey: 'key',
generationConfig: {
maxRetries: 4,
retryInitialDelayMs: 3000,
retryMaxDelayMs: 30000,
},
},
env: {},
});

expect(result.config.maxRetries).toBe(4);
expect(result.config.retryInitialDelayMs).toBe(3000);
expect(result.config.retryMaxDelayMs).toBe(30000);
expect(result.sources['retryInitialDelayMs'].kind).toBe('settings');
expect(result.sources['retryMaxDelayMs'].kind).toBe('settings');
});

it('QWEN_CODE_API_TIMEOUT_MS env var overrides settings timeout', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type ModelGenerationConfig = Pick<
| 'samplingParams'
| 'timeout'
| 'maxRetries'
| 'retryInitialDelayMs'
| 'retryMaxDelayMs'
| 'retryErrorCodes'
| 'enableCacheControl'
| 'forceGlobalCacheScope'
Expand Down
8 changes: 8 additions & 0 deletions packages/vscode-ide-companion/schemas/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,14 @@
"description": "Maximum number of retries for failed requests.",
"type": "number"
},
"retryInitialDelayMs": {
"description": "Initial delay in milliseconds for stream rate-limit retries.",
"type": "number"
},
"retryMaxDelayMs": {
"description": "Maximum delay in milliseconds for stream rate-limit retries.",
"type": "number"
},
"enableCacheControl": {
"description": "Enable cache control for DashScope providers.",
"type": "boolean",
Expand Down
Loading