From c63baecc9ef3a4db5583c77c87b91bcb154b9e6f Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:00:58 +0000 Subject: [PATCH 1/4] fix(release): name the signing method the run actually used, not the preferred one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release published so far has told readers: Windows binaries are signed through Azure Trusted Signing. That was not true. This repository signs Windows with the organization Authenticode certificate — the Azure steps skip, the PFX steps run. Checked against the published v0.2.0 run: `Sign with Azure Trusted Signing` skipped, `Sign with the organization Authenticode certificate` succeeded, and the notes still named Azure. The workflow already forbids exactly this, in the comment above the outputs it carries to `publish`: Notes that claim a signature the run did not produce are worse than no claim: a reader has no way to tell, and the whole point of saying "signed" is that it can be relied on. The mechanism was one output short of being able to honour it. `signed` is a boolean about SUCCESS, so it cannot say which of the two configured methods produced the signature, and the notes filled that gap with the preferred method rather than the used one. `sign-windows` now also exports `method`, `publish` receives it, and the notes name what ran. An unrecognised value is described without naming a method rather than guessed at — "signed, verify with `signtool verify /pa` or against the provenance attestation" is vaguer but true, and a wrong provenance claim is the worse failure because a reader cannot detect it. v0.2.0's published notes were corrected in place, since the false claim is already out there. Found while dispatching the release: the dispatch path itself was fine — repository id matches the pinned guard, the workflow is active and present on the default branch, and a dry run signed and verified both the binaries and the installer before the real publish. --- .github/workflows/release.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35032fbf6f..8b3cf71006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,6 +257,12 @@ jobs: # Separate from `signed`: the binaries can be signed while the installer is not, and the # notes must not round one up into the other. installer: ${{ steps.installer-result.outputs.installer }} + # WHICH method signed, not just whether something did. The notes used to name Azure + # Trusted Signing unconditionally while this repository signs with the organization + # Authenticode certificate, so every published release claimed a method it had not + # used. `signed` cannot carry that -- it is a boolean about success -- and a reader has + # no way to check, which is exactly the failure the comment above warns about. + method: ${{ steps.config.outputs.method }} env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -676,6 +682,7 @@ jobs: UPSTREAM: ${{ needs.version.outputs.upstream }} WINDOWS_SIGNED: ${{ needs.sign-windows.outputs.signed }} WINDOWS_INSTALLER: ${{ needs.sign-windows.outputs.installer }} + WINDOWS_SIGNING_METHOD: ${{ needs.sign-windows.outputs.method }} run: | set -euo pipefail # An annotated tag records a tagger, and a bare runner has no git identity, so @@ -721,7 +728,21 @@ jobs: echo fi if [ "$WINDOWS_SIGNED" = "true" ]; then - echo "Windows binaries are signed through Azure Trusted Signing." + # Named from what the run actually did. An unrecognised value is described + # without naming a method rather than guessed at: a wrong provenance claim is + # worse than a vaguer true one, because a reader cannot tell it is wrong. + case "${WINDOWS_SIGNING_METHOD:-}" in + azure) + echo "Windows binaries are signed through Azure Trusted Signing." + ;; + pfx) + echo "Windows binaries are signed with the Redrob Authenticode certificate." + ;; + *) + echo "Windows binaries are signed. Verify the signature with \`signtool verify /pa\`" + echo "or against the provenance attestation below." + ;; + esac else echo "**Windows binaries in this release are NOT signed**, so Windows will show a" echo "SmartScreen warning on first run. Verify the archive against \`SHA256SUMS\`" From 184a22f84f8f97d1c505ca82982b129347c1208c Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:24:25 +0000 Subject: [PATCH 2/4] feat: reach the console's multi-model endpoints, and print what they cost Two commands, `variants.paraphrase` and `variants.compare`, plus the route, service and schema they need. Both are gated on the Redrob provider being connected: the route answers 404 when it is not, so a user on a local runtime or another vendor is told the feature is unavailable rather than watching a request fail for reasons that look like their own. THE COST IS ALWAYS SHOWN. One of these requests makes several charges, one per model. A user who believes they made one request will be surprised by the bill unless the surprise happens immediately, while they can still decide not to do it again. So every result prints the model that actually answered -- `routedModel`, which under `auto` is the only thing that names it -- with its own cost, its latency, and the total. `compare` offers the answers in a selection list with previews, and only the pick enters the conversation. Session history cannot be pruned: `Revert.State` rewinds to a point and restores FILES with it, and `fork` makes a new session, so neither can remove "these two of three messages". An answer appended to try it out would be permanent. Driven by hand in a terminal, which is the only thing that found three of these: - The result was a toast. The toast is absolutely positioned, capped at sixty columns and dismisses on a timer, so it clipped the cost total and the whole answer and kept only the first few slot rows -- the two things a reader most needs were the two it dropped. Now a dialog, dismissed by the person reading it. - `compare`'s chooser never opened. `DialogAlert` calls `onConfirm` and then clears the dialog stack itself, so a chooser pushed synchronously from that callback is opened and immediately wiped. Deferred by a tick. - Picking an answer appeared to do nothing. The insert was a `void`ed promise with no catch, so its rejection was discarded. A user who has just been charged for several models must be told when the thing they paid for failed to land. `CONSOLE_URL` is now resolved once, in the constants module, rather than at each call site. A per-consumer `Flag.REDROB_CONSOLE_URL ?? CONSOLE_URL` made the override HALF apply: the variants service honoured it while the session's own inference path, the catalog fetch and the provider registration still went to production. Pointing the CLI at a local console then produced one that answered `/variants/paraphrase` locally and 401'd every chat turn against the real one -- a split that reads as a credential bug and is not. Measured after the change: the session's own turn reaches the local console. The HTTP client is provided inside the variants service instead of being demanded from callers. The requirement was propagating out through the route handler into the API's own requirement type and out to every entry point that builds the API; `serve` failed to typecheck with `Type 'HttpClient' is not assignable to type 'Service'`, which reads as a problem with `serve` and is not one. The repository's usual shape is a service node with `deps: () => [..., httpClient]`, sharing one client process-wide; these are two plain functions, so they take their own. That is a real difference and is recorded at the call site. `console-key.ts` extracts the credential resolution both consumers need. There are three origins and nothing bridges them -- `REDROB_API_KEY`, the Integration store, `auth.json` -- with different shapes (`type:"key"` versus `type:"api"`). Reading only some of them already shipped once as a bug: desktop-app users saw six fallback models. Also `loop.start` / `loop.stop`: a goal-driven autopilot. The first version re-sent a fixed instruction, which cannot notice circling because the input never changes. Each cycle now sends the goal, the ledger of approaches already rejected, and an instruction to assess against the goal before taking the next step. It stops on goal met, blocked, a repeated plan (three times, since two is patience -- waiting on a build looks identical), a cycle backstop, or a session failure. Driven by `session.idle` rather than a timer, because a timer fires into a session that is still working and stacks turns. --- .../client/src/generated-effect/client.ts | 469 +++++++++--------- packages/client/src/generated/client.ts | 30 ++ packages/client/src/generated/types.ts | 70 +++ packages/core/src/console-key.ts | 76 +++ packages/core/src/flag/flag.ts | 11 + .../src/plugin/provider/redrob-constants.ts | 19 +- packages/core/src/variants.ts | 149 ++++++ packages/protocol/src/api.ts | 2 + packages/protocol/src/groups/variant.ts | 56 +++ packages/schema/src/variant.ts | 86 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 63 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 123 +++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/variant.ts | 110 ++++ packages/tui/src/feature-plugins/builtins.ts | 4 + .../tui/src/feature-plugins/session/loop.tsx | 450 +++++++++++++++++ .../src/feature-plugins/session/variants.tsx | 377 ++++++++++++++ packages/tui/src/i18n/en.ts | 33 ++ packages/tui/src/i18n/ko.ts | 33 ++ packages/tui/test/loop.test.ts | 156 ++++++ 20 files changed, 2096 insertions(+), 223 deletions(-) create mode 100644 packages/core/src/console-key.ts create mode 100644 packages/core/src/variants.ts create mode 100644 packages/protocol/src/groups/variant.ts create mode 100644 packages/schema/src/variant.ts create mode 100644 packages/server/src/handlers/variant.ts create mode 100644 packages/tui/src/feature-plugins/session/loop.tsx create mode 100644 packages/tui/src/feature-plugins/session/variants.tsx create mode 100644 packages/tui/test/loop.test.ts diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 8f31885f9b..a0e209872b 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -266,152 +266,176 @@ const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1In const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) }) -type Endpoint7_0Request = Parameters[0] -type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } -const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) => - raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { + readonly text: Endpoint7_0Request["payload"]["text"] + readonly models: Endpoint7_0Request["payload"]["models"] + readonly requestId?: Endpoint7_0Request["payload"]["requestId"] +} +const Endpoint7_0 = (raw: RawClient["server.variant"]) => (input: Endpoint7_0Input) => + raw["variant.paraphrase"]({ + payload: { text: input["text"], models: input["models"], requestId: input["requestId"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Request = Parameters[0] type Endpoint7_1Input = { - readonly integrationID: Endpoint7_1Request["params"]["integrationID"] - readonly location?: Endpoint7_1Request["query"]["location"] + readonly messages: Endpoint7_1Request["payload"]["messages"] + readonly models: Endpoint7_1Request["payload"]["models"] + readonly requestId?: Endpoint7_1Request["payload"]["requestId"] } -const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) => +const Endpoint7_1 = (raw: RawClient["server.variant"]) => (input: Endpoint7_1Input) => + raw["variant.compare"]({ + payload: { messages: input["messages"], models: input["models"], requestId: input["requestId"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup7 = (raw: RawClient["server.variant"]) => ({ paraphrase: Endpoint7_0(raw), compare: Endpoint7_1(raw) }) + +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } +const Endpoint8_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint8_0Input) => + raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint8_1Request = Parameters[0] +type Endpoint8_1Input = { + readonly integrationID: Endpoint8_1Request["params"]["integrationID"] + readonly location?: Endpoint8_1Request["query"]["location"] +} +const Endpoint8_1 = (raw: RawClient["server.integration"]) => (input: Endpoint8_1Input) => raw["integration.get"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_2Request = Parameters[0] -type Endpoint7_2Input = { - readonly integrationID: Endpoint7_2Request["params"]["integrationID"] - readonly location?: Endpoint7_2Request["query"]["location"] - readonly key: Endpoint7_2Request["payload"]["key"] - readonly label?: Endpoint7_2Request["payload"]["label"] +type Endpoint8_2Request = Parameters[0] +type Endpoint8_2Input = { + readonly integrationID: Endpoint8_2Request["params"]["integrationID"] + readonly location?: Endpoint8_2Request["query"]["location"] + readonly key: Endpoint8_2Request["payload"]["key"] + readonly label?: Endpoint8_2Request["payload"]["label"] } -const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) => +const Endpoint8_2 = (raw: RawClient["server.integration"]) => (input: Endpoint8_2Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_3Request = Parameters[0] -type Endpoint7_3Input = { - readonly integrationID: Endpoint7_3Request["params"]["integrationID"] - readonly location?: Endpoint7_3Request["query"]["location"] - readonly methodID: Endpoint7_3Request["payload"]["methodID"] - readonly inputs: Endpoint7_3Request["payload"]["inputs"] - readonly label?: Endpoint7_3Request["payload"]["label"] +type Endpoint8_3Request = Parameters[0] +type Endpoint8_3Input = { + readonly integrationID: Endpoint8_3Request["params"]["integrationID"] + readonly location?: Endpoint8_3Request["query"]["location"] + readonly methodID: Endpoint8_3Request["payload"]["methodID"] + readonly inputs: Endpoint8_3Request["payload"]["inputs"] + readonly label?: Endpoint8_3Request["payload"]["label"] } -const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) => +const Endpoint8_3 = (raw: RawClient["server.integration"]) => (input: Endpoint8_3Input) => raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_4Request = Parameters[0] -type Endpoint7_4Input = { - readonly attemptID: Endpoint7_4Request["params"]["attemptID"] - readonly location?: Endpoint7_4Request["query"]["location"] +type Endpoint8_4Request = Parameters[0] +type Endpoint8_4Input = { + readonly attemptID: Endpoint8_4Request["params"]["attemptID"] + readonly location?: Endpoint8_4Request["query"]["location"] } -const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) => +const Endpoint8_4 = (raw: RawClient["server.integration"]) => (input: Endpoint8_4Input) => raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_5Request = Parameters[0] -type Endpoint7_5Input = { - readonly attemptID: Endpoint7_5Request["params"]["attemptID"] - readonly location?: Endpoint7_5Request["query"]["location"] - readonly code?: Endpoint7_5Request["payload"]["code"] +type Endpoint8_5Request = Parameters[0] +type Endpoint8_5Input = { + readonly attemptID: Endpoint8_5Request["params"]["attemptID"] + readonly location?: Endpoint8_5Request["query"]["location"] + readonly code?: Endpoint8_5Request["payload"]["code"] } -const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) => +const Endpoint8_5 = (raw: RawClient["server.integration"]) => (input: Endpoint8_5Input) => raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_6Request = Parameters[0] -type Endpoint7_6Input = { - readonly attemptID: Endpoint7_6Request["params"]["attemptID"] - readonly location?: Endpoint7_6Request["query"]["location"] +type Endpoint8_6Request = Parameters[0] +type Endpoint8_6Input = { + readonly attemptID: Endpoint8_6Request["params"]["attemptID"] + readonly location?: Endpoint8_6Request["query"]["location"] } -const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) => +const Endpoint8_6 = (raw: RawClient["server.integration"]) => (input: Endpoint8_6Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup7 = (raw: RawClient["server.integration"]) => ({ - list: Endpoint7_0(raw), - get: Endpoint7_1(raw), - connectKey: Endpoint7_2(raw), - connectOauth: Endpoint7_3(raw), - attemptStatus: Endpoint7_4(raw), - attemptComplete: Endpoint7_5(raw), - attemptCancel: Endpoint7_6(raw), +const adaptGroup8 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint8_0(raw), + get: Endpoint8_1(raw), + connectKey: Endpoint8_2(raw), + connectOauth: Endpoint8_3(raw), + attemptStatus: Endpoint8_4(raw), + attemptComplete: Endpoint8_5(raw), + attemptCancel: Endpoint8_6(raw), }) -type Endpoint8_0Request = Parameters[0] -type Endpoint8_0Input = { - readonly credentialID: Endpoint8_0Request["params"]["credentialID"] - readonly location?: Endpoint8_0Request["query"]["location"] - readonly label: Endpoint8_0Request["payload"]["label"] +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { + readonly credentialID: Endpoint9_0Request["params"]["credentialID"] + readonly location?: Endpoint9_0Request["query"]["location"] + readonly label: Endpoint9_0Request["payload"]["label"] } -const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) => +const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) => raw["credential.update"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, payload: { label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_1Request = Parameters[0] -type Endpoint8_1Input = { - readonly credentialID: Endpoint8_1Request["params"]["credentialID"] - readonly location?: Endpoint8_1Request["query"]["location"] +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { + readonly credentialID: Endpoint9_1Request["params"]["credentialID"] + readonly location?: Endpoint9_1Request["query"]["location"] } -const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) => +const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) => raw["credential.remove"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) }) +const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) -type Endpoint9_0Request = Parameters[0] -type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } -const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) => +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } +const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_1Request = Parameters[0] -type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] } -const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) => +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] } +const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_2Request = Parameters[0] -type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] } -const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) => +type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] } +const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] -type Endpoint9_3Input = { - readonly sessionID: Endpoint9_3Request["params"]["sessionID"] - readonly id?: Endpoint9_3Request["payload"]["id"] - readonly action: Endpoint9_3Request["payload"]["action"] - readonly resources: Endpoint9_3Request["payload"]["resources"] - readonly save?: Endpoint9_3Request["payload"]["save"] - readonly metadata?: Endpoint9_3Request["payload"]["metadata"] - readonly source?: Endpoint9_3Request["payload"]["source"] - readonly agent?: Endpoint9_3Request["payload"]["agent"] -} -const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) => +type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Input = { + readonly sessionID: Endpoint10_3Request["params"]["sessionID"] + readonly id?: Endpoint10_3Request["payload"]["id"] + readonly action: Endpoint10_3Request["payload"]["action"] + readonly resources: Endpoint10_3Request["payload"]["resources"] + readonly save?: Endpoint10_3Request["payload"]["save"] + readonly metadata?: Endpoint10_3Request["payload"]["metadata"] + readonly source?: Endpoint10_3Request["payload"]["source"] + readonly agent?: Endpoint10_3Request["payload"]["agent"] +} +const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -428,87 +452,87 @@ const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3 Effect.map((value) => value.data), ) -type Endpoint9_4Request = Parameters[0] -type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] } -const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) => +type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] } +const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_5Request = Parameters[0] -type Endpoint9_5Input = { - readonly sessionID: Endpoint9_5Request["params"]["sessionID"] - readonly requestID: Endpoint9_5Request["params"]["requestID"] +type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Input = { + readonly sessionID: Endpoint10_5Request["params"]["sessionID"] + readonly requestID: Endpoint10_5Request["params"]["requestID"] } -const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) => +const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint9_6Request = Parameters[0] -type Endpoint9_6Input = { - readonly sessionID: Endpoint9_6Request["params"]["sessionID"] - readonly requestID: Endpoint9_6Request["params"]["requestID"] - readonly reply: Endpoint9_6Request["payload"]["reply"] - readonly message?: Endpoint9_6Request["payload"]["message"] +type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Input = { + readonly sessionID: Endpoint10_6Request["params"]["sessionID"] + readonly requestID: Endpoint10_6Request["params"]["requestID"] + readonly reply: Endpoint10_6Request["payload"]["reply"] + readonly message?: Endpoint10_6Request["payload"]["message"] } -const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) => +const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup9 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint9_0(raw), - listSaved: Endpoint9_1(raw), - removeSaved: Endpoint9_2(raw), - create: Endpoint9_3(raw), - list: Endpoint9_4(raw), - get: Endpoint9_5(raw), - reply: Endpoint9_6(raw), +const adaptGroup10 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint10_0(raw), + listSaved: Endpoint10_1(raw), + removeSaved: Endpoint10_2(raw), + create: Endpoint10_3(raw), + list: Endpoint10_4(raw), + get: Endpoint10_5(raw), + reply: Endpoint10_6(raw), }) -type Endpoint10_0Request = Parameters[0] -type Endpoint10_0Input = { - readonly location?: Endpoint10_0Request["query"]["location"] - readonly path?: Endpoint10_0Request["query"]["path"] +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { + readonly location?: Endpoint11_0Request["query"]["location"] + readonly path?: Endpoint11_0Request["query"]["path"] } -const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) => +const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { - readonly location?: Endpoint10_1Request["query"]["location"] - readonly query: Endpoint10_1Request["query"]["query"] - readonly type?: Endpoint10_1Request["query"]["type"] - readonly limit?: Endpoint10_1Request["query"]["limit"] +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { + readonly location?: Endpoint11_1Request["query"]["location"] + readonly query: Endpoint11_1Request["query"]["query"] + readonly type?: Endpoint11_1Request["query"]["type"] + readonly limit?: Endpoint11_1Request["query"]["limit"] } -const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) => +const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) }) +const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } -const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) => +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) }) +const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) }) -type Endpoint12_0Request = Parameters[0] -type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) => +type Endpoint13_0Request = Parameters[0] +type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } +const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) }) +const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) }) -const Endpoint13_0 = (raw: RawClient["server.event"]) => () => +const Endpoint14_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -516,23 +540,23 @@ const Endpoint13_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) }) -type Endpoint14_0Request = Parameters[0] -type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } -const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) => +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_1Request = Parameters[0] -type Endpoint14_1Input = { - readonly location?: Endpoint14_1Request["query"]["location"] - readonly command?: Endpoint14_1Request["payload"]["command"] - readonly args?: Endpoint14_1Request["payload"]["args"] - readonly cwd?: Endpoint14_1Request["payload"]["cwd"] - readonly title?: Endpoint14_1Request["payload"]["title"] - readonly env?: Endpoint14_1Request["payload"]["env"] +type Endpoint15_1Request = Parameters[0] +type Endpoint15_1Input = { + readonly location?: Endpoint15_1Request["query"]["location"] + readonly command?: Endpoint15_1Request["payload"]["command"] + readonly args?: Endpoint15_1Request["payload"]["args"] + readonly cwd?: Endpoint15_1Request["payload"]["cwd"] + readonly title?: Endpoint15_1Request["payload"]["title"] + readonly env?: Endpoint15_1Request["payload"]["env"] } -const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) => +const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -544,141 +568,141 @@ const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_2Request = Parameters[0] -type Endpoint14_2Input = { - readonly ptyID: Endpoint14_2Request["params"]["ptyID"] - readonly location?: Endpoint14_2Request["query"]["location"] +type Endpoint15_2Request = Parameters[0] +type Endpoint15_2Input = { + readonly ptyID: Endpoint15_2Request["params"]["ptyID"] + readonly location?: Endpoint15_2Request["query"]["location"] } -const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) => +const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint14_3Request = Parameters[0] -type Endpoint14_3Input = { - readonly ptyID: Endpoint14_3Request["params"]["ptyID"] - readonly location?: Endpoint14_3Request["query"]["location"] - readonly title?: Endpoint14_3Request["payload"]["title"] - readonly size?: Endpoint14_3Request["payload"]["size"] +type Endpoint15_3Request = Parameters[0] +type Endpoint15_3Input = { + readonly ptyID: Endpoint15_3Request["params"]["ptyID"] + readonly location?: Endpoint15_3Request["query"]["location"] + readonly title?: Endpoint15_3Request["payload"]["title"] + readonly size?: Endpoint15_3Request["payload"]["size"] } -const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) => +const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_4Request = Parameters[0] -type Endpoint14_4Input = { - readonly ptyID: Endpoint14_4Request["params"]["ptyID"] - readonly location?: Endpoint14_4Request["query"]["location"] +type Endpoint15_4Request = Parameters[0] +type Endpoint15_4Input = { + readonly ptyID: Endpoint15_4Request["params"]["ptyID"] + readonly location?: Endpoint15_4Request["query"]["location"] } -const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) => +const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup14 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint14_0(raw), - create: Endpoint14_1(raw), - get: Endpoint14_2(raw), - update: Endpoint14_3(raw), - remove: Endpoint14_4(raw), +const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint15_0(raw), + create: Endpoint15_1(raw), + get: Endpoint15_2(raw), + update: Endpoint15_3(raw), + remove: Endpoint15_4(raw), }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } -const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) => +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] } -const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) => +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } +const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint15_2Request = Parameters[0] -type Endpoint15_2Input = { - readonly sessionID: Endpoint15_2Request["params"]["sessionID"] - readonly requestID: Endpoint15_2Request["params"]["requestID"] - readonly answers: Endpoint15_2Request["payload"]["answers"] +type Endpoint16_2Request = Parameters[0] +type Endpoint16_2Input = { + readonly sessionID: Endpoint16_2Request["params"]["sessionID"] + readonly requestID: Endpoint16_2Request["params"]["requestID"] + readonly answers: Endpoint16_2Request["payload"]["answers"] } -const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) => +const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_3Request = Parameters[0] -type Endpoint15_3Input = { - readonly sessionID: Endpoint15_3Request["params"]["sessionID"] - readonly requestID: Endpoint15_3Request["params"]["requestID"] +type Endpoint16_3Request = Parameters[0] +type Endpoint16_3Input = { + readonly sessionID: Endpoint16_3Request["params"]["sessionID"] + readonly requestID: Endpoint16_3Request["params"]["requestID"] } -const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) => +const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup15 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint15_0(raw), - list: Endpoint15_1(raw), - reply: Endpoint15_2(raw), - reject: Endpoint15_3(raw), +const adaptGroup16 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint16_0(raw), + list: Endpoint16_1(raw), + reply: Endpoint16_2(raw), + reject: Endpoint16_3(raw), }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) }) +const adaptGroup17 = (raw: RawClient["server.reference"]) => ({ list: Endpoint17_0(raw) }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { - readonly projectID: Endpoint17_0Request["params"]["projectID"] - readonly location?: Endpoint17_0Request["query"]["location"] - readonly strategy: Endpoint17_0Request["payload"]["strategy"] - readonly directory: Endpoint17_0Request["payload"]["directory"] - readonly name?: Endpoint17_0Request["payload"]["name"] +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { + readonly projectID: Endpoint18_0Request["params"]["projectID"] + readonly location?: Endpoint18_0Request["query"]["location"] + readonly strategy: Endpoint18_0Request["payload"]["strategy"] + readonly directory: Endpoint18_0Request["payload"]["directory"] + readonly name?: Endpoint18_0Request["payload"]["name"] } -const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) => +const Endpoint18_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_1Request = Parameters[0] -type Endpoint17_1Input = { - readonly projectID: Endpoint17_1Request["params"]["projectID"] - readonly location?: Endpoint17_1Request["query"]["location"] - readonly directory: Endpoint17_1Request["payload"]["directory"] - readonly force: Endpoint17_1Request["payload"]["force"] +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { + readonly projectID: Endpoint18_1Request["params"]["projectID"] + readonly location?: Endpoint18_1Request["query"]["location"] + readonly directory: Endpoint18_1Request["payload"]["directory"] + readonly force: Endpoint18_1Request["payload"]["force"] } -const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) => +const Endpoint18_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_2Request = Parameters[0] -type Endpoint17_2Input = { - readonly projectID: Endpoint17_2Request["params"]["projectID"] - readonly location?: Endpoint17_2Request["query"]["location"] +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly projectID: Endpoint18_2Request["params"]["projectID"] + readonly location?: Endpoint18_2Request["query"]["location"] } -const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) => +const Endpoint18_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint17_0(raw), - remove: Endpoint17_1(raw), - refresh: Endpoint17_2(raw), +const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint18_0(raw), + remove: Endpoint18_1(raw), + refresh: Endpoint18_2(raw), }) const adaptClient = (raw: RawClient) => ({ @@ -689,17 +713,18 @@ const adaptClient = (raw: RawClient) => ({ messages: adaptGroup4(raw["server.message"]), models: adaptGroup5(raw["server.model"]), providers: adaptGroup6(raw["server.provider"]), - integrations: adaptGroup7(raw["server.integration"]), - credentials: adaptGroup8(raw["server.credential"]), - permissions: adaptGroup9(raw["server.permission"]), - files: adaptGroup10(raw["server.fs"]), - commands: adaptGroup11(raw["server.command"]), - skills: adaptGroup12(raw["server.skill"]), - events: adaptGroup13(raw["server.event"]), - ptys: adaptGroup14(raw["server.pty"]), - questions: adaptGroup15(raw["server.question"]), - references: adaptGroup16(raw["server.reference"]), - projectCopies: adaptGroup17(raw["server.projectCopy"]), + "server.variant": adaptGroup7(raw["server.variant"]), + integrations: adaptGroup8(raw["server.integration"]), + credentials: adaptGroup9(raw["server.credential"]), + permissions: adaptGroup10(raw["server.permission"]), + files: adaptGroup11(raw["server.fs"]), + commands: adaptGroup12(raw["server.command"]), + skills: adaptGroup13(raw["server.skill"]), + events: adaptGroup14(raw["server.event"]), + ptys: adaptGroup15(raw["server.pty"]), + questions: adaptGroup16(raw["server.question"]), + references: adaptGroup17(raw["server.reference"]), + projectCopies: adaptGroup18(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba..521b25e0b4 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -45,6 +45,10 @@ import type { ProvidersListOutput, ProvidersGetInput, ProvidersGetOutput, + ServerVariantParaphraseInput, + ServerVariantParaphraseOutput, + ServerVariantCompareInput, + ServerVariantCompareOutput, IntegrationsListInput, IntegrationsListOutput, IntegrationsGetInput, @@ -547,6 +551,32 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.variant": { + paraphrase: (input: ServerVariantParaphraseInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/variant/paraphrase`, + body: { text: input["text"], models: input["models"], requestId: input["requestId"] }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 503], + empty: false, + }, + requestOptions, + ), + compare: (input: ServerVariantCompareInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/variant/compare`, + body: { messages: input["messages"], models: input["models"], requestId: input["requestId"] }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 503], + empty: false, + }, + requestOptions, + ), + }, integrations: { list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index ad1bad824e..948d394efd 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2057,6 +2057,76 @@ export type ProvidersGetOutput = { } } +export type ServerVariantParaphraseInput = { + readonly text: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["text"] + readonly models: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["models"] + readonly requestId?: { + readonly text: string + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["requestId"] +} + +export type ServerVariantParaphraseOutput = { + readonly variants: ReadonlyArray<{ + readonly slot: number + readonly model: string + readonly text?: string + readonly error?: string + readonly redrob?: { + readonly requestId?: string + readonly routedModel?: string + readonly upstreamProvider?: string + readonly latencyMs?: number + readonly costUsd?: number + } + }> + readonly totalCostUsd: number +} + +export type ServerVariantCompareInput = { + readonly messages: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["messages"] + readonly models: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["models"] + readonly requestId?: { + readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }> + readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }> + readonly requestId?: string + }["requestId"] +} + +export type ServerVariantCompareOutput = { + readonly variants: ReadonlyArray<{ + readonly slot: number + readonly model: string + readonly text?: string + readonly error?: string + readonly redrob?: { + readonly requestId?: string + readonly routedModel?: string + readonly upstreamProvider?: string + readonly latencyMs?: number + readonly costUsd?: number + } + }> + readonly totalCostUsd: number +} + export type IntegrationsListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/console-key.ts b/packages/core/src/console-key.ts new file mode 100644 index 0000000000..1b542b5dfe --- /dev/null +++ b/packages/core/src/console-key.ts @@ -0,0 +1,76 @@ +export * as ConsoleKey from "./console-key" + +import { join } from "node:path" +import { Effect } from "effect" + +import { Global } from "./global" +import { Integration } from "./integration" + +/** + * The console API key, from wherever the user actually put it. + * + * THREE ORIGINS, and they are not interchangeable historically: + * + * - `REDROB_API_KEY` is what a terminal user exports. + * - The Credential store is what `redrob providers login` writes. It is SQL. + * - `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both of + * its connect paths -- the pasted key and "Connect Redrob". That flow ends by handing the key to this + * same route, so the app never populates the Credential store at all. + * + * Nothing bridges the last two: one is SQL, the other a file, and no migration copies either into the + * other. So every consumer has to read all three. + * + * This lives in its own module because reading only some of them is a bug that has already shipped once: + * the dynamic model catalogue checked the environment and the Credential store, so a user who connected + * through the desktop app had a key present, in `auth.json`, that the catalogue could not see -- it took + * the keyless branch and silently served the six-id fallback list. A second consumer re-deriving this + * logic is how that happens again, so there is one implementation and both callers use it. + * + * Note the SHAPES differ as well as the locations: the Credential store discriminates on `type: "key"`, + * `auth.json` on `type: "api"`. Matching only one spelling was the second half of that same bug. + */ + +/** `auth.json`, the desktop app's store. Absent or malformed reads as "no key" rather than failing. */ +function fromAuthStore(): Effect.Effect { + return Effect.tryPromise(() => Bun.file(join(Global.Path.data, "auth.json")).json()).pipe( + Effect.map((data) => { + if (typeof data !== "object" || data === null) return undefined + const entry = (data as Record)["redrob"] + if (typeof entry !== "object" || entry === null) return undefined + const record = entry as Record + if (record["type"] !== "api") return undefined + const key = record["key"] + return typeof key === "string" && key.trim() ? key : undefined + }), + Effect.orElseSucceed(() => undefined), + ) +} + +/** + * Resolve the key, in the order a user would expect to win. + * + * The environment first, because an explicitly exported value is the one a person set most recently and + * most deliberately -- overriding a stored credential for one run is a thing people do, and the reverse + * would make that impossible. + */ +export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { + const fromEnv = process.env["REDROB_API_KEY"] + if (fromEnv) return fromEnv + + /* + Read through `Integration`, not `Credential` directly. Both reach the same store, but `Credential` is not + in scope everywhere this runs -- the HTTP handlers have `Integration` and resolving through it returns + the same material. Using the lower-level service typechecked and then failed at request time with + "Service not found", which is the kind of error a type system cannot catch for you. + */ + const integration = yield* Integration.Service + const connection = yield* integration.connection.active(Integration.ID.make("redrob")) + if (connection) { + const value = yield* integration.connection + .resolve(connection) + .pipe(Effect.orElseSucceed(() => undefined)) + if (value?.type === "key" && value.key.trim()) return value.key + } + + return yield* fromAuthStore() +}) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index df56580f04..e4dc288133 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -18,6 +18,17 @@ export const Flag = { REDROB_AUTO_HEAP_SNAPSHOT: truthy("REDROB_AUTO_HEAP_SNAPSHOT"), REDROB_GIT_BASH_PATH: process.env["REDROB_GIT_BASH_PATH"], + /** + * Point the console calls at a different base URL. + * + * Exists for verifying against a console running locally. Without it the only way to exercise a change + * to those endpoints is to deploy it, which is how a broken `/variants/paraphrase` reached production and + * stayed there: nothing could call it except the real thing. + * + * Never set in a shipped build, and it does not change where credentials come from -- a local console + * still wants a key it recognises. + */ + REDROB_CONSOLE_URL: process.env["REDROB_CONSOLE_URL"], REDROB_CONFIG: process.env["REDROB_CONFIG"], REDROB_CONFIG_CONTENT: process.env["REDROB_CONFIG_CONTENT"], REDROB_DISABLE_AUTOUPDATE: truthy("REDROB_DISABLE_AUTOUPDATE"), diff --git a/packages/core/src/plugin/provider/redrob-constants.ts b/packages/core/src/plugin/provider/redrob-constants.ts index d5e9733cdc..e4fa82e0ec 100644 --- a/packages/core/src/plugin/provider/redrob-constants.ts +++ b/packages/core/src/plugin/provider/redrob-constants.ts @@ -3,11 +3,28 @@ // ModelsDev.Service (packages/core/src/models-dev.ts) can import them without creating // an import cycle through the heavier plugin/event modules. +import { Flag } from "../../flag/flag" + // The real console.redrob.ai API is an OpenAI-standard base URL. SDKs and the dynamic // catalog fetch append paths to it: /chat/completions for inference and /models for the // OpenAI-standard model listing. It is served by the trusted @ai-sdk/openai-compatible // package (pinned by ConfigProviderPlugin). -export const CONSOLE_URL = "https://console.redrob.ai/api/backend/v1" +// +// Resolved HERE rather than at each call site, because a per-consumer `Flag.REDROB_CONSOLE_URL ?? +// CONSOLE_URL` is what made the override half-apply: the variants service honoured it while the +// session's own inference path, the catalog fetch and the provider registration all still went to +// production. Pointing the CLI at a local console then produced a console that answered +// `/variants/paraphrase` locally and 401'd every chat turn against the real one -- a split that looks +// like a credential bug and is not. +// +// The flag is a developer seam, never set in a shipped build, and it does not change where +// credentials come from: a local console still wants a key it recognises. Importing Flag is safe from +// this leaf module because `flag/flag.ts` imports only `effect` -- the cycle this file avoids is +// through the plugin and event modules, which Flag does not touch. +export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace( + /\/+$/, + "", +) export const CONSOLE_PACKAGE = "@ai-sdk/openai-compatible" // Every served console model publishes `capabilities.maxContextTokens: 1_000_000`. Held as one // constant because the value is uniform across the six ids, and used as the no-key fallback diff --git a/packages/core/src/variants.ts b/packages/core/src/variants.ts new file mode 100644 index 0000000000..8190cab44e --- /dev/null +++ b/packages/core/src/variants.ts @@ -0,0 +1,149 @@ +export * as Variants from "./variants" + +import { Effect, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" + +import { Variant } from "@redrob-code/schema/variant" + +import { ConsoleKey } from "./console-key" +import { CONSOLE_URL } from "./plugin/provider/redrob-constants" + +/** + * The console's multi-model endpoints, reached once on behalf of every client. + * + * `/v1/variants/paraphrase` and `/v1/variants/compare` are not OpenAI-standard, so they cannot arrive + * through the `@ai-sdk/openai-compatible` path. Putting them here rather than in each caller keeps one + * place that knows how to find the credential, what the console's envelope looks like, and how its + * refusals map -- three things that would otherwise drift between the CLI and the desktop apps. + * + * WHAT IS DELIBERATELY PASSED THROUGH RATHER THAN SUMMARISED: the per-slot `redrob` block, carrying + * `routedModel` and `costUsd`. When a request names `auto`, `routedModel` is the only thing that says which + * model actually answered, and one request here makes several charges -- so a caller that wants to show a + * user what was used and what it cost needs both, per slot, not a total. + */ + +/** No retries and no long wait: these are user-initiated and a caller can ask again. */ +const REQUEST_TIMEOUT = "120 seconds" + +export class NotConnected extends Error { + readonly _tag = "VariantsNotConnected" + constructor() { + super("the Redrob provider is not connected, so the console's multi-model endpoints are unavailable") + } +} + +export class Refused extends Error { + readonly _tag = "VariantsRefused" + constructor( + readonly status: number, + message: string, + /** Seconds the console asked the caller to wait, when it said. Surfaced so a UI can say when to retry. */ + readonly retryAfterSeconds?: number, + ) { + super(message) + } +} + +/** + * The console's own error envelope. + * + * Read rather than ignored because the message is the useful part: "This API key has spent $25.00 of its + * $25.00 monthly budget" tells a user what to do, and the status alone does not. A body that does not parse + * falls back to the status, which is still better than nothing. + */ +const ErrorEnvelope = Schema.Struct({ + message: Schema.String.pipe(Schema.optional), + error: Schema.Struct({ message: Schema.String.pipe(Schema.optional) }).pipe(Schema.optional), + retryAfterSeconds: Schema.Finite.pipe(Schema.optional), +}) + +function describe(status: number, body: string): { message: string; retryAfterSeconds?: number } { + const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelope))(body) + if (parsed._tag === "None") return { message: `the console refused the request with ${status}` } + const value = parsed.value + return { + message: + value.error?.message ?? value.message ?? `the console refused the request with ${status}`, + retryAfterSeconds: value.retryAfterSeconds, + } +} + +const call = Effect.fn("Variants.call")(function* ( + path: string, + schema: Schema.Codec, + payload: A, +) { + const apiKey = yield* ConsoleKey.resolve() + /* + No key means the provider is not connected, which is a different thing from a rejected key: there is + nothing to ask. Callers turn this into a 404 rather than a 401, so a user on a local runtime is told the + feature is not available instead of being sent to check a credential they never set. + */ + if (!apiKey) return yield* Effect.fail(new NotConnected()) + + const http = yield* HttpClient.HttpClient + /* + Encoded THROUGH THE SCHEMA rather than handed to a generic JSON body: the request shape is declared once + and this is what holds the wire format to it, so a field renamed in the schema cannot silently keep + sending the old name. + */ + /* + `CONSOLE_URL` already carries the developer override and is already trailing-slash trimmed. Resolving + the flag a second time here is what let the override apply to this service alone while the session's + inference path kept talking to production. + */ + const request = yield* HttpClientRequest.post(`${CONSOLE_URL}${path}`).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.schemaBodyJson(schema)(payload), + ) + + const response = yield* http.execute(request).pipe(Effect.timeout(REQUEST_TIMEOUT)) + const body = yield* response.text + + if (response.status < 200 || response.status >= 300) { + const { message, retryAfterSeconds } = describe(response.status, body) + /* + `Retry-After` is preferred over the body figure when both are present: it is the header HTTP clients + are built around, and the console sets it on exactly the statuses where waiting is the remedy. + */ + const header = response.headers["retry-after"] + const fromHeader = typeof header === "string" ? Number(header) : Number.NaN + return yield* Effect.fail( + new Refused( + response.status, + message, + Number.isFinite(fromHeader) ? fromHeader : retryAfterSeconds, + ), + ) + } + + const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Variant.Result))(body) + if (decoded._tag === "None") { + return yield* Effect.fail(new Refused(response.status, "could not read the console's response")) + } + return decoded.value +}) + +/* + The HTTP client is provided HERE rather than demanded from callers. + + Without this the `HttpClient` requirement propagated out of these two functions, through the route + handler, into the API's own requirement type, and out to every entry point that builds the API -- the + `serve` command failed to typecheck with `Type 'HttpClient' is not assignable to type 'Service'`, which + reads as a problem with `serve` and is not one. A leaf that makes one outbound call should not widen the + contract of everything above it. + + The repository's usual shape for this is a service node with `deps: () => [..., httpClient]`, which + shares one client process-wide. These are two plain functions rather than a service, so they take their + own fetch client instead. That is a real difference -- no shared connection pooling with the rest of the + CLI -- and it is proportionate for two endpoints called only on an explicit user action. Turning this + into a service node is the right move if it ever grows a third caller. +*/ +const withHttp = (effect: Effect.Effect) => + Effect.provide(effect, FetchHttpClient.layer) + +export const paraphrase = (request: Variant.ParaphraseRequest) => + withHttp(call("/variants/paraphrase", Variant.ParaphraseRequest, request)) + +export const compare = (request: Variant.CompareRequest) => + withHttp(call("/variants/compare", Variant.CompareRequest, request)) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index f58cd3128e..e7256237ca 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -4,6 +4,7 @@ import { SchemaErrorMiddleware } from "./middleware/schema-error" import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" import { ProviderGroup } from "./groups/provider" +import { VariantGroup } from "./groups/variant" import { makeSessionGroup } from "./groups/session" import { makePermissionGroup } from "./groups/permission" import { FileSystemGroup } from "./groups/fs" @@ -42,6 +43,7 @@ const makeApiFromGroup = < .add(MessageGroup.middleware(sessionLocationMiddleware)) .add(ModelGroup.middleware(locationMiddleware)) .add(ProviderGroup.middleware(locationMiddleware)) + .add(VariantGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) diff --git a/packages/protocol/src/groups/variant.ts b/packages/protocol/src/groups/variant.ts new file mode 100644 index 0000000000..685c766071 --- /dev/null +++ b/packages/protocol/src/groups/variant.ts @@ -0,0 +1,56 @@ +import { Variant } from "@redrob-code/schema/variant" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { + InvalidRequestError, + ProviderNotFoundError, + ServiceUnavailableError, + UnauthorizedError, +} from "../errors" + +/** + * Asking several models the same thing. + * + * Exposed here rather than reached directly by each client, so the credential resolution, retries and + * error mapping exist once. The CLI's commands, the desktop apps, and anything else on the SDK all get the + * same behaviour, and the endpoints stay usable by a client that has no idea the console exists. + * + * `ProviderNotFoundError` is the refusal when the Redrob provider is not connected. That is a 404 about a + * provider rather than a 400 about the request, because the request was fine -- there is simply nothing + * here to serve it. A user on a local runtime or another vendor learns that immediately instead of seeing a + * credential error from a call that could never have worked. + */ +export const VariantGroup = HttpApiGroup.make("server.variant") + .add( + HttpApiEndpoint.post("variant.paraphrase", "/api/variant/paraphrase", { + payload: Variant.ParaphraseRequest, + success: Variant.Result, + error: [InvalidRequestError, UnauthorizedError, ProviderNotFoundError, ServiceUnavailableError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.variant.paraphrase", + summary: "Rewrite one text with several models", + description: + "Send the same text to two or more models and get each rewrite back, with the model that actually answered and what that slot cost.", + }), + ), + ) + .add( + HttpApiEndpoint.post("variant.compare", "/api/variant/compare", { + payload: Variant.CompareRequest, + success: Variant.Result, + error: [InvalidRequestError, UnauthorizedError, ProviderNotFoundError, ServiceUnavailableError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.variant.compare", + summary: "Answer one conversation with several models", + description: + "Send the same messages to two or more models and get each answer back, so a caller can offer them as a choice.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "variants", + description: "Experimental multi-model routes. Available while the Redrob provider is connected.", + }), + ) diff --git a/packages/schema/src/variant.ts b/packages/schema/src/variant.ts new file mode 100644 index 0000000000..701a82177b --- /dev/null +++ b/packages/schema/src/variant.ts @@ -0,0 +1,86 @@ +export * as Variant from "./variant" + +import { Schema } from "effect" +import { optional } from "./schema" + +/** + * Asking several models the same thing, through the Redrob console. + * + * These are NOT OpenAI-standard endpoints, so they cannot arrive through the `@ai-sdk/openai-compatible` + * path every other model call uses. They are exposed here instead, which keeps ONE implementation for the + * three consumers that need it: the CLI's own commands, the desktop apps that read this server, and + * anything else built on the SDK. A second client path would mean re-implementing credential resolution, + * retries and error mapping, and those would drift. + * + * Available only while the Redrob provider is connected, because that is what the endpoints belong to. A + * user on a local runtime or another vendor gets a plain 404 rather than a confusing failure inside a + * request that could never have worked. + */ + +/** A model to ask, and how hard to make it think. */ +export const Model = Schema.Struct({ + model: Schema.String, + variant: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.Model" }) +export interface Model extends Schema.Schema.Type {} + +/** + * What the console reports about serving one slot. + * + * `routedModel` is the load-bearing field and the reason this is passed through rather than summarised: + * when the request names `auto`, it is the only thing that says which model actually answered. Without it + * the most a client can tell a user is "Redrob Auto did it", which is not the transparency the feature is + * for. `costUsd` is per slot, because one request here makes several charges. + */ +export const Provenance = Schema.Struct({ + requestId: Schema.String.pipe(optional), + routedModel: Schema.String.pipe(optional), + upstreamProvider: Schema.String.pipe(optional), + latencyMs: Schema.Finite.pipe(optional), + costUsd: Schema.Finite.pipe(optional), +}).annotate({ identifier: "Variant.Provenance" }) +export interface Provenance extends Schema.Schema.Type {} + +/** + * One slot's outcome. + * + * `text` and `error` are both optional and exactly one is expected, because the console runs the slots + * under `Promise.allSettled`: a request can come back with some slots served and others failed. Modelling + * that as an optional pair rather than a union keeps a partial result readable instead of forcing a client + * to discard the slots that did work. + */ +export const Slot = Schema.Struct({ + slot: Schema.Int, + model: Schema.String, + text: Schema.String.pipe(optional), + error: Schema.String.pipe(optional), + redrob: Provenance.pipe(optional), +}).annotate({ identifier: "Variant.Slot" }) +export interface Slot extends Schema.Schema.Type {} + +export const Result = Schema.Struct({ + variants: Schema.Array(Slot), + /** The sum the console already computed, so no client has to add the slots up itself. */ + totalCostUsd: Schema.Finite, +}).annotate({ identifier: "Variant.Result" }) +export interface Result extends Schema.Schema.Type {} + +export const ParaphraseRequest = Schema.Struct({ + text: Schema.String, + models: Schema.Array(Model), + requestId: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.ParaphraseRequest" }) +export interface ParaphraseRequest extends Schema.Schema.Type {} + +export const Message = Schema.Struct({ + role: Schema.Literals(["system", "user", "assistant"]), + content: Schema.String, +}).annotate({ identifier: "Variant.Message" }) +export interface Message extends Schema.Schema.Type {} + +export const CompareRequest = Schema.Struct({ + messages: Schema.Array(Message), + models: Schema.Array(Model), + requestId: Schema.String.pipe(optional), +}).annotate({ identifier: "Variant.CompareRequest" }) +export interface CompareRequest extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index aad9a38d9e..0b90a58f55 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -385,6 +385,12 @@ import type { V2SessionWaitResponses, V2SkillListErrors, V2SkillListResponses, + V2VariantCompareErrors, + V2VariantCompareResponses, + V2VariantParaphraseErrors, + V2VariantParaphraseResponses, + VariantCompareRequest, + VariantParaphraseRequest, VcsApplyErrors, VcsApplyResponses, VcsDiffErrors, @@ -5954,6 +5960,58 @@ export class Provider2 extends HeyApiClient { } } +export class Variant extends HeyApiClient { + /** + * Rewrite one text with several models + * + * Send the same text to two or more models and get each rewrite back, with the model that actually answered and what that slot cost. + */ + public paraphrase( + parameters: { + variantParaphraseRequest: VariantParaphraseRequest + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "variantParaphraseRequest", map: "body" }] }]) + return (options?.client ?? this.client).post( + { + url: "/api/variant/paraphrase", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + + /** + * Answer one conversation with several models + * + * Send the same messages to two or more models and get each answer back, so a caller can offer them as a choice. + */ + public compare( + parameters: { + variantCompareRequest: VariantCompareRequest + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "variantCompareRequest", map: "body" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/variant/compare", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Connect extends HeyApiClient { /** * Connect with key @@ -7018,6 +7076,11 @@ export class V2 extends HeyApiClient { return (this._provider ??= new Provider2({ client: this.client })) } + private _variant?: Variant + get variant(): Variant { + return (this._variant ??= new Variant({ client: this.client })) + } + private _integration?: Integration get integration(): Integration { return (this._integration ??= new Integration({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e323c89178..a609f30ae7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -358,6 +358,8 @@ export type AssistantMessage = { } summary?: boolean cost: number + routedModel?: string + upstreamProvider?: string tokens: { total?: number input: number @@ -2017,6 +2019,8 @@ export type Config = { tail_turns?: number preserve_recent_tokens?: number reserved?: number + threshold?: number + maxTurnInputCostUsd?: number } experimental?: { disable_paste_summary?: boolean @@ -4126,6 +4130,8 @@ export type SessionMessageAssistant = { } finish?: string cost?: number + routedModel?: string + upstreamProvider?: string tokens?: { input: number output: number @@ -4875,6 +4881,49 @@ export type ProviderV2Info = { request: ProviderRequest } +export type VariantModel = { + model: string + variant?: string +} + +export type VariantParaphraseRequest = { + text: string + models: Array + requestId?: string +} + +export type VariantProvenance = { + requestId?: string + routedModel?: string + upstreamProvider?: string + latencyMs?: number + costUsd?: number +} + +export type VariantSlot = { + slot: number + model: string + text?: string + error?: string + redrob?: VariantProvenance +} + +export type VariantResult = { + variants: Array + totalCostUsd: number +} + +export type VariantMessage = { + role: "system" | "user" | "assistant" + content: string +} + +export type VariantCompareRequest = { + messages: Array + models: Array + requestId?: string +} + export type IntegrationWhen = { key: string op: "eq" | "neq" @@ -12160,6 +12209,80 @@ export type V2ProviderGetResponses = { export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] +export type V2VariantParaphraseData = { + body: VariantParaphraseRequest + path?: never + query?: never + url: "/api/variant/paraphrase" +} + +export type V2VariantParaphraseErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2VariantParaphraseError = V2VariantParaphraseErrors[keyof V2VariantParaphraseErrors] + +export type V2VariantParaphraseResponses = { + /** + * Variant.Result + */ + 200: VariantResult +} + +export type V2VariantParaphraseResponse = V2VariantParaphraseResponses[keyof V2VariantParaphraseResponses] + +export type V2VariantCompareData = { + body: VariantCompareRequest + path?: never + query?: never + url: "/api/variant/compare" +} + +export type V2VariantCompareErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2VariantCompareError = V2VariantCompareErrors[keyof V2VariantCompareErrors] + +export type V2VariantCompareResponses = { + /** + * Variant.Result + */ + 200: VariantResult +} + +export type V2VariantCompareResponse = V2VariantCompareResponses[keyof V2VariantCompareResponses] + export type V2IntegrationListData = { body?: never path?: never diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 3a5e2e777e..2f7bb9e92d 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" +import { VariantHandler } from "./handlers/variant" import { SessionHandler } from "./handlers/session" import { PermissionHandler } from "./handlers/permission" import { FileSystemHandler } from "./handlers/fs" @@ -26,6 +27,7 @@ export const handlers = Layer.mergeAll( MessageHandler, ModelHandler, ProviderHandler, + VariantHandler, IntegrationHandler, CredentialHandler, PermissionHandler, diff --git a/packages/server/src/handlers/variant.ts b/packages/server/src/handlers/variant.ts new file mode 100644 index 0000000000..1a1a653b8a --- /dev/null +++ b/packages/server/src/handlers/variant.ts @@ -0,0 +1,110 @@ +import { Variants } from "@redrob-code/core/variants" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" + +import { Api } from "../api" +import { + InvalidRequestError, + ProviderNotFoundError, + ServiceUnavailableError, + UnauthorizedError, +} from "@redrob-code/protocol/errors" + +/** + * The multi-model routes, and the one place the console's refusals become this API's errors. + * + * Mapping by STATUS rather than passing the console's status through, because the two APIs answer different + * questions. A 402 from the console means that workspace's key is over its budget -- to a caller of this + * server that is an authorization problem with the upstream account, not a payment this server is asking + * for. Forwarding 402 would invite a client to show a checkout it cannot complete. + * + * The console's MESSAGE is always carried through, because it is the part a person can act on: "This API + * key has spent $25.00 of its $25.00 monthly budget" says what to do and a bare status does not. + */ + +/** Why no key means 404 rather than 401: there is nothing configured to ask, so nothing refused us. */ +const notConnected = () => + new ProviderNotFoundError({ + providerID: "redrob", + message: + "The Redrob provider is not connected, so the multi-model endpoints are unavailable. Connect it with `redrob providers login`.", + }) + +function refusal(error: Variants.Refused) { + const status = error.status + if (status === 401 || status === 403) { + return new UnauthorizedError({ message: error.message }) + } + if (status === 402) { + /* + Out of credit, or a key over its monthly budget. Surfaced as unauthorized rather than as a payment + this server wants: the money is owed to the console by the account behind the key, and the caller of + this route may not be the person who can settle it. + */ + return new UnauthorizedError({ message: error.message }) + } + if (status === 429) { + /* + Rate limited. `ServiceUnavailableError` because the remedy is to wait, and the message already says + how long -- the console puts the figure in the sentence as well as in `Retry-After`. + */ + return new ServiceUnavailableError({ message: error.message }) + } + if (status >= 400 && status < 500) { + return new InvalidRequestError({ message: error.message }) + } + return new ServiceUnavailableError({ message: error.message }) +} + +/* + Generic over BOTH the error and requirement channels, because the service can fail in more ways than its + own two: the HTTP client contributes transport failures and the timeout contributes its own. Pinning those + away claimed they could not happen, and the type checker caught it -- which matters, because an unhandled + transport failure would have surfaced as an unmapped 500 with no message for the caller. +*/ +/** The union this route declares. Named so the catch below cannot be narrowed to its first branch. */ +type RouteError = + | InvalidRequestError + | UnauthorizedError + | ProviderNotFoundError + | ServiceUnavailableError + +const handle = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catch((error): Effect.Effect => { + if (error instanceof Variants.Refused) return Effect.fail(refusal(error)) + if (error instanceof Variants.NotConnected) return Effect.fail(notConnected()) + /* + Timeout, DNS failure, connection refused, a body that would not encode. All of them mean the console + could not be reached or answered, which is the caller's cue to try again rather than to change the + request -- so they are service-unavailable, and the underlying message is carried so the reason is + not lost. + */ + return Effect.fail( + new ServiceUnavailableError({ + message: + error instanceof Error + ? `could not reach the Redrob console: ${error.message}` + : "could not reach the Redrob console", + }), + ) + }), + ) + +export const VariantHandler = HttpApiBuilder.group(Api, "server.variant", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "variant.paraphrase", + Effect.fn(function* (ctx) { + return yield* handle(Variants.paraphrase(ctx.payload)) + }), + ) + .handle( + "variant.compare", + Effect.fn(function* (ctx) { + return yield* handle(Variants.compare(ctx.payload)) + }), + ) + }), +) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 1b7150a518..001f4d6749 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -11,6 +11,8 @@ import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" +import SessionLoop from "./session/loop" +import SessionVariants from "./session/variants" export type BuiltinTuiPlugin = Omit & { id: string @@ -32,5 +34,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean PluginManager, WhichKey, DiffViewer, + SessionLoop, + SessionVariants, ] } diff --git a/packages/tui/src/feature-plugins/session/loop.tsx b/packages/tui/src/feature-plugins/session/loop.tsx new file mode 100644 index 0000000000..125069bb65 --- /dev/null +++ b/packages/tui/src/feature-plugins/session/loop.tsx @@ -0,0 +1,450 @@ +import type { TuiPlugin, TuiPluginApi } from "@redrob-code/plugin/tui" + +import type { BuiltinTuiPlugin } from "../builtins" +import { kvTranslator } from "../../i18n" + +const id = "internal:loop" + +/** + * Autopilot: hold a GOAL and keep nudging toward it, one turn at a time, until it is met. + * + * The distinction that matters, and that the first version of this file got wrong: an autopilot does not + * re-send a fixed instruction. Re-sending the same sentence gives the model the same input every cycle, + * so it has no way to notice it is going in circles and no reason to change approach. What makes a loop + * useful is that each cycle knows the GOAL and knows WHAT HAS ALREADY BEEN TRIED. + * + * So each cycle sends three things: the goal, a ledger of approaches already rejected, and a request to + * assess where the work stands before taking the next step. The ledger is the part that earns its keep -- + * without it cycle N+1 walks into the wall cycle N just hit, and the loop's cost grows while its progress + * does not. + * + * TERMINAL ONLY. This is session control, not a product capability: nothing reaches the console, the loop + * itself spends no credit, and there is no API for it. Every cycle is an ORDINARY prompt, so it costs what + * typing the same thing would have cost and appears in usage as exactly that. + */ + +/** A backstop, not a target. A loop that reaches this did not finish; it ran out of rope. */ +const DEFAULT_MAX_CYCLES = 25 + +/** + * How many identical next-steps in a row count as stuck. + * + * Two is too eager -- a model legitimately repeats a step while waiting for something external, like a + * build. Three consecutive identical plans is not patience, it is a loop with no exit. + */ +const STALL_THRESHOLD = 3 + +/** + * What the model is asked to emit, and what this plugin reads back. + * + * Exact strings, because they are a contract between the prompt text and the parser. A model asked to + * "say when it is done" has no way to signal that in a form code can read, so it is given the words. + */ +export const LOOP_MARKERS = { + /** The goal is met. Requires the model to say HOW it knows, which is the next marker. */ + done: "AUTOPILOT: GOAL MET", + /** The model cannot proceed without a person -- a decision, a credential, an access it lacks. */ + blocked: "AUTOPILOT: BLOCKED", + /** Prefix for the one-line plan for this cycle. Repeated verbatim is what stall detection reads. */ + next: "AUTOPILOT NEXT:", + /** Prefix for an approach that failed, carried into later cycles so they do not retry it. */ + rejected: "AUTOPILOT REJECTED:", +} as const + +export type LoopLedger = { + /** The binding objective. Fixed for the life of the loop; the per-cycle instruction is derived from it. */ + goal: string + /** One line per approach the model reported as failed, so later cycles do not re-walk them. */ + rejected: string[] + /** The most recent plans, newest last. Only used to notice repetition. */ + recentNext: string[] +} + +type LoopState = { + sessionID: string + ledger: LoopLedger + cycles: number + maxCycles: number + /** Set while a cycle's prompt is in flight, so one completion cannot start two cycles. */ + sending: boolean + /** Accumulates this cycle's assistant text, which arrives in fragments. */ + buffer: string +} + +/** + * Whether the recent plans show the loop going in circles. + * + * Compared after normalising whitespace and case, because "Run the tests" and "run the tests." are the + * same plan and a loop that only notices byte-identical repetition notices nothing. + */ +export function isStalled(recentNext: readonly string[], threshold = STALL_THRESHOLD): boolean { + if (recentNext.length < threshold) return false + const tail = recentNext.slice(-threshold).map(line => line.trim().toLowerCase().replace(/\s+/g, " ").replace(/[.!]+$/, "")) + return tail.every(line => line.length > 0 && line === tail[0]) +} + +/** Pull the markers out of a cycle's reply. Tolerant of surrounding prose -- models add it. */ +export function readMarkers(text: string): { + done: boolean + blocked: boolean + next: string | undefined + rejected: string[] +} { + const lines = text.split("\n").map(line => line.trim()) + const after = (prefix: string) => + lines.filter(line => line.startsWith(prefix)).map(line => line.slice(prefix.length).trim()) + const nextLines = after(LOOP_MARKERS.next) + return { + done: text.includes(LOOP_MARKERS.done), + blocked: text.includes(LOOP_MARKERS.blocked), + /* The LAST plan wins: a model that revises mid-reply meant the revision. */ + next: nextLines.length > 0 ? nextLines[nextLines.length - 1] : undefined, + rejected: after(LOOP_MARKERS.rejected).filter(line => line.length > 0), + } +} + +/** + * The prompt for one cycle. + * + * Built fresh every time rather than stored, because its whole point is to carry state that has changed: + * the rejected list grows, and the cycle number moves. A stored instruction cannot do that. + */ +export function cyclePrompt(ledger: LoopLedger, cycle: number, maxCycles: number): string { + const parts: string[] = [ + `You are on autopilot. This is cycle ${cycle} of at most ${maxCycles}.`, + "", + "GOAL", + ledger.goal, + ] + + if (ledger.rejected.length > 0) { + parts.push( + "", + "ALREADY TRIED AND REJECTED -- do not repeat these:", + ...ledger.rejected.map(item => `- ${item}`), + ) + } + + parts.push( + "", + "Assess where the work stands against the GOAL, then take the single next step. Do the work; do not", + "just describe it.", + "", + "End your reply with these lines:", + ` ${LOOP_MARKERS.next} `, + ` ${LOOP_MARKERS.rejected} (only if you ruled one out)`, + "", + `When the GOAL is met, say ${LOOP_MARKERS.done} and state how you verified it.`, + `If you cannot proceed without a person -- a decision, a credential, an access you lack -- say`, + `${LOOP_MARKERS.blocked} and say what you need. Do not guess at it and do not keep trying.`, + ) + + return parts.join("\n") +} + + +/** + * Where a loop's ledger is kept between runs. + * + * Through `api.kv`, which is already written atomically under a lock to `paths.state/kv.json` -- so this + * needs no file handling of its own, and cannot race the rest of the TUI's persisted state. + * + * WHAT THIS ACTUALLY BUYS, stated precisely because it is easy to overclaim: the ledger lives in plugin + * memory, which context compaction does not touch, and `cyclePrompt` re-sends the rejected list every + * cycle, so the model already recovers it after a compaction. What is NOT survivable without this is the + * CLI exiting -- a crash, a restart, closing the terminal -- which today loses every approach the loop + * learned was a dead end. Restoring it means a resumed loop does not re-walk them. + */ +const LEDGER_KEY_PREFIX = "loop.ledger." + +function ledgerKey(sessionID: string): string { + return `${LEDGER_KEY_PREFIX}${sessionID}` +} + +function saveLedger(api: TuiPluginApi, sessionID: string, ledger: LoopLedger): void { + api.kv.set(ledgerKey(sessionID), ledger) +} + +function clearLedger(api: TuiPluginApi, sessionID: string): void { + /* Cleared rather than left behind: a finished goal's dead ends are not advice for the next goal. */ + api.kv.set(ledgerKey(sessionID), undefined) +} + +/** + * Read a stored ledger back, defensively. + * + * Anything on disk is untrusted input -- an older build wrote a different shape, a hand-edited file, a + * truncated write. A malformed ledger is treated as absent rather than crashing the loop or, worse, + * feeding the model a half-read list of things it must not retry. + */ +export function parseLedger(value: unknown): LoopLedger | undefined { + if (typeof value !== "object" || value === null) return undefined + const record = value as Record + if (typeof record.goal !== "string" || record.goal.trim().length === 0) return undefined + const strings = (input: unknown): string[] => + Array.isArray(input) ? input.filter((item): item is string => typeof item === "string") : [] + return { + goal: record.goal, + rejected: strings(record.rejected), + recentNext: strings(record.recentNext), + } +} + +/** + * The session the user is looking at, read from the route rather than from a "current session" accessor, + * because the plugin state exposes sessions by id and has no notion of which one is on screen. + */ +function activeSessionID(api: TuiPluginApi): string | undefined { + const route = api.route.current + if (route.name !== "session") return undefined + const sessionID = route.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +const tui: TuiPlugin = async (api) => { + /* + One loop per session. Sessions are independent conversations, and autopilot is a property of the thing + being driven -- running one goal in one session while asking a question in another is ordinary. + */ + const loops = new Map() + + const stop = (sessionID: string, reason: string, variant: "info" | "error" = "info") => { + const state = loops.get(sessionID) + if (!state) return false + loops.delete(sessionID) + clearLedger(api, sessionID) + api.ui.toast({ + title: kvTranslator(api.kv).t("loop.stopped.title"), + message: reason, + variant, + }) + return true + } + + const send = async (state: LoopState) => { + state.sending = true + state.buffer = "" + const text = cyclePrompt(state.ledger, state.cycles + 1, state.maxCycles) + try { + await api.client.session.prompt( + { sessionID: state.sessionID, parts: [{ type: "text", text }] }, + { throwOnError: true }, + ) + state.cycles += 1 + } catch (error) { + /* + A failed send ends the loop rather than retrying. The turn that would carry the work never started, + so retrying blind re-sends into a session whose state this plugin cannot see -- and a loop that + stops visibly is easier to recover from than one that silently doubles up. + */ + loops.delete(state.sessionID) + api.ui.toast({ + title: kvTranslator(api.kv).t("loop.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + } finally { + state.sending = false + } + } + + /* Collect the reply as it streams, so the markers can be read once the turn is complete. */ + api.event.on("message.part.updated", (event) => { + const part = event.properties.part + if (part.type !== "text") return + const sessionID = event.properties.sessionID ?? part.sessionID + if (typeof sessionID !== "string") return + const state = loops.get(sessionID) + if (!state || typeof part.text !== "string") return + state.buffer = part.text + }) + + /* + `session.idle` is the completion signal: the turn is over and the model has produced the thing the next + cycle must react to. A timer would fire into a session still working, stacking turns on top of each + other -- expensive, and useless because the input the next cycle needs does not exist yet. + */ + api.event.on("session.idle", (event) => { + const sessionID = event.properties.sessionID + const state = loops.get(sessionID) + if (!state || state.sending) return + + const t = kvTranslator(api.kv) + const markers = readMarkers(state.buffer) + + if (markers.done) { + stop(sessionID, t.t("loop.stopped.done")) + return + } + + if (markers.blocked) { + /* + Stopping on BLOCKED is the point of having the marker. A loop that keeps prompting a model which has + said it needs a human decision burns cycles to re-learn the same thing, and buries the one message + the person actually needed to read. + */ + stop(sessionID, t.t("loop.stopped.blocked")) + return + } + + /* The ledger grows before the stall check, so a rejection recorded this cycle informs the next one. */ + let ledgerChanged = false + for (const item of markers.rejected) { + if (!state.ledger.rejected.includes(item)) { + state.ledger.rejected.push(item) + ledgerChanged = true + } + } + if (markers.next) { + state.ledger.recentNext.push(markers.next) + ledgerChanged = true + } + /* Written after each cycle, not only at the end: the cycle that crashes is the one worth remembering. */ + if (ledgerChanged) saveLedger(api, sessionID, state.ledger) + + if (isStalled(state.ledger.recentNext)) { + /* + Repeating one plan is how a loop looks when it has run out of ideas. Stopping hands it back to a + person while the transcript still shows what it kept trying, which is the useful moment to look. + */ + stop(sessionID, t.t("loop.stopped.stalled", { step: state.ledger.recentNext.at(-1) ?? "" })) + return + } + + if (state.cycles >= state.maxCycles) { + stop(sessionID, t.t("loop.stopped.exhausted", { max: String(state.maxCycles) })) + return + } + + void send(state) + }) + + /* A session that goes away takes its loop with it, so nothing points at a dead conversation. */ + api.event.on("session.deleted", (event) => { + loops.delete(event.properties.info.id) + }) + + api.event.on("session.error", (event) => { + const sessionID = event.properties.sessionID + if (typeof sessionID !== "string") return + /* + An error ends the loop. Whatever failed will keep failing, and re-prompting turns one visible error + into a stream of them. + */ + if (loops.has(sessionID)) stop(sessionID, kvTranslator(api.kv).t("loop.stopped.error"), "error") + }) + + api.keymap.registerLayer({ + commands: [ + { + name: "loop.start", + get title() { + return kvTranslator(api.kv).t("loop.start.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + const sessionID = activeSessionID(api) + if (!sessionID) { + api.ui.toast({ + title: t.t("loop.needs_session.title"), + message: t.t("loop.needs_session.message"), + variant: "error", + }) + return + } + + const existing = loops.get(sessionID) + if (existing) { + api.ui.toast({ + title: t.t("loop.already.title"), + message: t.t("loop.status", { + cycle: String(existing.cycles), + max: String(existing.maxCycles), + }), + variant: "info", + }) + return + } + + /* + A ledger left by a previous run means the CLI exited mid-goal. Its goal is offered back as the + prefilled value so resuming is one keypress, while still being a deliberate choice -- silently + restarting a loop the user may have abandoned is not a favour. + */ + const stored = parseLedger(api.kv.get(ledgerKey(sessionID))) + + api.ui.dialog.replace(() => ( + { + const goal = value.trim() + api.ui.dialog.clear() + if (!goal) return + + /* + The rejected list is carried over only when the goal is UNCHANGED. A different goal makes + the old dead ends irrelevant at best and misleading at worst -- they were dead ends for a + different question. + */ + const resumed = stored && stored.goal === goal ? stored : undefined + const state: LoopState = { + sessionID, + ledger: { + goal, + rejected: resumed ? [...resumed.rejected] : [], + /* Plans are NOT carried over: a stall is about one run's circling, not a resumed run's. */ + recentNext: [], + }, + cycles: 0, + maxCycles: DEFAULT_MAX_CYCLES, + sending: false, + buffer: "", + } + loops.set(sessionID, state) + /* + The first cycle goes immediately rather than waiting for an idle event: the user just + asked, and a loop that appears to do nothing until some later event is indistinguishable + from one that failed to start. + */ + void send(state) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + { + name: "loop.stop", + get title() { + return kvTranslator(api.kv).t("loop.stop.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + const sessionID = activeSessionID(api) + if (!sessionID) return + if (!stop(sessionID, t.t("loop.stopped.user"))) { + api.ui.toast({ + title: t.t("loop.none.title"), + message: t.t("loop.none.message"), + variant: "info", + }) + } + }, + }, + ], + bindings: api.tuiConfig.keybinds.gather("loop.palette", ["loop.start", "loop.stop"]), + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/feature-plugins/session/variants.tsx b/packages/tui/src/feature-plugins/session/variants.tsx new file mode 100644 index 0000000000..131df55cdd --- /dev/null +++ b/packages/tui/src/feature-plugins/session/variants.tsx @@ -0,0 +1,377 @@ +import type { TuiPlugin, TuiPluginApi } from "@redrob-code/plugin/tui" + +import type { BuiltinTuiPlugin } from "../builtins" +import { kvTranslator } from "../../i18n" + +const id = "internal:variants" + +/** + * `paraphrase` and `compare`, and the one thing they must both do: say what was used and what it cost. + * + * Both go through this server's own `/api/variant/*` routes rather than reaching the console directly, so + * credential resolution and error mapping live in one place. The routes answer 404 when the Redrob provider + * is not connected, which is what gates these commands -- a user on a local runtime or another vendor is + * told the feature is unavailable instead of watching a request fail for reasons that look like their fault. + * + * WHY THE COST IS ALWAYS SHOWN. One of these requests makes SEVERAL charges -- one per model. A user who + * thinks they made one request will be surprised by the bill unless the surprise happens immediately, at the + * moment they can still decide not to do it again. So every result prints the model that actually answered, + * per slot, with its own cost and the total. + */ + +/** Two is the minimum that makes a comparison, and the console refuses fewer. */ +const COMPARE_MINIMUM = 2 + +export type SlotResult = { + slot: number + model: string + text?: string + error?: string + redrob?: { + routedModel?: string + upstreamProvider?: string + latencyMs?: number + costUsd?: number + } +} + +export type VariantResult = { + variants: SlotResult[] + totalCostUsd: number +} + +/** + * What a slot is called when shown to a person. + * + * `routedModel` wins over the requested id, because when the request said `auto` the requested id tells the + * reader nothing they did not already type. Both are shown when they differ, since "I asked for auto and got + * this" is the interesting fact. + */ +export function describeSlot(slot: SlotResult): string { + const routed = slot.redrob?.routedModel + if (routed && routed !== slot.model) return `${slot.model} → ${routed}` + return routed ?? slot.model +} + +/** Dollars at a fixed width, small enough not to round a fraction of a cent to nothing. */ +function money(value: number | undefined): string { + return typeof value === "number" ? `$${value.toFixed(4)}` : "—" +} + +/** + * The cost table. + * + * Per slot AND totalled, because the total alone hides that a slot failed for free while another was + * charged, and the slots alone make a reader do the addition the response already did. + */ +export function costReport(result: VariantResult): string { + const lines = result.variants.map((slot) => { + const latency = slot.redrob?.latencyMs + const timing = typeof latency === "number" ? `${Math.round(latency)}ms` : "—" + const status = slot.error ? `failed: ${slot.error}` : money(slot.redrob?.costUsd) + return ` ${describeSlot(slot).padEnd(34)} ${status.padStart(12)} ${timing.padStart(8)}` + }) + lines.push(` ${"total".padEnd(34)} ${money(result.totalCostUsd).padStart(12)}`) + return lines.join("\n") +} + +/** The served slots, in order. A failed slot is not an option a user can pick. */ +export function servedSlots(result: VariantResult): SlotResult[] { + return result.variants.filter((slot) => typeof slot.text === "string" && slot.text.length > 0) +} + +/** + * A one-line preview for the selection list. + * + * An answer is usually several lines and the list renders one, so the first non-empty line is shown and the + * rest is what the user gets when they choose. Truncated rather than wrapped, because a list row that grows + * pushes the other options off the screen -- which is the opposite of what a chooser needs. + */ +export function previewOf(text: string, width = 72): string { + const firstLine = text.split("\n").find((line) => line.trim().length > 0)?.trim() ?? "" + if (firstLine.length <= width) return firstLine + return `${firstLine.slice(0, width - 1)}…` +} + +function activeSessionID(api: TuiPluginApi): string | undefined { + const route = api.route.current + if (route.name !== "session") return undefined + const sessionID = route.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +/** + * The models to fan out to. + * + * Taken from what the catalogue says is connected rather than hard-coded, so a deployment that serves + * different ids does not need this file changed. `auto` is excluded: fanning out to the router twice would + * be two charges for what may well be the same model, which is the opposite of the point. + */ +function fanOutModels(api: TuiPluginApi, count: number): string[] { + const redrob = api.state.provider.find((provider) => provider.id === "redrob") + if (!redrob) return [] + return Object.keys(redrob.models ?? {}) + .filter((model) => model !== "auto") + .slice(0, count) +} + +const tui: TuiPlugin = async (api) => { + const connected = () => api.state.provider.some((provider) => provider.id === "redrob") + + const requireRedrob = (): boolean => { + if (connected()) return true + const t = kvTranslator(api.kv) + api.ui.toast({ + title: t.t("variants.unavailable.title"), + message: t.t("variants.unavailable.message"), + variant: "info", + }) + return false + } + + /** + * Cost first, then the content: the number is the part a user did not ask for and must not miss. + * + * A DIALOG rather than a toast, which is where this started. The toast is absolutely positioned at the + * top-right, capped at sixty columns, and dismisses itself on a timer -- pressing the command for real + * showed it clipping the cost total and the whole answer, keeping only the first few slot rows. The two + * things a user most needs were the two it dropped. A dialog is dismissed by the person reading it, so + * nothing disappears before it has been read. + */ + const report = (title: string, result: VariantResult, body?: string) => { + api.ui.dialog.replace(() => ( + api.ui.dialog.clear()} + /> + )) + } + + api.keymap.registerLayer({ + commands: [ + { + name: "variants.paraphrase", + get title() { + return kvTranslator(api.kv).t("variants.paraphrase.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + if (!requireRedrob()) return + + api.ui.dialog.replace(() => ( + { + const text = value.trim() + api.ui.dialog.clear() + if (!text) return + + const models = fanOutModels(api, 3) + if (models.length === 0) { + api.ui.toast({ + title: t.t("variants.no_models.title"), + message: t.t("variants.no_models.message"), + variant: "error", + }) + return + } + + void api.client.v2.variant + .paraphrase( + { + variantParaphraseRequest: { + text, + models: models.map((model) => ({ model })), + }, + }, + { throwOnError: true }, + ) + .then(({ data: result }) => { + const served = servedSlots(result) + if (served.length === 0) { + /* + Every slot failed. The cost report is still shown, because a failed slot can still + have been charged and a silent zero would be a claim rather than a fact. + */ + report(t.t("variants.none.title"), result) + return + } + /* + Paraphrase returns ONE value to the user: the last served slot, which is the end of the + chain the models were asked to refine. The others are not offered as a choice -- that is + what `compare` is for -- but every one of them is named in the cost table, so the user + can see what the single answer cost to produce. + */ + const chosen = served[served.length - 1] + report(t.t("variants.paraphrase.done"), result, chosen.text) + }) + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + { + name: "variants.compare", + get title() { + return kvTranslator(api.kv).t("variants.compare.title") + }, + category: "Session", + namespace: "palette", + run() { + const t = kvTranslator(api.kv) + if (!requireRedrob()) return + + const sessionID = activeSessionID(api) + if (!sessionID) { + api.ui.toast({ + title: t.t("variants.needs_session.title"), + message: t.t("variants.needs_session.message"), + variant: "error", + }) + return + } + + api.ui.dialog.replace(() => ( + { + const question = value.trim() + api.ui.dialog.clear() + if (!question) return + + const models = fanOutModels(api, COMPARE_MINIMUM) + if (models.length < COMPARE_MINIMUM) { + api.ui.toast({ + title: t.t("variants.no_models.title"), + message: t.t("variants.no_models.message"), + variant: "error", + }) + return + } + + void api.client.v2.variant + .compare( + { + variantCompareRequest: { + messages: [{ role: "user", content: question }], + models: models.map((model) => ({ model })), + }, + }, + { throwOnError: true }, + ) + .then(({ data: result }) => { + const served = servedSlots(result) + if (served.length === 0) { + report(t.t("variants.none.title"), result) + return + } + + /* + Cost BEFORE the choice, and the choice only once the cost has been acknowledged. + These are two dialogs on one stack, so opening the chooser directly would replace the + cost dialog the instant it appeared -- which is the same disappearing-evidence problem + the toast had. Confirming the cost is what opens the chooser. + */ + api.ui.dialog.replace(() => ( + { + /* + Deferred by a tick on purpose. `DialogAlert` calls this and then clears the + dialog stack itself, so a chooser pushed synchronously here is opened and then + immediately wiped by that clear -- which is what happened the first time this + was driven by hand: the cost dialog closed and nothing replaced it. Queuing the + push means it lands after the clear rather than before it. + */ + setTimeout(() => { + api.ui.dialog.replace(() => ( + + title={t.t("variants.compare.choose")} + options={served.map((slot, index) => ({ + title: `${describeSlot(slot)} · ${money(slot.redrob?.costUsd)}`, + value: index, + description: previewOf(slot.text ?? ""), + }))} + onSelect={(option) => { + api.ui.dialog.clear() + const chosen = served[option.value] + if (!chosen?.text) return + void api.client.v2.session + .prompt( + { + sessionID, + prompt: { + text: [ + question, + "", + `(answered by ${describeSlot(chosen)})`, + "", + chosen.text, + ].join("\n"), + }, + }, + { throwOnError: true }, + ) + /* + Reported rather than swallowed. Without this the first hand-run looked + like the pick had simply done nothing: the dialog closed, the answer + never arrived, and the reason was discarded with the rejected promise. + A user who has just been charged for several models must be told when + the thing they paid for failed to land. + */ + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.insert_failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + /> + )) + }, 0) + }} + /> + )) + }) + .catch((error: unknown) => { + api.ui.toast({ + title: t.t("variants.failed.title"), + message: error instanceof Error ? error.message : String(error), + variant: "error", + }) + }) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + }, + }, + ], + bindings: api.tuiConfig.keybinds.gather("variants.palette", [ + "variants.paraphrase", + "variants.compare", + ]), + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/i18n/en.ts b/packages/tui/src/i18n/en.ts index 7f3e2798e8..dc989d7d89 100644 --- a/packages/tui/src/i18n/en.ts +++ b/packages/tui/src/i18n/en.ts @@ -738,6 +738,39 @@ export const dict = { "mcp.status.failed": "failed", // Plugin manager + "loop.status": "Cycle {cycle} of at most {max}", + "loop.start.title": "Start autopilot", + "loop.stop.title": "Stop the loop", + "loop.stopped.title": "Loop stopped", + "loop.stopped.done": "The goal was reported met.", + "loop.stopped.user": "Stopped at your request.", + "loop.stopped.error": "The session failed, so the loop stopped rather than re-prompting a broken session.", + "loop.stopped.exhausted": "Reached the limit of {max} cycles without reporting it was finished.", + "loop.failed.title": "Loop could not continue", + "loop.needs_session.title": "No session", + "loop.needs_session.message": "Open a session first; a loop runs inside one.", + "loop.already.title": "A loop is already running here", + "loop.none.title": "No loop here", + "loop.none.message": "This session has no loop to stop.", + "loop.start.placeholder": "The goal — include how you will know it is met", + "loop.stopped.blocked": "It needs a person: a decision, a credential, or an access it does not have.", + "loop.stopped.stalled": "Stopped going in circles — it planned the same step three times: {step}", + "variants.paraphrase.title": "Rewrite with several models", + "variants.paraphrase.placeholder": "The text to rewrite", + "variants.paraphrase.done": "Rewritten", + "variants.compare.title": "Compare answers from several models", + "variants.compare.placeholder": "The question to ask each model", + "variants.compare.done": "Answers in", + "variants.compare.choose": "Pick an answer to keep", + "variants.unavailable.title": "Not available", + "variants.unavailable.message": "These need the Redrob provider. Connect it with `redrob providers login`.", + "variants.no_models.title": "Nothing to fan out to", + "variants.no_models.message": "The Redrob provider is serving no models this can use.", + "variants.needs_session.title": "No session", + "variants.needs_session.message": "Open a session first; the answer you pick goes into it.", + "variants.none.title": "Every model failed", + "variants.failed.title": "The request failed", + "variants.insert_failed.title": "The answer could not be added", "plugins.dialog.title": "Plugins", "plugins.install.title": "Install plugin", "plugins.install.placeholder": "npm package name", diff --git a/packages/tui/src/i18n/ko.ts b/packages/tui/src/i18n/ko.ts index e72e28e24c..211ab55600 100644 --- a/packages/tui/src/i18n/ko.ts +++ b/packages/tui/src/i18n/ko.ts @@ -707,6 +707,39 @@ export const dict: Record = { "mcp.status.disabled": "사용 안 함", "mcp.status.failed": "실패", + "loop.status": "{max}회 중 {cycle}회 진행", + "loop.start.title": "오토파일럿 시작", + "loop.stop.title": "반복 중지", + "loop.stopped.title": "반복 중지됨", + "loop.stopped.done": "목표가 충족됐다고 보고했습니다.", + "loop.stopped.user": "요청에 따라 중지했습니다.", + "loop.stopped.error": "세션이 실패해서, 망가진 세션에 다시 요청하지 않고 반복을 멈췄습니다.", + "loop.stopped.exhausted": "완료를 알리지 않은 채 {max}회 한도에 도달했습니다.", + "loop.failed.title": "반복을 이어갈 수 없습니다", + "loop.needs_session.title": "세션이 없습니다", + "loop.needs_session.message": "먼저 세션을 여세요. 반복은 세션 안에서 돕니다.", + "loop.already.title": "이미 이 세션에서 반복이 돌고 있습니다", + "loop.none.title": "반복이 없습니다", + "loop.none.message": "이 세션에는 중지할 반복이 없습니다.", + "loop.start.placeholder": "목표 — 무엇으로 충족을 판단할지 함께 적으세요", + "loop.stopped.blocked": "사람이 필요합니다: 결정, 자격증명, 또는 갖지 못한 접근 권한.", + "loop.stopped.stalled": "같은 단계를 세 번 계획해서 멈췄습니다: {step}", + "variants.paraphrase.title": "여러 모델로 다시 쓰기", + "variants.paraphrase.placeholder": "다시 쓸 텍스트", + "variants.paraphrase.done": "다시 썼습니다", + "variants.compare.title": "여러 모델의 답변 비교", + "variants.compare.placeholder": "각 모델에 물어볼 질문", + "variants.compare.done": "답변이 도착했습니다", + "variants.compare.choose": "남길 답변을 고르세요", + "variants.unavailable.title": "사용할 수 없습니다", + "variants.unavailable.message": "레드롭 프로바이더가 필요합니다. `redrob providers login` 으로 연결하세요.", + "variants.no_models.title": "호출할 모델이 없습니다", + "variants.no_models.message": "레드롭 프로바이더가 이 기능에 쓸 모델을 서비스하지 않습니다.", + "variants.needs_session.title": "세션이 없습니다", + "variants.needs_session.message": "먼저 세션을 여세요. 고른 답변이 그 세션에 들어갑니다.", + "variants.none.title": "모든 모델이 실패했습니다", + "variants.failed.title": "요청이 실패했습니다", + "variants.insert_failed.title": "답변을 넣지 못했습니다", "plugins.dialog.title": "플러그인", "plugins.install.title": "플러그인 설치", "plugins.install.placeholder": "npm 패키지 이름", diff --git a/packages/tui/test/loop.test.ts b/packages/tui/test/loop.test.ts new file mode 100644 index 0000000000..5664908559 --- /dev/null +++ b/packages/tui/test/loop.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import { LOOP_MARKERS, cyclePrompt, isStalled, parseLedger, readMarkers } from "../src/feature-plugins/session/loop" + +/* + What is worth testing here is the part that makes this an autopilot rather than a re-sender: the markers + are a contract between the prompt text and the parser, and the stall detector is the only thing that + notices a loop going in circles. Both fail SILENTLY when wrong -- a loop that never stops looks like a + loop that is working, right up until the cycle cap. +*/ + +describe("readMarkers", () => { + test("reads a completion claim out of surrounding prose", () => { + const reply = `I ran the suite and it is green.\n\n${LOOP_MARKERS.done} — verified by 317 passing tests.` + expect(readMarkers(reply).done).toBe(true) + }) + + test("reads a block, which must stop the loop rather than be retried", () => { + const reply = `I cannot reach the production database.\n${LOOP_MARKERS.blocked} I need MIGRATION_DATABASE_URL.` + const markers = readMarkers(reply) + expect(markers.blocked).toBe(true) + expect(markers.done).toBe(false) + }) + + test("takes the LAST plan when a reply revises itself", () => { + // A model that changes its mind mid-reply meant the revision, not the first thought. + const reply = [ + `${LOOP_MARKERS.next} add the failing test`, + "on reflection the test already exists", + `${LOOP_MARKERS.next} fix the Windows branch in _safe_chmod`, + ].join("\n") + expect(readMarkers(reply).next).toBe("fix the Windows branch in _safe_chmod") + }) + + test("collects every rejected approach, since they all have to reach the next cycle", () => { + const reply = [ + `${LOOP_MARKERS.rejected} patching the caller instead of the helper`, + `${LOOP_MARKERS.rejected} widening the type to silence the checker`, + `${LOOP_MARKERS.next} fix the helper`, + ].join("\n") + expect(readMarkers(reply).rejected).toEqual([ + "patching the caller instead of the helper", + "widening the type to silence the checker", + ]) + }) + + test("a reply with no markers yields nothing rather than guessing", () => { + const markers = readMarkers("I had a look around and things seem fine.") + expect(markers.done).toBe(false) + expect(markers.blocked).toBe(false) + expect(markers.next).toBeUndefined() + expect(markers.rejected).toEqual([]) + }) +}) + +describe("isStalled", () => { + test("three identical plans in a row is a loop with no exit", () => { + expect(isStalled(["run the tests", "run the tests", "run the tests"])).toBe(true) + }) + + test("two is patience, not a stall", () => { + // A model legitimately repeats a step while waiting on something external, like a build. + expect(isStalled(["run the tests", "run the tests"])).toBe(false) + }) + + test("ignores punctuation, case and spacing, which are not a change of plan", () => { + expect(isStalled(["Run the tests", "run the tests.", "RUN THE TESTS"])).toBe(true) + }) + + test("progress resets it", () => { + expect(isStalled(["run the tests", "run the tests", "fix the failing assertion"])).toBe(false) + }) + + test("blank plans are not treated as repetition", () => { + // Otherwise a model that simply stopped emitting the marker would read as stuck on an empty step. + expect(isStalled(["", "", ""])).toBe(false) + }) +}) + +describe("cyclePrompt", () => { + test("carries the goal and the cycle position", () => { + const prompt = cyclePrompt({ goal: "make CI green", rejected: [], recentNext: [] }, 3, 25) + expect(prompt).toContain("make CI green") + expect(prompt).toContain("cycle 3 of at most 25") + }) + + test("carries rejected approaches, which is the whole reason cycles differ", () => { + /* + Without this the loop is a re-sender: cycle N+1 gets the same input as cycle N and walks into the + same wall. This assertion is the one that would catch a regression back to that behaviour. + */ + const prompt = cyclePrompt( + { goal: "make CI green", rejected: ["bumping the timeout"], recentNext: [] }, + 2, + 25, + ) + expect(prompt).toContain("ALREADY TRIED AND REJECTED") + expect(prompt).toContain("bumping the timeout") + }) + + test("omits the rejected section entirely when there is nothing to say", () => { + const prompt = cyclePrompt({ goal: "make CI green", rejected: [], recentNext: [] }, 1, 25) + expect(prompt).not.toContain("ALREADY TRIED AND REJECTED") + }) + + test("asks for the markers it will later parse", () => { + // The prompt and the parser have to agree; this is what keeps them from drifting apart. + const prompt = cyclePrompt({ goal: "g", rejected: [], recentNext: [] }, 1, 5) + expect(prompt).toContain(LOOP_MARKERS.next) + expect(prompt).toContain(LOOP_MARKERS.done) + expect(prompt).toContain(LOOP_MARKERS.blocked) + expect(prompt).toContain(LOOP_MARKERS.rejected) + }) +}) + +describe("parseLedger", () => { + /* + Anything read back from disk is untrusted: an older build wrote a different shape, a file was + hand-edited, a write was truncated. The failure to avoid is feeding the model a half-read list of + things it must not retry -- it would then retry them, or avoid things it never tried. + */ + test("accepts a well-formed ledger", () => { + expect( + parseLedger({ goal: "make CI green", rejected: ["bump the timeout"], recentNext: ["run tests"] }), + ).toEqual({ goal: "make CI green", rejected: ["bump the timeout"], recentNext: ["run tests"] }) + }) + + test("refuses anything without a real goal, since the goal IS the loop", () => { + expect(parseLedger(undefined)).toBeUndefined() + expect(parseLedger(null)).toBeUndefined() + expect(parseLedger("make CI green")).toBeUndefined() + expect(parseLedger({ rejected: ["x"] })).toBeUndefined() + expect(parseLedger({ goal: "" })).toBeUndefined() + expect(parseLedger({ goal: " " })).toBeUndefined() + }) + + test("drops non-string entries instead of rejecting the whole ledger", () => { + // One bad entry should cost that entry, not the goal and every other dead end alongside it. + expect(parseLedger({ goal: "g", rejected: ["keep", 42, null, "also keep"] })).toEqual({ + goal: "g", + rejected: ["keep", "also keep"], + recentNext: [], + }) + }) + + test("tolerates missing lists rather than requiring them", () => { + expect(parseLedger({ goal: "g" })).toEqual({ goal: "g", rejected: [], recentNext: [] }) + }) + + test("a list that is not a list becomes empty, not a crash", () => { + expect(parseLedger({ goal: "g", rejected: "bump the timeout" })).toEqual({ + goal: "g", + rejected: [], + recentNext: [], + }) + }) +}) From 6af0fd3b93c69d293442eec6674e30988d2c3f71 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:33:46 +0000 Subject: [PATCH 3/4] test: give the variants routes an exerciser scenario The HttpApi exerciser gate fails on any route with no scenario, and it caught both of mine. Exercised through their NOT-CONNECTED path, which is the honest state in CI: there is no console credential, so the assertion is the 404 saying the Redrob provider is not connected. A scenario that needed a real key would either skip -- and this gate fails on skip, correctly, since a skipped route is an unexercised route -- or send a paid request to the live console on every run. 404 rather than 401 is the contract being pinned: nothing is configured to ask, so nothing refused us. --- .../test/server/httpapi-exercise/index.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/redrob/test/server/httpapi-exercise/index.ts b/packages/redrob/test/server/httpapi-exercise/index.ts index ed925b3572..551550e90c 100644 --- a/packages/redrob/test/server/httpapi-exercise/index.ts +++ b/packages/redrob/test/server/httpapi-exercise/index.ts @@ -1740,6 +1740,37 @@ const scenarios: Scenario[] = [ }, "status", ), + /* + The variants routes, exercised through their NOT-CONNECTED path. + + The exerciser has no console credential, which is the honest state to assert here: a 404 saying the + Redrob provider is not connected. A scenario that needed a real key would either skip -- and this gate + fails on skip, correctly, because a skipped route is an unexercised route -- or send a paid request to + the live console on every CI run. + + 404 rather than 401 is the contract being pinned: nothing is configured to ask, so nothing refused us, + and a user on a local runtime should be told the feature is unavailable instead of being sent to check a + credential they never set. + */ + http.protected + .post("/api/variant/paraphrase", "variant.paraphrase") + .at((ctx) => ({ + path: "/api/variant/paraphrase", + headers: ctx.headers(), + body: { text: "exercise", models: [{ model: "auto" }] }, + })) + .json(404, object, "status"), + http.protected + .post("/api/variant/compare", "variant.compare") + .at((ctx) => ({ + path: "/api/variant/compare", + headers: ctx.headers(), + body: { + messages: [{ role: "user", content: "exercise" }], + models: [{ model: "auto" }, { model: "auto" }], + }, + })) + .json(404, object, "status"), http.protected .post("/global/upgrade", "global.upgrade") .global() From e4a96937c189ae326eb8b7585bf13ce7fee06885 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:31:53 +0000 Subject: [PATCH 4/4] feat: turn the console's refusals into cards the user can act on A 429 and a 402 from the console arrived as prose. The 429 rendered as a message with a countdown and no way to see the ceiling it hit; the 402 rendered as a sentence in a red box, telling the user their budget was gone and leaving them to find the page that raises it. Split by what actually helps, because the two are not the same kind of problem: - 429 goes through `retryable()` and gets the existing retry card, linking to the console's limits page. Retrying IS the remedy here, and `Retry-After` already paces it. - 402 does NOT. The console answers 402 rather than 429 specifically so that clients stop -- its own comment says retrying an out-of-credit workspace "just turns one refusal into six" -- and a card that reads "Retrying in 4s" over something that will never succeed is worse than a plain message. These go through a new `blocking()` instead. So `SessionStatus` gains a `blocked` variant: the turn ended, no retry will fix it, but there is something to click. It is distinct from `retry`, which means a retry is IN FLIGHT and is drawn with a spinner and a countdown, and distinct from a plain session error, which travels as message text and therefore cannot carry a link at all -- which is why the user was told "budget exhausted" and left to go looking. The two 402s are told apart by `code`, and they need different pages: a key over its cap is fixed on the key, an empty balance by topping up. `code` is the only thing that distinguishes them on the OpenAI-compatible path, because the console's fuller refusal body does not survive that envelope (fixed console-side in the same change set). Everything is gated on the provider being `redrob`. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, so any vendor may send one, and offering a link to our console for somebody else's rate limit would send the user to a page that cannot help them. --- packages/redrob/src/session/processor.ts | 15 ++- packages/redrob/src/session/retry.ts | 124 +++++++++++++++++++- packages/redrob/test/session/retry.test.ts | 69 +++++++++++ packages/schema/src/session-status-event.ts | 26 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++ 5 files changed, 244 insertions(+), 2 deletions(-) diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index ab583f0685..7a43dfaf17 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -640,7 +640,20 @@ const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }) + /* + A refusal the user can act on ends as `blocked` rather than `idle`, so the client can offer the + page that fixes it instead of printing a sentence and leaving them to find it. `idle` otherwise, + which is every failure with nothing to click. + + Published BEFORE the error event would be wrong: a client that renders the error first and then + sees `idle` has already drawn the plain box. The order here -- error, then the terminal status -- + is the existing one, and `blocked` simply replaces `idle` in it. + */ + const block = SessionRetry.blocking(error, input.model.providerID) + yield* status.set( + ctx.sessionID, + block ? { type: "blocked", message: block.message, action: block.action } : { type: "idle" }, + ) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { diff --git a/packages/redrob/src/session/retry.ts b/packages/redrob/src/session/retry.ts index b687d2cc64..95da7b46a8 100644 --- a/packages/redrob/src/session/retry.ts +++ b/packages/redrob/src/session/retry.ts @@ -9,7 +9,22 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://code.redrob.ai/go" -export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) +/** + * Where a console refusal sends the user. `/limits` shows the tier and its per-minute ceiling; + * `/api-keys` is where a key's monthly cap is raised; `/billing` is where the account is topped up. + * Each card links to the page that fixes ITS refusal, because a card that lands on the wrong page is + * barely better than no card. + */ +export const CONSOLE_LIMITS_URL = "https://console.redrob.ai/limits" +export const CONSOLE_KEYS_URL = "https://console.redrob.ai/api-keys" +export const CONSOLE_BILLING_URL = "https://console.redrob.ai/billing" +export type RetryReason = + | "free_tier_limit" + | "account_rate_limit" + | "console_rate_limit" + | "console_key_budget" + | "console_out_of_credit" + | (string & {}) export type Retryable = { message: string @@ -82,6 +97,107 @@ function exponential(attempt: number, random: number) { return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) } +/** + * A console refusal the user can act on, as the card cowork already draws. + * + * Only for our own provider. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, + * so any vendor may send them -- offering a link to the Redrob console for somebody else's rate limit + * would send the user to a page that cannot fix their problem. + * + * Only the RATE refusal is here. A 402 is deliberately not retryable: the console chose that status over + * 429 precisely so clients would stop rather than turn one refusal into six, and a spinner reading + * "Retrying in 4s" over something that will never succeed is worse than a plain message. Those are + * handled by `blocking()` instead. + */ +function consoleRateLimit(error: SessionV1.APIError, provider: string): Retryable | undefined { + if (provider !== "redrob") return undefined + if (consoleErrorCode(error) !== "rate_limit_exceeded") return undefined + + /* + The console's own message already names the tier's per-minute ceiling and how long to wait, so it is + used as-is rather than paraphrased into something less specific. + */ + const message = error.data.message || "Rate limit reached" + return { + message, + action: { + reason: "console_rate_limit", + provider, + title: "Rate limit reached", + message: + "This is your workspace's requests-per-minute ceiling, which rises with your lifetime top-ups. It clears on its own; the console shows the current tier and limit.", + label: "open limits", + link: CONSOLE_LIMITS_URL, + }, + } +} + +/** The machine-readable code from the console's OpenAI-shaped error envelope, if there is one. */ +function consoleErrorCode(error: SessionV1.APIError): string | undefined { + const body = parseJSON(error.data.responseBody) + if (!isRecord(body)) return undefined + const envelope = body["error"] + if (!isRecord(envelope)) return undefined + const code = envelope["code"] + return typeof code === "string" && code.length > 0 ? code : undefined +} + +export type Blocking = { + message: string + action?: Retryable["action"] +} + +/** + * A console refusal that retrying cannot fix, but a person can. + * + * Both of the console's 402s land here. They are NOT routed through `retryable()` on purpose: the console + * answers 402 rather than 429 specifically so that clients stop, and a retry card would both retry and + * claim to be retrying something that will never succeed. + * + * The two are told apart by `code`, which is the only thing that distinguishes them on the + * OpenAI-compatible path -- the console's fuller refusal body, with `reason` and the figures, does not + * survive that envelope. They need different pages: a key over its cap is fixed by raising that key's + * cap, an empty balance by topping up, and sending the user to the wrong one wastes the card. + */ +export function blocking(error: Err, provider: string): Blocking | undefined { + if (provider !== "redrob") return undefined + if (!SessionV1.APIError.isInstance(error)) return undefined + const code = consoleErrorCode(error) + /* The console's own message carries the figures, so it is shown rather than paraphrased. */ + const message = error.data.message || "The request was refused" + + if (code === "api_key_budget_exhausted") { + return { + message, + action: { + reason: "console_key_budget", + provider, + title: "This key is over its monthly budget", + message: + "The cap is per API key and resets at the start of the month. Raise it on the key, or use a key without a cap.", + label: "open api keys", + link: CONSOLE_KEYS_URL, + }, + } + } + + if (code === "insufficient_quota") { + return { + message, + action: { + reason: "console_out_of_credit", + provider, + title: "The workspace is out of credit", + message: "Top up to continue. A balance covers every key on the workspace.", + label: "open billing", + link: CONSOLE_BILLING_URL, + }, + } + } + + return undefined +} + export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined @@ -96,6 +212,12 @@ export function retryable(error: Err, provider: string) { !matchesRetryableMessage(error.data.responseBody) ) return undefined + /* + Checked before the upstream markers below, because those match on a body substring while this + matches on the console's own error code -- the more specific signal should win. + */ + const rate = consoleRateLimit(error, provider) + if (rate) return rate if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, diff --git a/packages/redrob/test/session/retry.test.ts b/packages/redrob/test/session/retry.test.ts index 702d5956f2..01f7bfd1ac 100644 --- a/packages/redrob/test/session/retry.test.ts +++ b/packages/redrob/test/session/retry.test.ts @@ -519,3 +519,72 @@ describe("session.message-v2.fromError", () => { }) }) }) + +/** + * The console's refusals, and the line between the two kinds. + * + * A 429 is retryable and gets the retry card. A 402 is NOT: the console answers 402 rather than 429 + * specifically so clients stop, so it must never appear in `retryable()` -- a retry card would both retry + * and claim to be retrying something that will never succeed. + * + * Both are gated on the provider being ours. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's + * generic codes, so another vendor sending one must not be handed a link to our console. + */ +describe("session.retry console refusals", () => { + function consoleError(status: number, code: string, message = "refused"): SessionV1.APIError { + return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message, + isRetryable: status === 429, + statusCode: status, + responseBody: JSON.stringify({ error: { message, type: "x", param: null, code } }), + }).toObject(), + ) + } + + test("a rate refusal becomes a retry card pointing at the limits page", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded", "slow down"), "redrob") + expect(result?.message).toBe("slow down") + expect(result?.action?.reason).toBe("console_rate_limit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_LIMITS_URL) + }) + + test("another vendor's rate limit gets no console link", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded"), "openai") + expect(result?.action).toBeUndefined() + }) + + test("a budget refusal is NOT retryable", () => { + expect(SessionRetry.retryable(consoleError(402, "api_key_budget_exhausted"), "redrob")).toBeUndefined() + }) + + test("a budget refusal blocks, pointing at the keys page where the cap lives", () => { + const result = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted", "over cap"), "redrob") + expect(result?.message).toBe("over cap") + expect(result?.action?.reason).toBe("console_key_budget") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_KEYS_URL) + }) + + test("an empty balance blocks, pointing at billing instead", () => { + const result = SessionRetry.blocking(consoleError(402, "insufficient_quota", "no credit"), "redrob") + expect(result?.action?.reason).toBe("console_out_of_credit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_BILLING_URL) + }) + + test("the two 402s are told apart, which is the whole point of carrying the code", () => { + const budget = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted"), "redrob") + const balance = SessionRetry.blocking(consoleError(402, "insufficient_quota"), "redrob") + expect(budget?.action?.link).not.toBe(balance?.action?.link) + }) + + test("another vendor's 402 does not block with our pages", () => { + expect(SessionRetry.blocking(consoleError(402, "insufficient_quota"), "openai")).toBeUndefined() + }) + + test("a 402 with no code is left alone rather than guessed at", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ message: "refused", isRetryable: false, statusCode: 402 }).toObject(), + ) + expect(SessionRetry.blocking(error, "redrob")).toBeUndefined() + }) +}) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index f6a3022bcb..47cdec58e1 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -29,6 +29,32 @@ export const Info = Schema.Union([ Schema.Struct({ type: Schema.Literal("busy"), }), + /** + * The turn stopped and no retry will fix it, but there IS something the user can do. + * + * Distinct from `retry` because that variant means a retry is in flight: a client renders it with a + * spinner and a countdown, which would be a lie here. Distinct from a plain session error because + * those travel as message text and so can carry no link -- the user was told "budget exhausted" and + * left to find the page themselves. + * + * The console's 402s are the case this exists for. Both a key over its monthly cap and an account with + * an empty balance are refusals a person fixes in the console, and the console deliberately answers 402 + * rather than 429 so that clients STOP instead of turning one refusal into six. + */ + Schema.Struct({ + type: Schema.Literal("blocked"), + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + }), ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a609f30ae7..ac9249b06d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -693,6 +693,18 @@ export type SessionStatus = | { type: "busy" } + | { + type: "blocked" + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + } export type QuestionOption = { /**