Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/provider-muse/src/server/textGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,62 @@ it.layer(NodeServices.layer)("Muse text generation", (it) => {
}),
);

it.effect("explains a failed turn by Muse's error category", () =>
Effect.gen(function* () {
const message = "API error 429: raw provider text";
const cases = [
{
error: { kind: "modelError", quota: { limitKind: "tierLimit", resetsAtMs: 1 } },
detail: "Muse Code usage limit reached. Try again after it resets.",
},
{
// Credential proxies can refuse with a 429 quota message and no quota object.
error: { kind: "modelError", message: "upstream 429: quota exceeded for this key" },
detail: "Muse Code usage limit reached. Try again after it resets.",
},
{
error: { kind: "authRequired" },
detail: "Muse Code is not signed in. Run `muse login` on this T3 server host.",
},
{
error: { kind: "modelError" },
detail: "Muse Code's model request failed. Try again or choose another Muse model.",
},
{
error: { kind: "stepLimit" },
detail:
"Muse Code text generation failed. Check Muse login and availability on this T3 server host.",
},
{
reason: "upstream 429: quota exceeded for this key",
detail: "Muse Code usage limit reached. Try again after it resets.",
},
{
reason: "raw provider refusal",
detail:
"Muse Code text generation failed. Check Muse login and availability on this T3 server host.",
},
];
for (const { error, reason, detail } of cases) {
const raw = error === undefined ? reason : "message" in error ? error.message : message;
const test = fixture((emit) =>
emit("turn/completed", {
terminal: "failed",
...(error === undefined
? { reason }
: { error: { message, ...error, retryable: false } }),
}),
);
const service = yield* test.make;
const failure = yield* service.generateThreadTitle(titleInput).pipe(Effect.flip);
expect(failure).toMatchObject({ _tag: "TextGenerationError", detail });
expect(failure.detail).not.toContain(raw);
expect(failure.cause).toMatchObject({ cause: { message: raw } });
expect(test.host.close).toHaveBeenCalledOnce();
}
}),
);

it.effect("settles a completed turn even when its start acknowledgement never arrives", () =>
Effect.gen(function* () {
const test = fixture(finish('{"title":"Fix login form"}'), false);
Expand Down
41 changes: 37 additions & 4 deletions packages/provider-muse/src/server/textGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type MuseSdkHost,
} from "./sdk.ts";
import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts";
import { isMuseUsageLimitFailure } from "./usageLimits.ts";
import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
import {
generatedThreadTitle,
Expand Down Expand Up @@ -52,11 +53,17 @@ const ItemNotification = Schema.Struct({
truncated: Schema.optionalKey(Schema.Boolean),
}),
});
const TurnError = Schema.Struct({
kind: Schema.optionalKey(Schema.String),
message: Schema.String,
quota: Schema.optionalKey(Schema.Unknown),
});
const TurnCompleted = Schema.Struct({
sessionId: Schema.String,
turnId: Schema.String,
terminal: Schema.String,
error: Schema.optionalKey(Schema.Struct({ message: Schema.String })),
reason: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(TurnError),
});
const ApprovalRequested = Schema.Struct({
sessionId: Schema.String,
Expand All @@ -75,10 +82,32 @@ const decodeItem = Schema.decodeUnknownSync(ItemNotification);
const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted);
const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested);
const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested);
const isMuseSdkError = Schema.is(MuseSdkError);
const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));

type Operation = keyof ProviderTextGeneration;
class MuseTurnFailure extends Schema.TaggedError<MuseTurnFailure>()("MuseTurnFailure", {
turnError: TurnError,
}) {
override get message() {
return this.turnError.message;
}
}
const isMuseTurnFailure = Schema.is(MuseTurnFailure);

function museFailureDetail(cause: unknown): string {
const error =
isMuseSdkError(cause) && isMuseTurnFailure(cause.cause) ? cause.cause.turnError : undefined;
// Muse sends `quota` only for quota exhaustion, which it still labels `modelError`.
if (error?.quota !== undefined || isMuseUsageLimitFailure(error?.message))
return "Muse Code usage limit reached. Try again after it resets.";
if (error?.kind === "authRequired")
return "Muse Code is not signed in. Run `muse login` on this T3 server host.";
if (error?.kind === "modelError")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return "Muse Code's model request failed. Try again or choose another Muse model.";
return "Muse Code text generation failed. Check Muse login and availability on this T3 server host.";
}

async function generateMuseText(
host: MuseSdkHost,
Expand Down Expand Up @@ -144,8 +173,13 @@ async function generateMuseText(
resolveCompletion(answer?.text ?? "");
}
} else {
const error =
completed.error ??
(completed.reason !== undefined ? { message: completed.reason } : undefined);
rejectCompletion(
new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`),
error
? new MuseTurnFailure({ turnError: error })
: new Error(`Muse turn ${completed.terminal}.`),
);
}
} else if (
Expand Down Expand Up @@ -276,8 +310,7 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi
(cause) =>
new TextGenerationError({
operation,
detail:
"Muse Code text generation failed. Check Muse login and availability on this T3 server host.",
detail: museFailureDetail(cause),
cause,
}),
),
Expand Down
Loading