Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ffad448f |
@@ -222,7 +222,19 @@ internal sealed class MessagesTransport
|
||||
requestMessage.Headers.Add("x-api-key", _options.ApiKey);
|
||||
requestMessage.Headers.Add("anthropic-version", AnthropicVersion);
|
||||
|
||||
var response = await _http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException e) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// HttpClient.Timeout while waiting for headers
|
||||
throw new HttpRequestException(HttpRequestError.ResponseEnded,
|
||||
"anthropic: no response headers before the HTTP timeout",
|
||||
new TimeoutException("The request timed out.", e));
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return response;
|
||||
|
||||
@@ -70,7 +70,17 @@ public sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings se
|
||||
var proposed = new Dictionary<string, PlannedArticle>(StringComparer.Ordinal);
|
||||
if (root["new"] is JsonArray items)
|
||||
foreach (var item in items)
|
||||
if (item is JsonObject o && ReadNew(o, candidateSlugs, proposed.Keys, plan) is { } a) proposed[a.Slug] = a;
|
||||
{
|
||||
if (item is not JsonObject o) continue;
|
||||
// a "new" slug that is a live article is a reference to it, not a new article
|
||||
if (Text(o["slug"]) is { } existing && plan.Find(existing) is not null)
|
||||
{
|
||||
log.LogInformation("Merged proposed new article {Slug} into the existing article", existing);
|
||||
candidateSlugs.Add(existing);
|
||||
continue;
|
||||
}
|
||||
if (ReadNew(o, candidateSlugs, proposed.Keys, plan) is { } a) proposed[a.Slug] = a;
|
||||
}
|
||||
|
||||
var inBatch = batch.Select(n => n.Slug).ToHashSet(StringComparer.Ordinal);
|
||||
var assignments = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record EncyclopediaSettings(
|
||||
int MaxSourcesPerArticle = 120,
|
||||
int FoldMinSources = 3,
|
||||
int FoldMinChars = 4000,
|
||||
int AssignMaxTokens = 8000,
|
||||
int SplitMaxTokens = 8000,
|
||||
int FoldMaxTokens = 1000);
|
||||
// thinking tokens count against max_tokens
|
||||
int AssignMaxTokens = 32000,
|
||||
int SplitMaxTokens = 32000,
|
||||
int FoldMaxTokens = 8000);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Wiki.Application.Encyclopedia;
|
||||
/// </summary>
|
||||
public static class Prompts
|
||||
{
|
||||
public const int Version = 6;
|
||||
public const int Version = 7;
|
||||
|
||||
public const string Assign = """
|
||||
[assign]
|
||||
|
||||
@@ -101,7 +101,8 @@ public sealed class EncyclopediaOptions
|
||||
public int MaxSourcesPerArticle { get; set; } = 120;
|
||||
public int FoldMinSources { get; set; } = 3;
|
||||
public int FoldMinChars { get; set; } = 4000;
|
||||
public int AssignMaxTokens { get; set; } = 8000;
|
||||
public int SplitMaxTokens { get; set; } = 8000;
|
||||
public int FoldMaxTokens { get; set; } = 1000;
|
||||
// thinking tokens count against max_tokens
|
||||
public int AssignMaxTokens { get; set; } = 32000;
|
||||
public int SplitMaxTokens { get; set; } = 32000;
|
||||
public int FoldMaxTokens { get; set; } = 8000;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class AssignNotesUseCaseTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Assign_NewWithTakenSlug_DroppedAndItsNotesUnassigned()
|
||||
public async Task Assign_NewWithExistingNonCandidateSlug_MergedIntoExistingArticle()
|
||||
{
|
||||
var heating = Article("heating");
|
||||
var plan = PlanWith(heating, Article("garden"));
|
||||
@@ -80,6 +80,43 @@ public class AssignNotesUseCaseTests
|
||||
|
||||
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
|
||||
|
||||
Assert.False(outcome.Unusable);
|
||||
Assert.Empty(outcome.New);
|
||||
Assert.Equal(["garden"], outcome.Assignments["a"]);
|
||||
Assert.Equal(["heating"], outcome.Assignments["b"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Assign_NewWithRedirectedSlug_DroppedAndItsNotesUnassigned()
|
||||
{
|
||||
var heating = Article("heating");
|
||||
var plan = PlanWith(heating, Article("garden"), Article("yard"));
|
||||
plan.Fold("yard", "garden", isPublic: false);
|
||||
var model = new FakeTextModel().Reply("""
|
||||
{"new":[{"slug":"yard","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
|
||||
"notes":[{"note":"a","articles":["yard"]},{"note":"b","articles":["heating"]}]}
|
||||
""");
|
||||
|
||||
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
|
||||
|
||||
Assert.Empty(outcome.New);
|
||||
Assert.False(outcome.Assignments.ContainsKey("a"));
|
||||
Assert.Equal(["heating"], outcome.Assignments["b"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Assign_NewWithRetiredSlug_DroppedAndItsNotesUnassigned()
|
||||
{
|
||||
var heating = Article("heating");
|
||||
var plan = PlanWith(heating, Article("garden", "gone"));
|
||||
plan.RemoveNote("gone");
|
||||
var model = new FakeTextModel().Reply("""
|
||||
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
|
||||
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
|
||||
""");
|
||||
|
||||
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
|
||||
|
||||
Assert.Empty(outcome.New);
|
||||
Assert.False(outcome.Assignments.ContainsKey("a"));
|
||||
Assert.Equal(["heating"], outcome.Assignments["b"]);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Wiki.Application.Encyclopedia;
|
||||
|
||||
namespace Wiki.Application.Tests;
|
||||
|
||||
public class EncyclopediaSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_LeaveRoomForThinking()
|
||||
{
|
||||
var s = new EncyclopediaSettings(16000, 10, 300_000, [], null);
|
||||
|
||||
Assert.Equal(32000, s.AssignMaxTokens);
|
||||
Assert.Equal(32000, s.SplitMaxTokens);
|
||||
Assert.Equal(8000, s.FoldMaxTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptsVersion_InvalidatesBatchesFailedAtTheOldCap() => Assert.Equal(7, Prompts.Version);
|
||||
}
|
||||
@@ -99,6 +99,27 @@ public sealed class AnthropicTextModelTests
|
||||
Assert.Null(ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_HeaderTimeout_ThrowsHttpRequestWithoutStatus()
|
||||
{
|
||||
var model = CreateModel(new FakeAnthropicHandler { TimeOutBeforeHeaders = true });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => model.CompleteAsync("sys", "user", 16000, CancellationToken.None));
|
||||
|
||||
Assert.IsType<TimeoutException>(ex.InnerException);
|
||||
Assert.Null(ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_CallerCancelsBeforeHeaders_ThrowsOperationCanceled()
|
||||
{
|
||||
var model = CreateModel(new FakeAnthropicHandler { HangBeforeHeaders = true });
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => model.CompleteAsync("sys", "user", 16000, cts.Token).WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_PassesMaxTokensAndSystem()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,12 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler
|
||||
/// <summary>When set, the stream sends its message_start event and then nothing, until the request is cancelled.</summary>
|
||||
public bool StallAfterFirstEvent { get; set; }
|
||||
|
||||
/// <summary>When set, a POST fails like an HttpClient.Timeout before headers: TaskCanceledException, caller token not cancelled.</summary>
|
||||
public bool TimeOutBeforeHeaders { get; set; }
|
||||
|
||||
/// <summary>When set, a POST never answers until the request is cancelled.</summary>
|
||||
public bool HangBeforeHeaders { get; set; }
|
||||
|
||||
/// <summary>When set, every POST is answered with this status and <see cref="FailureBody"/>.</summary>
|
||||
public HttpStatusCode? FailWithStatus { get; set; }
|
||||
|
||||
@@ -64,6 +70,16 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler
|
||||
var body = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
LastRequestJson = body;
|
||||
|
||||
if (TimeOutBeforeHeaders)
|
||||
{
|
||||
throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout.", new TimeoutException());
|
||||
}
|
||||
|
||||
if (HangBeforeHeaders)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_tooManyRequestsServed < TooManyRequestsBeforeSuccess)
|
||||
{
|
||||
_tooManyRequestsServed++;
|
||||
|
||||
Reference in New Issue
Block a user