Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7eb81d6d
|
||
|
|
b8aff19826 | ||
|
|
0a23db241b
|
||
|
|
c838eb4f67
|
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - UI API 1.6.0
|
||||
|
||||
- UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes.
|
||||
- Retain AssistantErrorV1 as a deprecated component for compatibility. Remove dangling `null` schema references from the two bodyless 204 responses (delete conversation and feedback), with an empty-content workaround for core schema traversal. Success payloads are unchanged; the contract test retains the release baseline with explicit exceptions for the error schema migration and these documentation corrections.
|
||||
|
||||
## Unreleased - Central health
|
||||
|
||||
### Added
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
<properties>
|
||||
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
|
||||
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
+40
-17
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Response;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.ContentType;
|
||||
@@ -54,14 +56,14 @@ import io.javalin.http.HttpStatus;
|
||||
** routes, with the same URLs and bodies, so they appear in the served
|
||||
** /qqq/v1/openapi.yaml and a generated client covers them.
|
||||
**
|
||||
** Errors keep the assistant's shape: HTTP status plus {"error", "code"}.
|
||||
** Errors use BasicErrorResponseV1 and retain assistant-specific codes.
|
||||
** Turns and events are text/event-stream; each event type is documented as
|
||||
** AssistantTurnEventV1.
|
||||
*******************************************************************************/
|
||||
public final class AssistantSpecs
|
||||
{
|
||||
public static final String EXTENSION_NAME = "assistant";
|
||||
public static final String EXTENSION_VERSION = "1.2.0";
|
||||
public static final String EXTENSION_VERSION = "1.3.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -227,7 +229,7 @@ public final class AssistantSpecs
|
||||
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, turn.phase, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
|
||||
"seq", integer("Sequence number; resume with ?after=<seq>."), "type", string("The event type (also the SSE event name)."),
|
||||
"data", freeObject("The event's fields, by type. message.start includes startedAt (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional toolName and label.")));
|
||||
rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
|
||||
rs.put("AssistantErrorV1", object("Deprecated since " + EXTENSION_VERSION + ": errors use BasicErrorResponseV1.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
@@ -260,7 +262,7 @@ public final class AssistantSpecs
|
||||
/*******************************************************************************
|
||||
** Base of the assistant's JSON specs: the user's own AssistantApi (with the
|
||||
** request's credentials, forwarded to the UI API by the tools), errors in
|
||||
** the assistant's {"error","code"} shape with their status.
|
||||
** the standard error shape with their status and assistant-specific code.
|
||||
*******************************************************************************/
|
||||
public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
@@ -336,6 +338,26 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bodyless response has empty content. Core 1.6's schema traversal
|
||||
** requires a non-null map even when the response has no schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<Integer, Response> defineResponses()
|
||||
{
|
||||
Map<Integer, Response> responses = super.defineResponses();
|
||||
for(Response response : responses.values())
|
||||
{
|
||||
if(response.getContent() == null)
|
||||
{
|
||||
response.withContent(Map.of());
|
||||
}
|
||||
}
|
||||
return (responses);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the request (for its credentials and the server's local port).
|
||||
*******************************************************************************/
|
||||
@@ -403,7 +425,7 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assistant errors keep their status and {"error","code"}; others go
|
||||
** Assistant errors keep their status and code in the standard shape; others go
|
||||
** through QQQ's mapping (400 / 401 / 403 / 404 / 500).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@@ -413,15 +435,16 @@ public final class AssistantSpecs
|
||||
AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class);
|
||||
if(apiException != null)
|
||||
{
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", apiException.getMessage());
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(apiException.getStatus()), apiException.getMessage());
|
||||
if(apiException.getCode() != null)
|
||||
{
|
||||
body.put("code", apiException.getCode());
|
||||
context.result(JsonUtils.toJson(new BasicErrorResponseV1()
|
||||
.withError(apiException.getMessage())
|
||||
.withCode(apiException.getCode())
|
||||
.withTraceId(QJavalinUtils.getTraceId(context))
|
||||
.toMap()));
|
||||
}
|
||||
context.status(apiException.getStatus());
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJson(body));
|
||||
return;
|
||||
}
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
@@ -436,12 +459,12 @@ public final class AssistantSpecs
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(
|
||||
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1")));
|
||||
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+13
-2
@@ -179,7 +179,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
|
||||
|
||||
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
|
||||
assertThat(send("GET", "/conversations", NO_ACCESS, null).statusCode()).isEqualTo(403);
|
||||
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
|
||||
assertThat(denied.statusCode()).isEqualTo(403);
|
||||
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
|
||||
.containsEntry("message", json(denied).get("error"))
|
||||
.containsEntry("traceId", denied.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
|
||||
setKillSwitch(true);
|
||||
assertThat(json(send("GET", "/config", EDITOR, null))).containsEntry("enabled", false).containsEntry("reason", "KILL_SWITCH");
|
||||
@@ -217,6 +221,9 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
HttpResponse<String> foreign = send("GET", "/conversations/" + id, OTHER_EDITOR, null);
|
||||
assertThat(foreign.statusCode()).isEqualTo(404);
|
||||
assertThat(foreign.body()).doesNotContain("Posts");
|
||||
assertThat(json(foreign)).containsEntry("code", "NOT_FOUND")
|
||||
.containsEntry("message", json(foreign).get("error"))
|
||||
.containsEntry("traceId", foreign.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
assertThat(send("PATCH", "/conversations/" + id, OTHER_EDITOR, "{\"title\":\"x\"}").statusCode()).isEqualTo(404);
|
||||
assertThat(send("DELETE", "/conversations/" + id, OTHER_EDITOR, null).statusCode()).isEqualTo(404);
|
||||
assertThat(send("POST", "/conversations/" + id + "/turns", OTHER_EDITOR, "{\"text\":\"hi\"}").statusCode()).isEqualTo(404);
|
||||
@@ -238,7 +245,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
HttpRequest form = HttpRequest.newBuilder(URI.create(base + "/conversations")).header("Cookie", "sessionId=" + EDITOR)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofString("title=x")).build();
|
||||
assertThat(client.send(form, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/conversations/abc", EDITOR, null).statusCode()).isEqualTo(400);
|
||||
HttpResponse<String> invalid = send("GET", "/conversations/abc", EDITOR, null);
|
||||
assertThat(invalid.statusCode()).isEqualTo(400);
|
||||
assertThat(json(invalid)).containsEntry("code", "BAD_REQUEST")
|
||||
.containsEntry("message", json(invalid).get("error"))
|
||||
.containsEntry("traceId", invalid.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
assertThat(send("POST", "/conversations/" + id + "/turns", EDITOR, "{}").statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(send("DELETE", "/conversations/" + id, EDITOR, null).statusCode()).isEqualTo(204);
|
||||
|
||||
+10
-1
@@ -65,8 +65,17 @@ class AssistantSpecsContractTest extends BaseAssistantTest
|
||||
|
||||
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
|
||||
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// The standard error schema retains error/code and adds message/traceId/fieldErrors. //
|
||||
// The two 204 responses never had bodies; their old refs pointed to schema "null". //
|
||||
// Keep the release baseline and accept only these documented migration corrections. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
List<String> acceptedChanges = List.of(
|
||||
"$ref changed from #/components/schemas/AssistantErrorV1 to #/components/schemas/BasicErrorResponseV1",
|
||||
"DELETE /qqq/v1/assistant/conversations/{id} response 204: content type removed: application/json",
|
||||
"POST /qqq/v1/assistant/messages/{id}/feedback response 204: content type removed: application/json");
|
||||
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
|
||||
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
|
||||
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true", acceptedChanges);
|
||||
assertThat(problems).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user