diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 6d81e95..0c28629 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -39,6 +39,7 @@ var tests = new (string Name, Action Test)[] ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), ("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes), ("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable), + ("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota), ("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), @@ -698,6 +699,14 @@ static void StoryIntelligenceSimulationModeRemainsAvailable() Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions."); } +static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota() +{ + var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs")); + + Assert(client.Contains("IsInsufficientQuota(responseText)", StringComparison.Ordinal), "OpenAI insufficient_quota responses must be detected before retrying."); + Assert(client.Contains("request was not retried", StringComparison.Ordinal), "OpenAI insufficient_quota responses should fail clearly without retry churn."); +} + static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText() { var model = new StoryIntelligenceExperiencePrototypeViewModel diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index a78a5cb..218710b 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -279,6 +279,11 @@ public sealed class StoryIntelligenceClient( return (responseText, retryCount); } + if (IsInsufficientQuota(responseText)) + { + throw new InvalidOperationException($"OpenAI billing quota exhausted; request was not retried: {TrimForLog(responseText)}"); + } + if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts) { throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}"); @@ -318,6 +323,10 @@ public sealed class StoryIntelligenceClient( or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; + internal static bool IsInsufficientQuota(string responseText) + => responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase) + || responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase); + private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) => Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);