From 96a62b6c68d6cacb43f71e9b4f8b2d76f416f6d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 10:30:54 +0000 Subject: [PATCH 1/2] Forward idempotency_key on AI assist so retries are not charged twice ePoints dedupes a repeated ai-assist request only when it sees the same idempotency_key. The proxy was dropping the key the SDK already sends. Co-authored-by: hivetrending --- .../EcencyApi.Tests/AiAssistHandlerTests.cs | 75 +++++++++++++++++++ dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs | 20 ++++- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs diff --git a/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs b/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs new file mode 100644 index 00000000..93387eeb --- /dev/null +++ b/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// AI assist bills whoever us names. ePoints returns the cached result for a +/// repeated idempotency_key instead of charging again, so the proxy has to +/// forward the key the client sent. Older clients omit it; those requests still go +/// through. +/// +[Collection("ai-assist")] +public class AiAssistHandlerTests : IDisposable +{ + private readonly CurationDeskTestSupport.Recorder _upstream = new(); + + public AiAssistHandlerTests() + { + // `code` "as:alice" validates as alice; anything else is invalid. + PrivateApi.AiAssistValidateCode = body => + { + var code = body["code"]?.GetValue(); + return Task.FromResult(code != null && code.StartsWith("as:", StringComparison.Ordinal) ? code[3..] : null); + }; + PrivateApi.AiAssistUpstream = (endpoint, method, payload, _) => + _upstream.Handle(endpoint, method, Array.Empty>(), payload); + } + + public void Dispose() + { + PrivateApi.AiAssistValidateCode = PrivateApi.ValidateCode; + PrivateApi.AiAssistUpstream = (endpoint, method, payload, timeoutMs) => + EcencyApi.Infrastructure.ApiClient.ApiRequest(endpoint, method, null, payload, null, timeoutMs); + } + + [Fact] + public async Task ForwardsTheIdempotencyKeyOnTheUpstreamBody() + { + var ctx = CurationDeskTestSupport.Post("/private-api/ai-assist", """ + {"code":"as:alice","action":"summarize","text":"hello world","idempotency_key":"abcd1234efgh"} + """); + await PrivateApi.AiAssist(ctx); + + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_upstream.Calls); + Assert.Equal("ai-assist", call.Endpoint); + Assert.Equal(HttpMethod.Post, call.Method); + var payload = Assert.IsType(call.Payload); + Assert.Equal("alice", payload["us"]?.GetValue()); + Assert.Equal("summarize", payload["action"]?.GetValue()); + Assert.Equal("hello world", payload["text"]?.GetValue()); + Assert.Equal("abcd1234efgh", payload["idempotency_key"]?.GetValue()); + } + + [Fact] + public async Task ForwardsWhenTheClientOmitsTheIdempotencyKey() + { + var ctx = CurationDeskTestSupport.Post("/private-api/ai-assist", """ + {"code":"as:alice","action":"summarize","text":"hello world"} + """); + await PrivateApi.AiAssist(ctx); + + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_upstream.Calls); + var payload = Assert.IsType(call.Payload); + Assert.Equal("alice", payload["us"]?.GetValue()); + Assert.Equal("summarize", payload["action"]?.GetValue()); + Assert.Equal("hello world", payload["text"]?.GetValue()); + Assert.False(payload.ContainsKey("idempotency_key")); + } +} + +[CollectionDefinition("ai-assist", DisableParallelization = true)] +public class AiAssistCollection { } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs index 8d77c4da..8c63eb66 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -597,10 +597,21 @@ public static async Task AiAssistPrice(HttpContext ctx) await Upstream.Pipe(ApiClient.ApiRequest($"ai-assist-price?us={username}", HttpMethod.Get), ctx); } + /// Signed-code validation for AI assist, replaceable for tests (no chain RPC). + internal static Func> AiAssistValidateCode = ValidateCode; + + /// + /// The AI assist upstream call, replaceable so tests can observe the payload + /// without a network. The timeout is the long generation budget the handler passes. + /// + internal static Func> AiAssistUpstream = + (endpoint, method, payload, timeoutMs) => + ApiClient.ApiRequest(endpoint, method, null, payload, null, timeoutMs); + public static async Task AiAssist(HttpContext ctx) { var body = await ctx.ReadBody(); - var username = await ValidateCode(body); + var username = await AiAssistValidateCode(body); if (username == null) { await ctx.SendText(401, "Unauthorized"); @@ -610,9 +621,12 @@ public static async Task AiAssist(HttpContext ctx) { ["us"] = username, }; - MiscCopyIfPresent(data, body, "action", "text"); + // idempotency_key lets a retry recover the same paid assist instead of + // charging a second one. Older clients omit it, so it is copied only when + // present; the upstream validates its format itself. + MiscCopyIfPresent(data, body, "action", "text", "idempotency_key"); // AI assist generation can take a long time; keep it long. - await Upstream.Pipe(ApiClient.ApiRequest("ai-assist", HttpMethod.Post, null, data, null, 120000), ctx); + await Upstream.Pipe(AiAssistUpstream("ai-assist", HttpMethod.Post, data, 120000), ctx); } public static async Task AiTranscribePrice(HttpContext ctx) From 18ecceb909a65a322cd7af5b63381b5446a4d64d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 12:37:38 +0000 Subject: [PATCH 2/2] Read AI assist test codes with the lenient string helper The test validator used GetValue(), which throws on a lone-surrogate escape that production ValidateCode accepts via JsVal.TryGetStringLenient. Note in the parity driver why keyed-retry forwarding stays out of KNOWN_DIVERGENCES: the catalog never sends a valid code, so those cases still match the reference image, and the unit test covers the key. Co-authored-by: hivetrending --- dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs | 7 ++++++- dotnet/parity/driver.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs b/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs index 93387eeb..9740d4e6 100644 --- a/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs +++ b/dotnet/EcencyApi.Tests/AiAssistHandlerTests.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using EcencyApi.Handlers; +using EcencyApi.Infrastructure; using Xunit; namespace EcencyApi.Tests; @@ -18,9 +19,13 @@ public class AiAssistHandlerTests : IDisposable public AiAssistHandlerTests() { // `code` "as:alice" validates as alice; anything else is invalid. + // Same extraction as production ValidateCode: a lone-surrogate escape is a + // real string, and GetValue() throws on it. PrivateApi.AiAssistValidateCode = body => { - var code = body["code"]?.GetValue(); + string? code = null; + if (body["code"] is JsonValue codeValue && JsVal.TryGetStringLenient(codeValue, out var codeStr)) + code = codeStr; return Task.FromResult(code != null && code.StartsWith("as:", StringComparison.Ordinal) ? code[3..] : null); }; PrivateApi.AiAssistUpstream = (endpoint, method, payload, _) => diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index 5c6836e4..c15a6322 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -349,6 +349,17 @@ def norm_body(text): # better reason: balances and APR move every block, so the run-vs-run comparison puts # it in `loose`, which still checks status and content-type. Add an entry here only # for a divergence that is deterministic and not already loose. +# +# Also not listed: /private-api/ai-assist::{min,pop,badcode}. Those catalog bodies +# carry an empty or invalid `code`, so both this build and the reference image +# answer 401 before any upstream call and the responses still match. A keyed retry +# is different only after a valid code: this build copies `idempotency_key` onto +# the upstream body when the client sent one (so a repeat recovers the same paid +# assist instead of being charged again) and omits it when the client did not. The +# reference image dropped the field either way. The catalog never sends a valid +# code, so that difference is covered by AiAssistHandlerTests rather than an entry +# here — an entry would skip status, content-type, and body and hide unrelated +# regressions on the route. def diff(a_name, b_name, loose_name=None):