Compare commits

...

5 Commits

Author SHA1 Message Date
Jack Wotherspoon eb9e7eb10e chore: add removal of function call if response causes 404 2026-01-20 10:25:23 -05:00
Jack Wotherspoon 7630190038 Merge branch 'main' into 400-errors 2026-01-20 09:14:10 -05:00
Jack Wotherspoon 35419b51f2 chore: better comment 2026-01-20 09:12:22 -05:00
Jack Wotherspoon 7a687ce858 chore: only pop nonretryable errors 2026-01-20 09:08:34 -05:00
Jack Wotherspoon 53994b2ff0 fix: remove 400 errors from model history 2026-01-19 21:34:43 -05:00
2 changed files with 82 additions and 10 deletions
+57 -6
View File
@@ -1279,13 +1279,10 @@ describe('GeminiChat', () => {
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should still contain the user message.
// History should NOT contain the failed user message,
// to prevent invalid content from breaking subsequent requests.
const history = chat.getHistory();
expect(history.length).toBe(1);
expect(history[0]).toEqual({
role: 'user',
parts: [{ text: 'test' }],
});
expect(history.length).toBe(0);
});
describe('API error retry behavior', () => {
@@ -1347,6 +1344,60 @@ describe('GeminiChat', () => {
).toHaveBeenCalledTimes(1);
});
it('should remove function response AND preceding function call on 400 error', async () => {
// Set up history with a user message and model function call
const initialUserMessage: Content = {
role: 'user',
parts: [{ text: 'Call a tool for me' }],
};
const modelFunctionCall: Content = {
role: 'model',
parts: [{ functionCall: { name: 'test_tool', args: {} } }],
};
chat.addHistory(initialUserMessage);
chat.addHistory(modelFunctionCall);
// Verify initial history state
expect(chat.getHistory().length).toBe(2);
const error400 = new ApiError({ message: 'Bad Request', status: 400 });
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
error400,
);
// Send a function response that will fail with 400
const functionResponse = [
{
functionResponse: {
name: 'test_tool',
response: { invalid: 'data' },
},
},
];
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.5-flash' },
functionResponse,
'prompt-id-400-fn',
new AbortController().signal,
);
await expect(
(async () => {
for await (const _ of stream) {
/* consume stream */
}
})(),
).rejects.toThrow(error400);
// History should only contain the initial user message.
// Both the function response AND the model's function call should be removed
// to avoid a dangling function call state.
const history = chat.getHistory();
expect(history.length).toBe(1);
expect(history[0]).toEqual(initialUserMessage);
});
it('should retry on 429 Rate Limit errors', async () => {
const error429 = new ApiError({ message: 'Rate Limited', status: 429 });
+25 -4
View File
@@ -41,7 +41,10 @@ import {
ContentRetryFailureEvent,
} from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import {
isFunctionResponse,
isFunctionCall,
} from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
@@ -379,9 +382,6 @@ export class GeminiChat {
return; // Stop the generator
}
if (isConnectionPhase) {
throw error;
}
lastError = error;
const isContentError = error instanceof InvalidStreamError;
const isRetryable = isRetryableError(
@@ -389,6 +389,11 @@ export class GeminiChat {
this.config.getRetryFetchErrors(),
);
if (isConnectionPhase && !isRetryable) {
this.popFailedUserContent();
throw error;
}
if (
(isContentError && isGemini2Model(model)) ||
(isRetryable && !signal.aborted)
@@ -429,6 +434,7 @@ export class GeminiChat {
new ContentRetryFailureEvent(maxAttempts, lastError.type, model),
);
}
this.popFailedUserContent();
throw lastError;
}
} finally {
@@ -673,6 +679,21 @@ export class GeminiChat {
this.history = [];
}
/**
* Removes failed user content from history. If the content was a function
* response, also removes the preceding model function call to keep
* history consistent (avoids dangling function call state).
*/
private popFailedUserContent(): void {
const popped = this.history.pop();
if (popped && isFunctionResponse(popped)) {
const prev = this.history[this.history.length - 1];
if (prev && isFunctionCall(prev)) {
this.history.pop();
}
}
}
/**
* Adds a new entry to the chat history.
*/