From 905e78e7b4fe101b1f329355e3356bbcabe62bda Mon Sep 17 00:00:00 2001 From: Jye Cusch Date: Fri, 9 Oct 2026 10:29:18 +1100 Subject: [PATCH 1/2] fix(core): name each provider request failure --- biome.json | 5 +- .../compaction/compaction.steps.ts | 2 +- .../floor/facilitator.execution.test.ts | 3 +- .../src/conversations/floor/facilitator.ts | 2 +- .../conversations/model-trials/trial.test.ts | 11 +- .../summaries/summary.steps.test.ts | 18 +- .../conversations/summaries/summary.steps.ts | 2 +- .../src/conversations/threads/threads.test.ts | 14 +- .../conversations/turns/turn.segment.test.ts | 33 +- .../src/conversations/turns/turn.steps.ts | 25 +- .../src/conversations/turns/turn.workflow.ts | 10 +- .../model-provider-repository.ts | 25 +- .../core/src/providers/models/answer.test.ts | 4 +- .../providers/models/models.recording.test.ts | 17 +- .../core/src/providers/models/models.test.ts | 197 +++++---- packages/core/src/providers/models/models.ts | 228 ++++------- .../src/providers/models/request-failures.ts | 374 ++++++++++++++++++ packages/core/src/providers/models/testing.ts | 13 +- tsconfig.base.json | 5 +- 19 files changed, 691 insertions(+), 297 deletions(-) create mode 100644 packages/core/src/providers/models/request-failures.ts diff --git a/biome.json b/biome.json index 3ec6a4d3..7ce9e54f 100644 --- a/biome.json +++ b/biome.json @@ -46,7 +46,10 @@ }, "overrides": [ { - "includes": ["packages/server/src/http/errors.ts"], + "includes": [ + "packages/core/src/providers/models/**/*.ts", + "packages/server/src/http/errors.ts" + ], "plugins": [ "./lint/errors-implement-domain-error.grit", "./lint/no-effect-promise.grit", diff --git a/packages/core/src/conversations/compaction/compaction.steps.ts b/packages/core/src/conversations/compaction/compaction.steps.ts index a91bfb2e..6a673301 100644 --- a/packages/core/src/conversations/compaction/compaction.steps.ts +++ b/packages/core/src/conversations/compaction/compaction.steps.ts @@ -87,4 +87,4 @@ const generate = ( /** * Why a compaction failed. */ -type CompactionFailure = Models.RequestFailed | Models.AnswerTimedOut | Models.UnusableAnswer; +type CompactionFailure = Models.RequestFailure | Models.AnswerTimedOut | Models.UnusableAnswer; diff --git a/packages/core/src/conversations/floor/facilitator.execution.test.ts b/packages/core/src/conversations/floor/facilitator.execution.test.ts index 465b88d6..38e349fa 100644 --- a/packages/core/src/conversations/floor/facilitator.execution.test.ts +++ b/packages/core/src/conversations/floor/facilitator.execution.test.ts @@ -1,3 +1,4 @@ +import { DisplayName } from "@sugabots/errors"; import { Effect, Layer } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { Database, transactional } from "../../database/database.ts"; @@ -47,7 +48,7 @@ const scope: FacilitatorScope = { }; const unavailable = Models.fromStream(() => - Effect.fail(new Models.RequestFailed({ message: "provider unavailable", reason: "unavailable" })), + Effect.fail(new Models.ModelNotOffered({ model: DisplayName.fromRecord("test-model") })), ); describe("an attempt at facilitation", () => { diff --git a/packages/core/src/conversations/floor/facilitator.ts b/packages/core/src/conversations/floor/facilitator.ts index e1ff1371..05c94d0f 100644 --- a/packages/core/src/conversations/floor/facilitator.ts +++ b/packages/core/src/conversations/floor/facilitator.ts @@ -160,7 +160,7 @@ const decide = ( model: Models.Interface, ): Effect.Effect< FacilitatorDecision, - Models.RequestFailed | Models.AnswerTimedOut | Models.UnusableAnswer, + Models.RequestFailure | Models.AnswerTimedOut | Models.UnusableAnswer, Database > => Effect.gen(function* () { diff --git a/packages/core/src/conversations/model-trials/trial.test.ts b/packages/core/src/conversations/model-trials/trial.test.ts index 15831747..55419e94 100644 --- a/packages/core/src/conversations/model-trials/trial.test.ts +++ b/packages/core/src/conversations/model-trials/trial.test.ts @@ -1,3 +1,4 @@ +import { DisplayName } from "@sugabots/errors"; import { Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; import { noDatabase } from "../../database/testing.ts"; @@ -8,13 +9,11 @@ import { ACCURACY_NEEDED, BUDGET_MS, explain, rateAccuracy, rateSpeed, runTrial /** A model that answers whatever it is told to, so the grading is what is under test. */ function scripted( - answer: (input: Models.StreamRequest) => string | Models.RequestFailed, + answer: (input: Models.StreamRequest) => string | Models.RequestFailure, ): Models.Interface { return Models.fromStream((input) => { const next = answer(input); - return next instanceof Models.RequestFailed - ? Effect.fail(next) - : Effect.succeed(streamed(chunks(next))); + return typeof next !== "string" ? Effect.fail(next) : Effect.succeed(streamed(chunks(next))); }); } @@ -73,9 +72,7 @@ describe("trying a model on the facilitator", () => { it("counts a model that cannot answer at all as a failure, not an error", async () => { const report = await trial( - scripted( - () => new Models.RequestFailed({ message: "provider unavailable", reason: "unavailable" }), - ), + scripted(() => new Models.ModelNotOffered({ model: DisplayName.fromRecord("test-model") })), "facilitate", ); diff --git a/packages/core/src/conversations/summaries/summary.steps.test.ts b/packages/core/src/conversations/summaries/summary.steps.test.ts index 45e7ffdc..ce93a2d2 100644 --- a/packages/core/src/conversations/summaries/summary.steps.test.ts +++ b/packages/core/src/conversations/summaries/summary.steps.test.ts @@ -1,3 +1,4 @@ +import { DisplayName } from "@sugabots/errors"; import { Effect, Layer, ManagedRuntime } from "effect"; import { describe, expect, it, vi } from "vitest"; import { effectRunner } from "../../database/database.ts"; @@ -62,7 +63,7 @@ describe("summarise", () => { await execution; expect(signal?.aborted).toBe(true); expect(stream).toHaveBeenCalledTimes(1); - expect(ended).toEqual([{ failed: "The model did not answer in time." }]); + expect(ended).toEqual([{ failed: "The model didn't answer in time. Try again." }]); expect(summaries.complete).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -112,7 +113,7 @@ describe("summarise", () => { // whole summary being retried over one bad answer. expect(stream).toHaveBeenCalledTimes(3); expect(summaries.complete).not.toHaveBeenCalled(); - expect(ended).toEqual([{ failed: "The model's answer could not be used." }]); + expect(ended).toEqual([{ failed: "The model answered in a form we couldn't use. Try again." }]); }); it("takes the answer as soon as one of the asks comes back usable", async () => { @@ -158,11 +159,12 @@ describe("summarise", () => { it("does not re-ask a provider that is down, and records the failure", async () => { const { summaries, turns, ended } = fakes(); - const stream = vi.fn(() => - Effect.fail( - new Models.RequestFailed({ message: "provider unavailable", reason: "unavailable" }), - ), - ); + const unreachable = new Models.ProviderUnreachable({ + provider: DisplayName.fromRecord("Test provider"), + model: DisplayName.fromRecord("test-model"), + cause: new Error("ECONNREFUSED"), + }); + const stream = vi.fn(() => Effect.fail(unreachable)); await runWithServices( summarise(request, Models.fromStream(stream)).pipe( @@ -173,7 +175,7 @@ describe("summarise", () => { // Asking again would cost the same and fail the same way. The thread's // next turn asks for a summary again. expect(stream).toHaveBeenCalledTimes(1); - expect(ended).toEqual([{ failed: "The model provider could not answer." }]); + expect(ended).toEqual([{ failed: unreachable.userMessage }]); expect(summaries.complete).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/conversations/summaries/summary.steps.ts b/packages/core/src/conversations/summaries/summary.steps.ts index 068ce645..c9c6b8a6 100644 --- a/packages/core/src/conversations/summaries/summary.steps.ts +++ b/packages/core/src/conversations/summaries/summary.steps.ts @@ -142,4 +142,4 @@ function stripFence(text: string): string { } /** Why a summary failed. */ -type SummaryFailure = Models.RequestFailed | Models.AnswerTimedOut | Models.UnusableAnswer; +type SummaryFailure = Models.RequestFailure | Models.AnswerTimedOut | Models.UnusableAnswer; diff --git a/packages/core/src/conversations/threads/threads.test.ts b/packages/core/src/conversations/threads/threads.test.ts index a6eb8bec..6fa4b652 100644 --- a/packages/core/src/conversations/threads/threads.test.ts +++ b/packages/core/src/conversations/threads/threads.test.ts @@ -1,4 +1,5 @@ import { handleFromName, threadChannel } from "@sugabots/contracts"; +import { DisplayName } from "@sugabots/errors"; import { and, eq } from "drizzle-orm"; import { Context, Effect } from "effect"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; @@ -861,9 +862,12 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () { content: "The work is complete.", collaborations: [], toolCalls: [] }, { contextCapacity: 128_000, readKeptFrom: null, answeredCollaboration: false }, ); - const providerDown = Models.fromStream(() => - Effect.fail(new Models.RequestFailed({ message: "provider down", reason: "unavailable" })), - ); + const unreachable = new Models.ProviderUnreachable({ + provider: DisplayName.fromRecord("Test provider"), + model: DisplayName.fromRecord("test-model"), + cause: new Error("ECONNREFUSED"), + }); + const providerDown = Models.fromStream(() => Effect.fail(unreachable)); await runOnPostgres( summarise( @@ -885,9 +889,7 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () and(eq(thread.parentThreadId, details.thread.id), eq(thread.systemAgentKey, "summarise")), ), ); - expect(scribeTurns).toEqual([ - { status: "failed", error: "The model provider could not answer." }, - ]); + expect(scribeTurns).toEqual([{ status: "failed", error: unreachable.userMessage }]); }); it("requests cancellation only once while an authorized turn remains active", async () => { diff --git a/packages/core/src/conversations/turns/turn.segment.test.ts b/packages/core/src/conversations/turns/turn.segment.test.ts index be732013..f0f43eec 100644 --- a/packages/core/src/conversations/turns/turn.segment.test.ts +++ b/packages/core/src/conversations/turns/turn.segment.test.ts @@ -1,4 +1,4 @@ -import { userText } from "@sugabots/errors"; +import { DisplayName, userText } from "@sugabots/errors"; import { tool } from "ai"; import { eq } from "drizzle-orm"; import { Context, Effect, Layer, Schema } from "effect"; @@ -42,7 +42,14 @@ describe.skipIf(!process.env.DATABASE_URL)("a turn's segment, against Postgres", const execution = onPostgres({ prepare: Context.get(conversations, TurnExecution.Service).prepare, }); - const providerDown = userText`The model provider could not answer.`; + const testProvider = { + provider: DisplayName.fromRecord("Test provider"), + model: DisplayName.fromRecord("test-model"), + }; + const providerDown = new Models.ProviderServerError({ + ...testProvider, + cause: new Error("provider down"), + }); let threadId: string; let connectionId: string; let hostId: string; @@ -143,16 +150,16 @@ describe.skipIf(!process.env.DATABASE_URL)("a turn's segment, against Postgres", }); it("records a failed run, which the workflow runs again", async () => { - const outcome = await segmentWith( - Models.fromStream(() => - Effect.fail(new Models.RequestFailed({ message: "provider down", reason: "unavailable" })), - ), - ); + const outcome = await segmentWith(Models.fromStream(() => Effect.fail(providerDown))); expect(outcome).toEqual({ _tag: "Retry" }); - expect(await storedTurn()).toMatchObject({ status: "failed", error: providerDown }); + expect(await storedTurn()).toMatchObject({ status: "failed", error: providerDown.userMessage }); expect(deliveredEvents()).toContainEqual( - expect.objectContaining({ type: "message.failed", willRetry: true, error: providerDown }), + expect.objectContaining({ + type: "message.failed", + willRetry: true, + error: providerDown.userMessage, + }), ); }); @@ -165,11 +172,7 @@ describe.skipIf(!process.env.DATABASE_URL)("a turn's segment, against Postgres", .where(eq(turn.id, prepared.turnId)), ); - const outcome = await segmentWith( - Models.fromStream(() => - Effect.fail(new Models.RequestFailed({ message: "provider down", reason: "unavailable" })), - ), - ); + const outcome = await segmentWith(Models.fromStream(() => Effect.fail(providerDown))); expect(outcome).toEqual({ _tag: "Finished" }); expect(await storedTurn()).toMatchObject({ status: "failed", runs: MAX_TURN_RUNS }); @@ -182,7 +185,7 @@ describe.skipIf(!process.env.DATABASE_URL)("a turn's segment, against Postgres", const outcome = await segmentWith( Models.fromStream(() => Effect.fail( - new Models.RequestFailed({ message: "Provider returned 402", reason: "outOfCredit" }), + new Models.ProviderQuotaExhausted({ ...testProvider, cause: new Error("402") }), ), ), ); diff --git a/packages/core/src/conversations/turns/turn.steps.ts b/packages/core/src/conversations/turns/turn.steps.ts index b1ef9df4..48cc6447 100644 --- a/packages/core/src/conversations/turns/turn.steps.ts +++ b/packages/core/src/conversations/turns/turn.steps.ts @@ -1,5 +1,5 @@ import { streamEvent, threadChannel } from "@sugabots/contracts"; -import { userText } from "@sugabots/errors"; +import { type DomainError, userText } from "@sugabots/errors"; import type { ToolApprovalConfiguration, ToolSet } from "ai"; import { Cause, @@ -146,7 +146,6 @@ export const runSegment = ( }); const finished: SegmentOutcome = { _tag: "Finished" }; -const retry: SegmentOutcome = { _tag: "Retry" }; const emptyReply: ReplyDraft = { content: "", collaborations: [], toolCalls: [] }; @@ -160,7 +159,7 @@ type StreamOutcome = /** Why a reply stopped streaming before the model finished, other than being cancelled. */ type TurnFailure = - | Models.RequestFailed + | Models.RequestFailure | ToolApprovalsIncomplete | TurnTimedOut | ApprovedToolChanged @@ -262,20 +261,26 @@ const generateReply = ( * Logs the failure and records what people are told of it; the turn * runs again only while that is safe and could help. A reply without * an answer does not: running it again would repeat every tool call it - * made. Nor does a request the provider refused, which it would refuse again. + * made. Nor does a model request that would fail the same way again. */ - const failed = (failure: TurnFailure) => - logTurnFailure(prepared, failure.message).pipe( + const failed = (failure: TurnFailure) => { + const request: DomainError | undefined = Models.isRequestFailure(failure) + ? failure + : undefined; + return logTurnFailure(prepared, failure.message).pipe( Effect.andThen( turns.fail(replyTurn, draft, { userMessage: failure.userMessage, mayRunAgain: - !(failure instanceof ReplyWithoutAnswer) && - !(failure instanceof Models.RequestFailed && !failure.mayRetry), + !(failure instanceof ReplyWithoutAnswer) && (request?.isRetryable ?? true), }), ), - Effect.map((willRetry) => (willRetry ? retry : finished)), + Effect.map( + (willRetry): SegmentOutcome => + willRetry ? { _tag: "Retry", after: request?.retryAfter } : finished, + ), ); + }; if (Exit.isSuccess(streamed)) { if (streamed.value.kind === "suspended") { @@ -357,7 +362,7 @@ const streamReply = ( reply: Ref.Ref, ): Effect.Effect< StreamOutcome, - | Models.RequestFailed + | Models.RequestFailure | ToolApprovalsIncomplete | TurnTimedOut | ApprovedToolChanged diff --git a/packages/core/src/conversations/turns/turn.workflow.ts b/packages/core/src/conversations/turns/turn.workflow.ts index e052b933..85fd022d 100644 --- a/packages/core/src/conversations/turns/turn.workflow.ts +++ b/packages/core/src/conversations/turns/turn.workflow.ts @@ -64,7 +64,10 @@ export const SegmentOutcome = Schema.Union([ /** The approvals the turn waits for, each decided through `approvalDecided`. */ approvals: Schema.Array(Schema.String), }), - Schema.TaggedStruct("Retry", {}), + Schema.TaggedStruct("Retry", { + /** How long the provider asked to be left, when it said; the turn waits at least `RETRY_DELAY`. */ + after: Schema.optional(Schema.DurationFromMillis), + }).pipe(Schema.encodeKeys({ after: "afterMillis" })), ]); export type SegmentOutcome = typeof SegmentOutcome.Type; @@ -115,7 +118,10 @@ export const turnWorkflow = Lanes.workflow(Turn, { const outcome = yield* turnActivities.activity("segment", request, run); if (outcome._tag === "Finished") return; if (outcome._tag === "Retry") { - yield* DurableClock.sleep({ name: `retry/${run}`, duration: RETRY_DELAY }); + yield* DurableClock.sleep({ + name: `retry/${run}`, + duration: Duration.max(RETRY_DELAY, outcome.after ?? Duration.zero), + }); continue; } const cancelled = yield* waitForApprovals(run, outcome.approvals); diff --git a/packages/core/src/providers/model-providers/model-provider-repository.ts b/packages/core/src/providers/model-providers/model-provider-repository.ts index 7ddf7ce4..06e4c57d 100644 --- a/packages/core/src/providers/model-providers/model-provider-repository.ts +++ b/packages/core/src/providers/model-providers/model-provider-repository.ts @@ -13,7 +13,7 @@ import { providerPreset, seededPresets, } from "@sugabots/contracts"; -import { type UserText, userText } from "@sugabots/errors"; +import { DisplayName, type UserText, userText } from "@sugabots/errors"; import { and, asc, eq, inArray, isNull, ne, notInArray, or, sql } from "drizzle-orm"; import { Context, Data, DateTime, Effect, Layer, Schema } from "effect"; import { Credentials } from "../../credentials/credentials.ts"; @@ -136,7 +136,7 @@ export interface Interface { readonly resolve: ( workspaceId: string, modelId: string, - ) => Effect.Effect; + ) => Effect.Effect; /** * Whether the workspace offers a model: it is switched on, at a provider that * is. The one rule for what an agent may run on. @@ -566,12 +566,22 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const [row] = yield* query((db) => db - .select({ providerId: modelProvider.id }) + .select({ + providerId: modelProvider.id, + providerName: modelProvider.name, + modelName: providerModel.displayName, + }) .from(providerModel) .innerJoin(modelProvider, eq(modelProvider.id, providerModel.providerId)) .where(and(offeredIn(workspaceId), eq(providerModel.modelId, modelId))), ); - return row ? yield* endpoint(workspaceId, row.providerId) : undefined; + const reached = row && (yield* endpoint(workspaceId, row.providerId)); + if (!reached) return undefined; + return { + endpoint: reached, + providerName: DisplayName.fromRecord(row.providerName), + modelName: DisplayName.fromRecord(row.modelName ?? modelId), + }; }), ), @@ -625,6 +635,13 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(Service, make); +/** The provider that offers a model, and the names people know both by. */ +export interface ResolvedModel { + endpoint: ProviderEndpoint; + providerName: DisplayName; + modelName: DisplayName; +} + /** How to reach a provider: where, in which protocol, and with which credentials. */ export interface ProviderEndpoint { providerId: string; diff --git a/packages/core/src/providers/models/answer.test.ts b/packages/core/src/providers/models/answer.test.ts index f9dd181f..d0c15b05 100644 --- a/packages/core/src/providers/models/answer.test.ts +++ b/packages/core/src/providers/models/answer.test.ts @@ -26,9 +26,7 @@ describe("asking a model again", () => { it("does not ask again for anything but the shape", async () => { // A timeout costs the same next time, and a dead provider is for the // caller to handle. - const ask = vi.fn(() => - Effect.fail(new Models.AnswerTimedOut({ message: "Thread summary timed out" })), - ); + const ask = vi.fn(() => Effect.fail(new Models.AnswerTimedOut({ purpose: "thread summary" }))); const exit = await Effect.runPromiseExit(Models.retryUnusable(Effect.suspend(ask))); diff --git a/packages/core/src/providers/models/models.recording.test.ts b/packages/core/src/providers/models/models.recording.test.ts index 4486a659..4e9353d4 100644 --- a/packages/core/src/providers/models/models.recording.test.ts +++ b/packages/core/src/providers/models/models.recording.test.ts @@ -6,6 +6,7 @@ import { effectRunner } from "../../database/database.ts"; import { noDatabase } from "../../database/testing.ts"; import type { ProviderIdentity } from "../model-providers/dialects/index.ts"; import { Models } from "./models.ts"; +import { resolvedModel } from "./testing.ts"; /** The fake provider never queries, so nothing here reaches the database. */ const run = effectRunner(ManagedRuntime.make(noDatabase)); @@ -76,13 +77,15 @@ function modelAnswering( modelProviders: { renewOAuthTokens: () => Effect.die(new Error("Not a ChatGPT provider")), resolve: () => - Effect.succeed({ - providerId: PROVIDER_ID, - ...provider, - apiKey: "secret", - headers: {}, - configurationUpdatedAt: new Date(0), - }), + Effect.succeed( + resolvedModel({ + providerId: PROVIDER_ID, + ...provider, + apiKey: "secret", + headers: {}, + configurationUpdatedAt: new Date(0), + }), + ), }, httpClients: { for: () => async (_url, init) => { diff --git a/packages/core/src/providers/models/models.test.ts b/packages/core/src/providers/models/models.test.ts index 814d2edc..13404147 100644 --- a/packages/core/src/providers/models/models.test.ts +++ b/packages/core/src/providers/models/models.test.ts @@ -1,8 +1,10 @@ -import { Effect, ManagedRuntime } from "effect"; +import { DisplayName } from "@sugabots/errors"; +import { Duration, Effect, ManagedRuntime } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { effectRunner } from "../../database/database.ts"; import { noDatabase } from "../../database/testing.ts"; import { emptyRegistry } from "../../providers/model-providers/dialects/index.ts"; +import { EgressRefused } from "../network/egress.ts"; const sdk = vi.hoisted(() => ({ createAnthropic: vi.fn(), @@ -34,6 +36,7 @@ vi.mock("ai", async (importOriginal) => ({ import { APICallError } from "ai"; import { Models } from "./models.ts"; +import { resolvedModel } from "./testing.ts"; /** The fake resolver never queries, so nothing here reaches the database. */ const run = effectRunner(ManagedRuntime.make(noDatabase)); @@ -56,15 +59,17 @@ describe("models", () => { modelProviders: { renewOAuthTokens: () => Effect.die(new Error("Not a ChatGPT provider")), resolve: () => - Effect.succeed({ - providerId: "provider-id", - preset: null, - baseUrl: "https://models.example/v1", - apiFormat, - apiKey: "secret", - headers: { "x-workspace": "workspace" }, - configurationUpdatedAt: new Date(), - }), + Effect.succeed( + resolvedModel({ + providerId: "provider-id", + preset: null, + baseUrl: "https://models.example/v1", + apiFormat, + apiKey: "secret", + headers: { "x-workspace": "workspace" }, + configurationUpdatedAt: new Date(), + }), + ), }, httpClients: { for: () => httpClient }, requests: { start: () => Effect.succeed("request-id"), finish: () => Effect.void }, @@ -100,15 +105,17 @@ describe("models", () => { modelProviders: { renewOAuthTokens: (_workspaceId, _providerId, renew) => renew(tokens), resolve: () => - Effect.succeed({ - providerId: "provider-id", - preset: "chatgpt", - baseUrl: "https://chatgpt.com/backend-api/codex", - apiFormat: "openai", - headers: {}, - oauthTokens: tokens, - configurationUpdatedAt: new Date(), - }), + Effect.succeed( + resolvedModel({ + providerId: "provider-id", + preset: "chatgpt", + baseUrl: "https://chatgpt.com/backend-api/codex", + apiFormat: "openai", + headers: {}, + oauthTokens: tokens, + configurationUpdatedAt: new Date(), + }), + ), }, httpClients: { for: () => vi.fn() }, requests: { start: () => Effect.succeed("request-id"), finish: () => Effect.void }, @@ -144,84 +151,130 @@ describe("models", () => { }); describe("a failed model request", () => { - it("logs what the provider said, and tells people only what it means", () => { - const refused = new APICallError({ - message: "Forbidden", - url: "https://openrouter.ai/api/v1/chat/completions", + const context = { + provider: DisplayName.fromRecord("OpenRouter"), + model: DisplayName.fromRecord("Gemini 3.8 Flash"), + }; + const answered = ( + statusCode: number, + body: unknown = {}, + responseHeaders?: Record, + ) => + new APICallError({ + message: "Failed", + url: "https://models.example/v1/chat/completions", requestBodyValues: {}, - statusCode: 403, - responseBody: JSON.stringify({ + statusCode, + responseBody: typeof body === "string" ? body : JSON.stringify(body), + responseHeaders, + }); + const unsent = (code: string) => new TypeError("fetch failed", { cause: { code } }); + + it("logs what the provider said, and tells people what it means in the names they know", () => { + const failure = Models.classifyRequestFailure( + answered(403, { error: { message: "This model requires you to complete the following before use: 18+ age confirmation.", }, }), - }); + context, + ); - const failure = Models.RequestFailed.fromCause(refused); + expect(failure).toMatchObject({ + _tag: "ProviderCredentialsRejected", + message: + "Provider returned 403: This model requires you to complete the following before use: 18+ age confirmation.", + userMessage: + "OpenRouter didn't accept the workspace's API key. A workspace admin can update it in Models.", + isRetryable: false, + }); + }); - expect(failure.message).toBe( - "Provider returned 403: This model requires you to complete the following before use: 18+ age confirmation.", - ); - expect(failure.userMessage).toBe("The model provider refused the request. Check its API key."); + it.each([ + ["a 402", answered(402, { error: { message: "Insufficient balance" } })], + ["an exhausted quota", answered(429, { error: { code: "insufficient_quota" } })], + ])("says plainly when a provider wants payment, for %s", (_, refused) => { + expect(Models.classifyRequestFailure(refused, context)).toMatchObject({ + _tag: "ProviderQuotaExhausted", + isRetryable: false, + }); }); it.each([ - ["a 402", 402, { error: { message: "Insufficient balance" } }], - [ - "an exhausted quota", - 429, - { error: { message: "Quota exceeded", code: "insufficient_quota" } }, - ], - ])("says plainly when a provider wants payment, for %s", (_, statusCode, body) => { - const refused = new APICallError({ - message: "Refused", - url: "https://models.example/v1/chat/completions", - requestBodyValues: {}, - statusCode, - responseBody: JSON.stringify(body), + [400, "ProviderRejectedRequest", false], + [404, "ProviderModelNotFound", false], + [408, "ProviderServerError", true], + [413, "ProviderContextLengthExceeded", false], + [429, "ProviderRateLimited", true], + [503, "ProviderServerError", true], + ])("names a %i as %s, which may be tried again: %s", (statusCode, tag, isRetryable) => { + expect(Models.classifyRequestFailure(answered(statusCode), context)).toMatchObject({ + _tag: tag, + isRetryable, }); + }); + + it("knows a prompt over the model's window from the provider's words", () => { + const tooLong = answered(400, { error: { message: "prompt is too long: 210000 tokens" } }); + + expect(Models.classifyRequestFailure(tooLong, context)._tag).toBe( + "ProviderContextLengthExceeded", + ); + }); + + it("waits as long as a rate-limited provider asks, and says how long", () => { + const failure = Models.classifyRequestFailure( + answered(429, {}, { "retry-after": "90" }), + context, + ); - expect(Models.RequestFailed.fromCause(refused)).toMatchObject({ - reason: "outOfCredit", + expect(failure).toMatchObject({ + _tag: "ProviderRateLimited", + retryAfter: Duration.seconds(90), userMessage: - "The model provider declined the request because of a billing issue, such as no credit left on the account or this bot's API key. A workspace admin can check with the provider.", + "OpenRouter is limiting how often this workspace can ask it. Try again in 2 minutes.", }); }); it.each([ - [400, false], - [404, false], - [408, true], - [429, true], - [503, true], - ])("tries a request the provider answered with %i again: %s", (statusCode, mayRetry) => { - const failed = new APICallError({ - message: "Failed", - url: "https://models.example/v1/chat/completions", - requestBodyValues: {}, - statusCode, - responseBody: "{}", + ["ECONNREFUSED", "ProviderUnreachable"], + ["ENOTFOUND", "ProviderUnreachable"], + ["UND_ERR_CONNECT_TIMEOUT", "ProviderUnreachable"], + ["ECONNRESET", "ProviderConnectionLost"], + ["UND_ERR_SOCKET", "ProviderConnectionLost"], + ])("tells a connection never made from one that dropped: %s", (code, tag) => { + expect(Models.classifyRequestFailure(unsent(code), context)).toMatchObject({ + _tag: tag, + isRetryable: true, }); - - expect(Models.RequestFailed.fromCause(failed).mayRetry).toBe(mayRetry); }); - it("logs the SDK's message when the body says nothing readable", () => { - const opaque = new APICallError({ - message: "Bad Gateway", - url: "https://models.example/v1/chat/completions", + it("explains an address the network policy refuses, and that it won't pass by retrying", () => { + const refused = new APICallError({ + message: "Cannot connect to API", + url: "http://192.168.1.20:11434/v1/chat/completions", requestBodyValues: {}, - statusCode: 502, - responseBody: "upstream timed out", + cause: new EgressRefused({ reason: "privateNetwork" }), }); - expect(Models.RequestFailed.fromCause(opaque)).toMatchObject({ + const failure = Models.classifyRequestFailure(refused, context); + + expect(failure).toMatchObject({ _tag: "ProviderAddressRefused", isRetryable: false }); + expect(failure.userMessage).toContain("local or private network"); + }); + + it("keeps an unreadable response for the logs and tells people only that it failed", () => { + const opaque = answered(502, "upstream timed out"); + + expect(Models.classifyRequestFailure(opaque, context)).toMatchObject({ message: "Provider returned 502: upstream timed out", - userMessage: "The model provider could not answer.", + userMessage: + "We're having trouble getting an answer from OpenRouter. Try again in a few minutes. If it keeps happening, choose another model.", + }); + expect(Models.classifyRequestFailure(new Error("socket hang up"), context)).toMatchObject({ + _tag: "ProviderRequestFailed", + message: "socket hang up", }); - expect(Models.RequestFailed.fromCause(new Error("socket hang up")).message).toBe( - "socket hang up", - ); }); }); diff --git a/packages/core/src/providers/models/models.ts b/packages/core/src/providers/models/models.ts index eb5b8c2c..d6b09adb 100644 --- a/packages/core/src/providers/models/models.ts +++ b/packages/core/src/providers/models/models.ts @@ -10,7 +10,7 @@ export * as Models from "./models.ts"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { type UserText, userText } from "@sugabots/errors"; +import { DisplayName, type DomainError, userText } from "@sugabots/errors"; import { APICallError, type AssistantModelMessage, @@ -23,16 +23,35 @@ import { type ToolResultPart, type ToolSet, } from "ai"; -import { Context, Data, Duration, Effect, Layer, Ref, Schedule, type Scope } from "effect"; +import { + Context, + Duration, + Effect, + ErrorReporter, + Layer, + Ref, + Schedule, + Schema, + type Scope, +} from "effect"; import { ModelRequests } from "../../accounting/model-requests.ts"; import { streamLedger } from "../../accounting/stream-ledger.ts"; import type { Database } from "../../database/database.ts"; -import type { UserFacing } from "../../user-message.ts"; import type { ProviderOptions } from "../model-providers/dialects/dialect.ts"; import { dialectFor, type ModelRegistry, modelsDev } from "../model-providers/dialects/index.ts"; import { ModelProviderRepository } from "../model-providers/model-provider-repository.ts"; import { withSignInAccess } from "../model-providers/sign-in/sign-in.ts"; import { Egress, type EgressHttpClients } from "../network/egress.ts"; +import { + classifyRequestFailure, + isRequestFailure, + ModelNotOffered, + ProviderOAuthRefreshFailed, + type RequestContext, + type RequestFailure, +} from "./request-failures.ts"; + +export * from "./request-failures.ts"; /** What asking a model once is about: which model, for which workspace, and what it is told. */ export interface Prompt { @@ -87,7 +106,7 @@ export interface Streamed { * so nothing exists until the caller asks, which is what keeps an aborted * response from leaving a rejection nobody handles. */ - finished: Effect.Effect; + finished: Effect.Effect; } /** @@ -120,11 +139,11 @@ export interface Interface { * closing its scope aborts the request, along with any tool call still * running inside it. */ - stream(request: StreamRequest): Effect.Effect; + stream(request: StreamRequest): Effect.Effect; /** Asks for one whole answer, in a single model call. */ answer( request: AnswerRequest, - ): Effect.Effect; + ): Effect.Effect; } /** @@ -203,93 +222,20 @@ function definedFields(record: Record, names: readonly string[] } /** - * loggingFailure passes `text` through, calling `log` with whatever ends it - * early, since a reader of the text alone can't say what else went wrong. + * failingWith passes `text` through, and throws `fail(cause)` for whatever + * ends it early, so a reader of the text sees only a classified failure. */ -async function* loggingFailure(text: AsyncIterable, log: (cause: unknown) => void) { +async function* failingWith( + text: AsyncIterable, + fail: (cause: unknown) => RequestFailure, +): AsyncGenerator { try { yield* text; } catch (cause) { - log(cause); - throw cause; - } -} - -/** The model could not be asked, or its provider failed the request. */ -export class RequestFailed - extends Data.TaggedError("ModelRequestFailed")<{ - /** Why, for the logs. It may quote the provider's own response. */ - readonly message: string; - readonly reason: RequestFailure; - readonly cause?: unknown; - }> - implements UserFacing -{ - /** - * A request that failed with `cause`. A refused request carries its reason - * in the response body, usually as `{"error":{"message":...}}`; that - * sentence is the one worth logging, ahead of the SDK's own summary. - */ - static fromCause(cause: unknown): RequestFailed { - if (!APICallError.isInstance(cause)) { - const message = cause instanceof Error ? cause.message : String(cause); - return new RequestFailed({ message, reason: "unavailable", cause }); - } - const status = cause.statusCode; - const said = providerSaid(cause.responseBody) ?? cause.message; - return new RequestFailed({ - message: `${status ? `Provider returned ${status}` : "Provider refused"}: ${said}`, - reason: reasonFor(status, cause.responseBody), - cause, - }); - } - - /** - * Whether the same request may work if sent again: the provider was busy, - * timed out or failed. A refusal, such as a rejected key, no credit or a - * request it couldn't accept, would only be refused again. - */ - get mayRetry(): boolean { - return this.reason === "rateLimited" || this.reason === "unavailable"; - } - - get userMessage() { - return REQUEST_USER_MESSAGES[this.reason]; + throw fail(cause); } } -/** reasonFor names why a provider answered a request with `status` and `body`. */ -function reasonFor(status: number | undefined, body: string | undefined): RequestFailure { - if (status === 401 || status === 403) return "rejected"; - // OpenAI, and the servers that copy its errors, report an exhausted quota as a 429. - if (status === 402 || (status === 429 && errorCode(body) === "insufficient_quota")) { - return "outOfCredit"; - } - if (status === 429) return "rateLimited"; - // A timed-out request may go through next time; any other 4xx is a problem with the request itself. - if (status !== undefined && status >= 400 && status < 500 && status !== 408) return "refused"; - return "unavailable"; -} - -type RequestFailure = - | "noProvider" - | "signInFailed" - | "rejected" - | "outOfCredit" - | "refused" - | "rateLimited" - | "unavailable"; - -const REQUEST_USER_MESSAGES: Record = { - noProvider: userText`No active provider offers this model.`, - signInFailed: userText`The model provider's sign-in failed. Sign in again.`, - rejected: userText`The model provider refused the request. Check its API key.`, - outOfCredit: userText`The model provider declined the request because of a billing issue, such as no credit left on the account or this bot's API key. A workspace admin can check with the provider.`, - refused: userText`The model provider couldn't accept this request.`, - rateLimited: userText`The model provider is busy. Try again shortly.`, - unavailable: userText`The model provider could not answer.`, -}; - interface Options { modelProviders: Pick; httpClients: EgressHttpClients; @@ -305,26 +251,18 @@ export function make({ modelProviders, httpClients, requests, registry }: Option const signal = yield* Effect.abortSignal; const resolved = yield* modelProviders.resolve(input.workspaceId, input.model); if (!resolved) { - return yield* new RequestFailed({ - message: `No active provider offers the model "${input.model}"`, - reason: "noProvider", - }); + return yield* new ModelNotOffered({ model: DisplayName.fromRecord(input.model) }); } + const context: RequestContext = { + provider: resolved.providerName, + model: resolved.modelName, + }; const connection = yield* withSignInAccess( modelProviders, httpClients, input.workspaceId, - resolved, - ).pipe( - Effect.mapError( - (failure) => - new RequestFailed({ - message: failure.message, - reason: "signInFailed", - cause: failure, - }), - ), - ); + resolved.endpoint, + ).pipe(Effect.mapError((cause) => new ProviderOAuthRefreshFailed({ ...context, cause }))); const fetch = httpClients.for(connection); const ledger = yield* streamLedger({ requests, @@ -409,10 +347,14 @@ export function make({ modelProviders, httpClients, requests, registry }: Option stopWhen: stepCountIs(input.maxSteps), maxRetries: 0, }); + /** Logs what ended the request, and names it for whoever reads the response. */ + const classified = (cause: unknown) => { + const failure = providerFailure ?? cause; + runLog(logModelFailure(failure, streamErrors, signal)); + return classifyRequestFailure(failure, context); + }; return { - text: loggingFailure(result.textStream, (cause) => - runLog(logModelFailure(cause, streamErrors, signal)), - ), + text: failingWith(result.textStream, classified), finished: Effect.tryPromise({ try: async () => { const [steps, responseMessages] = await Promise.all([ @@ -427,11 +369,8 @@ export function make({ modelProviders, httpClients, requests, registry }: Option responseMessages, }; }, - catch: (cause) => providerFailure ?? cause, - }).pipe( - Effect.tapError((failure) => logModelFailure(failure, streamErrors, signal)), - Effect.mapError(RequestFailed.fromCause), - ), + catch: classified, + }), }; }), ); @@ -467,10 +406,7 @@ export function fromStream(stream: Interface["stream"]): Interface { ).pipe( Effect.timeoutOrElse({ duration: request.timeout, - orElse: () => - Effect.fail( - new AnswerTimedOut({ message: `The ${request.activity.purpose} answer timed out` }), - ), + orElse: () => Effect.fail(new AnswerTimedOut({ purpose: request.activity.purpose })), }), ), }; @@ -591,34 +527,6 @@ export const layer = Layer.effect( }), ).pipe(Layer.provide(ModelProviderRepository.layer)); -/** errorCode returns the `code`, else the `type`, of the error in an OpenAI-style error `body`. */ -function errorCode(body: string | undefined): string | undefined { - if (!body) return undefined; - try { - const { error } = JSON.parse(body) as { error?: { code?: unknown; type?: unknown } }; - const code = error?.code ?? error?.type; - return typeof code === "string" ? code : undefined; - } catch { - return undefined; - } -} - -function providerSaid(body: string | undefined): string | undefined { - if (!body) return undefined; - try { - const parsed: unknown = JSON.parse(body); - if (typeof parsed !== "object" || parsed === null) return undefined; - const error = (parsed as { error?: unknown }).error; - const message = - typeof error === "object" && error !== null - ? (error as { message?: unknown }).message - : (parsed as { message?: unknown }).message; - return typeof message === "string" && message ? message : undefined; - } catch { - return body.length <= 300 ? body : undefined; - } -} - /** * Reads the model's text one delta at a time and runs `onDelta` for each. * @@ -629,15 +537,16 @@ function providerSaid(body: string | undefined): string | undefined { export const forEachDelta = ( text: AsyncIterable, onDelta: (delta: string) => Effect.Effect, -): Effect.Effect => { +): Effect.Effect => { const iterator = text[Symbol.asyncIterator](); - const next = Effect.callback, RequestFailed>((resume) => { + // `make` ends its text with a classified failure, so anything else is a bug. + const next = Effect.callback, RequestFailure>((resume) => { iterator.next().then( (result) => resume(Effect.succeed(result)), - (cause) => resume(Effect.fail(RequestFailed.fromCause(cause))), + (cause: unknown) => resume(isRequestFailure(cause) ? Effect.fail(cause) : Effect.die(cause)), ); }); - const loop: Effect.Effect = Effect.flatMap(next, (result) => + const loop: Effect.Effect = Effect.flatMap(next, (result) => result.done ? Effect.void : Effect.andThen(onDelta(result.value), loop), ); return loop; @@ -645,30 +554,37 @@ export const forEachDelta = ( /** The model answered, but not in a shape we can use. */ export class UnusableAnswer - extends Data.TaggedError("UnusableAnswer")<{ + extends Schema.TaggedError()("UnusableAnswer", { /** What was wrong with the answer. */ - readonly reason: string; - }> - implements UserFacing + reason: Schema.String, + }) + implements DomainError { + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; override get message() { return this.reason; } get userMessage() { - return userText`The model's answer could not be used.`; + return userText`The model answered in a form we couldn't use. Try again.`; } } /** The model did not finish answering within the time allowed. */ export class AnswerTimedOut - extends Data.TaggedError("AnswerTimedOut")<{ - /** Which answer, and the limit it ran past. */ - readonly message: string; - }> - implements UserFacing + extends Schema.TaggedError()("AnswerTimedOut", { + /** What the answer was for, such as a summary. */ + purpose: Schema.String, + }) + implements DomainError { + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return `The ${this.purpose} answer timed out`; + } get userMessage() { - return userText`The model did not answer in time.`; + return userText`The model didn't answer in time. Try again.`; } } diff --git a/packages/core/src/providers/models/request-failures.ts b/packages/core/src/providers/models/request-failures.ts new file mode 100644 index 00000000..f4ccd797 --- /dev/null +++ b/packages/core/src/providers/models/request-failures.ts @@ -0,0 +1,374 @@ +import { DisplayName, type DomainError, userText } from "@sugabots/errors"; +import { APICallError } from "ai"; +import { Duration, ErrorReporter, Schema } from "effect"; +import { EgressRefused } from "../network/egress.ts"; + +/** Who a model request went to, as people see them in the Models settings. */ +export interface RequestContext { + readonly provider: DisplayName; + readonly model: DisplayName; +} + +const context = { provider: DisplayName.schema, model: DisplayName.schema }; +const wrapped = { ...context, cause: Schema.Defect() }; + +/** No provider in the workspace offers the model, or the one that does is switched off. */ +export class ModelNotOffered + extends Schema.TaggedError()("ModelNotOffered", { model: DisplayName.schema }) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Info" as const; + override get message() { + return `No active provider offers the model "${this.model}"`; + } + get userMessage() { + return userText`${this.model} isn't available: it, or the provider that offers it, has been switched off or removed in Models. A workspace admin can switch it back on, or you can choose another model.`; + } +} + +/** The provider's sign-in couldn't be renewed. */ +export class ProviderOAuthRefreshFailed + extends Schema.TaggedError()("ProviderOAuthRefreshFailed", wrapped) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return `Renewing the sign-in to ${this.provider} failed: ${describe(this.cause)}`; + } + get userMessage() { + return userText`We couldn't renew the workspace's sign-in to ${this.provider}. A workspace admin can sign in again in Models.`; + } +} + +/** The network policy refused the provider's address before anything was sent. */ +export class ProviderAddressRefused + extends Schema.TaggedError()("ProviderAddressRefused", { + ...context, + cause: Schema.instanceOf(EgressRefused), + }) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return `The network policy refused ${this.provider}'s address: ${this.cause.reason}`; + } + get userMessage() { + return userText`We can't send requests to ${this.provider}'s address. ${this.cause.userMessage} A workspace admin can change the address in Models.`; + } +} + +/** No connection could be made: refused, no such host, or no answer in time. */ +export class ProviderUnreachable + extends Schema.TaggedError()("ProviderUnreachable", wrapped) + implements DomainError +{ + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return `${this.provider} could not be reached: ${describe(this.cause)}. Check the server is running and reachable from this one.`; + } + get userMessage() { + return userText`We can't reach ${this.provider}, which provides ${this.model}. If it runs on your own computer, check it's on. Otherwise, try again in a few minutes.`; + } +} + +/** The connection dropped after it was made, partway through the response. */ +export class ProviderConnectionLost + extends Schema.TaggedError()("ProviderConnectionLost", wrapped) + implements DomainError +{ + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return `The connection to ${this.provider} dropped: ${describe(this.cause)}`; + } + get userMessage() { + return userText`We lost the connection to ${this.provider} partway through. Try again.`; + } +} + +/** The provider answered 401 or 403. */ +export class ProviderCredentialsRejected + extends Schema.TaggedError()("ProviderCredentialsRejected", wrapped) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`${this.provider} didn't accept the workspace's API key. A workspace admin can update it in Models.`; + } +} + +/** The provider wants payment: a 402, or a 429 for an exhausted quota. */ +export class ProviderQuotaExhausted + extends Schema.TaggedError()("ProviderQuotaExhausted", wrapped) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`${this.provider} turned the request down because of a billing problem, such as no credit left on the account or a spending limit on the API key. A workspace admin can check the account with ${this.provider}, or you can choose another model.`; + } +} + +/** The provider answered 429 for asking too often. */ +export class ProviderRateLimited + extends Schema.TaggedError()("ProviderRateLimited", { + ...wrapped, + retryAfter: Schema.optional(Schema.Duration), + }) + implements DomainError +{ + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + const minutes = this.retryAfter && Math.ceil(Duration.toMinutes(this.retryAfter)); + if (!minutes) { + return userText`${this.provider} is limiting how often this workspace can ask it. Try again shortly.`; + } + return userText`${this.provider} is limiting how often this workspace can ask it. Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`; + } +} + +/** The provider doesn't recognise the model: a 404. */ +export class ProviderModelNotFound + extends Schema.TaggedError()("ProviderModelNotFound", wrapped) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`${this.provider} no longer offers ${this.model}. Choose another model.`; + } +} + +/** The prompt is longer than the model can read. */ +export class ProviderContextLengthExceeded + extends Schema.TaggedError()( + "ProviderContextLengthExceeded", + wrapped, + ) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`The conversation is too long for ${this.model} to read. Start a new conversation, or choose a model that reads more.`; + } +} + +/** The provider refused the request itself: any other 4xx. */ +export class ProviderRejectedRequest + extends Schema.TaggedError()("ProviderRejectedRequest", wrapped) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`${this.provider} didn't accept the request for ${this.model}. This is a problem on our end, and we've logged it. If it keeps happening, choose another model.`; + } +} + +/** The provider failed or timed out on its side: a 5xx or a 408. */ +export class ProviderServerError + extends Schema.TaggedError()("ProviderServerError", wrapped) + implements DomainError +{ + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`We're having trouble getting an answer from ${this.provider}. Try again in a few minutes. If it keeps happening, choose another model.`; + } +} + +/** A failure none of the others describe, such as a response the SDK couldn't read. */ +export class ProviderRequestFailed + extends Schema.TaggedError()("ProviderRequestFailed", wrapped) + implements DomainError +{ + readonly isRetryable = true; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`We couldn't get an answer from ${this.provider}. Try again, and if it keeps happening, choose another model.`; + } +} + +/** Why asking a model failed. */ +export const RequestFailure = Schema.Union([ + ModelNotOffered, + ProviderOAuthRefreshFailed, + ProviderAddressRefused, + ProviderUnreachable, + ProviderConnectionLost, + ProviderCredentialsRejected, + ProviderQuotaExhausted, + ProviderRateLimited, + ProviderModelNotFound, + ProviderContextLengthExceeded, + ProviderRejectedRequest, + ProviderServerError, + ProviderRequestFailed, +]); +export type RequestFailure = typeof RequestFailure.Type; + +/** Whether `failure` is one asking a model can end with. */ +export const isRequestFailure = Schema.is(RequestFailure); + +/** Socket error codes for a connection that was never made. */ +const NOT_CONNECTED = new Set([ + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + "UND_ERR_CONNECT_TIMEOUT", +]); + +/** Socket error codes for a connection that dropped once made. */ +const CONNECTION_DROPPED = new Set([ + "ECONNRESET", + "EPIPE", + "ETIMEDOUT", + "UND_ERR_SOCKET", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", +]); + +/** The failure a request to `context.provider` ended with, from what the SDK or the network threw. */ +export function classifyRequestFailure(cause: unknown, context: RequestContext): RequestFailure { + const fields = { ...context, cause }; + if (APICallError.isInstance(cause) && cause.statusCode !== undefined) { + return classifyStatus(cause.statusCode, cause, fields); + } + const refused = findInChain(cause, (link) => link instanceof EgressRefused); + if (refused instanceof EgressRefused) { + return refused.reason === "unresolved" + ? new ProviderUnreachable(fields) + : new ProviderAddressRefused({ ...context, cause: refused }); + } + const code = findErrorCode(cause); + if (code && NOT_CONNECTED.has(code)) return new ProviderUnreachable(fields); + if (code && CONNECTION_DROPPED.has(code)) return new ProviderConnectionLost(fields); + return new ProviderRequestFailed(fields); +} + +function classifyStatus( + status: number, + cause: APICallError, + fields: RequestContext & { cause: unknown }, +): RequestFailure { + const code = errorCode(cause.responseBody); + if (status === 401 || status === 403) return new ProviderCredentialsRejected(fields); + // OpenAI, and the servers that copy its errors, report an exhausted quota as a 429. + if (status === 402 || (status === 429 && code === "insufficient_quota")) { + return new ProviderQuotaExhausted(fields); + } + if (status === 429) { + return new ProviderRateLimited({ ...fields, retryAfter: retryAfter(cause.responseHeaders) }); + } + if (status === 404) return new ProviderModelNotFound(fields); + if (status === 413 || code === "context_length_exceeded" || saysPromptTooLong(cause)) { + return new ProviderContextLengthExceeded(fields); + } + if (status === 408 || status >= 500) return new ProviderServerError(fields); + return new ProviderRejectedRequest(fields); +} + +/** Anthropic reports a prompt over the model's window as a 400 saying so. */ +function saysPromptTooLong(cause: APICallError): boolean { + return /prompt is too long/i.test(providerSaid(cause.responseBody) ?? ""); +} + +/** How long a 429 asks to be left: `retry-after-ms`, which OpenAI sends, or `retry-after` in seconds. */ +function retryAfter(headers: Record | undefined): Duration.Duration | undefined { + const milliseconds = Number(headers?.["retry-after-ms"]); + if (Number.isFinite(milliseconds) && milliseconds > 0) return Duration.millis(milliseconds); + const seconds = Number(headers?.["retry-after"]); + return Number.isFinite(seconds) && seconds > 0 ? Duration.seconds(seconds) : undefined; +} + +/** How many causes deep the classifier looks, so a cycle can't run forever. */ +const MAX_CAUSE_DEPTH = 5; + +function findInChain(error: unknown, matches: (link: unknown) => boolean): unknown { + for (let link = error, depth = 0; link !== undefined && depth < MAX_CAUSE_DEPTH; depth++) { + if (matches(link)) return link; + link = link instanceof Error ? link.cause : undefined; + } + return undefined; +} + +/** The first socket or undici error code in `error`'s cause chain. */ +function findErrorCode(error: unknown): string | undefined { + const withCode = findInChain( + error, + (link) => + typeof link === "object" && + link !== null && + typeof (link as { code?: unknown }).code === "string", + ); + return (withCode as { code?: string } | undefined)?.code; +} + +/** What the provider said about a failed request, for the logs: its own sentence when it gave one. */ +function describe(cause: unknown): string { + if (!APICallError.isInstance(cause)) + return cause instanceof Error ? cause.message : String(cause); + const said = providerSaid(cause.responseBody) ?? cause.message; + return `${cause.statusCode ? `Provider returned ${cause.statusCode}` : "Provider refused"}: ${said}`; +} + +/** errorCode returns the `code`, else the `type`, of the error in an OpenAI-style error `body`. */ +function errorCode(body: string | undefined): string | undefined { + if (!body) return undefined; + try { + const { error } = JSON.parse(body) as { error?: { code?: unknown; type?: unknown } }; + const code = error?.code ?? error?.type; + return typeof code === "string" ? code : undefined; + } catch { + return undefined; + } +} + +function providerSaid(body: string | undefined): string | undefined { + if (!body) return undefined; + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null) return undefined; + const error = (parsed as { error?: unknown }).error; + const message = + typeof error === "object" && error !== null + ? (error as { message?: unknown }).message + : (parsed as { message?: unknown }).message; + return typeof message === "string" && message ? message : undefined; + } catch { + return body.length <= 300 ? body : undefined; + } +} diff --git a/packages/core/src/providers/models/testing.ts b/packages/core/src/providers/models/testing.ts index adfc6bff..910aea43 100644 --- a/packages/core/src/providers/models/testing.ts +++ b/packages/core/src/providers/models/testing.ts @@ -1,6 +1,17 @@ +import { DisplayName } from "@sugabots/errors"; import { Effect } from "effect"; +import type { ModelProviderRepository } from "../model-providers/model-provider-repository.ts"; import { Models } from "./models.ts"; +/** `endpoint` as the provider offering the model, under test names. */ +export const resolvedModel = ( + endpoint: ModelProviderRepository.ProviderEndpoint, +): ModelProviderRepository.ResolvedModel => ({ + endpoint, + providerName: DisplayName.fromRecord("Test provider"), + modelName: DisplayName.fromRecord("test-model"), +}); + /** A model client that streams `text` from every request, with no tool calls waiting. */ export const scriptedModel = (...text: string[]): Models.Interface => Models.fromStream(() => Effect.succeed(streamed(chunks(...text)))); @@ -8,7 +19,7 @@ export const scriptedModel = (...text: string[]): Models.Interface => /** A model client whose every request fails, for a test that must not reach a model. */ export const unusedModel = (): Models.Interface => Models.fromStream(() => - Effect.fail(new Models.RequestFailed({ message: "unused model", reason: "unavailable" })), + Effect.fail(new Models.ModelNotOffered({ model: DisplayName.fromRecord("unused model") })), ); /** A response with this text that ended as `finished` says: by default after one model call, with no tool calls waiting. */ diff --git a/tsconfig.base.json b/tsconfig.base.json index 679a7d5f..6f4cc116 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,10 @@ { // Code that uses DomainError: a caught error must become a typed class. // Keep this list the same as the DomainError override in biome.json. - "include": ["packages/server/src/http/errors.ts"], + "include": [ + "packages/core/src/providers/models/**/*.ts", + "packages/server/src/http/errors.ts" + ], "options": { "diagnosticSeverity": { "unknownInEffectCatch": "error", From 369e4f8c5521bfc0b639da423fd4c0ac2e4027e6 Mon Sep 17 00:00:00 2001 From: Jye Cusch Date: Fri, 9 Oct 2026 11:02:46 +1100 Subject: [PATCH 2/2] fix(core): sharpen provider failure classification --- .../summaries/summary.steps.test.ts | 4 +- .../conversations/turns/turn.segment.test.ts | 21 ++- .../src/conversations/turns/turn.steps.ts | 15 +- .../conversations/turns/turn.workflow.test.ts | 17 ++ .../model-provider-repository.ts | 14 +- .../core/src/providers/models/models.test.ts | 85 ++++++++- packages/core/src/providers/models/models.ts | 6 +- .../src/providers/models/request-failures.ts | 165 +++++++++++++----- 8 files changed, 267 insertions(+), 60 deletions(-) diff --git a/packages/core/src/conversations/summaries/summary.steps.test.ts b/packages/core/src/conversations/summaries/summary.steps.test.ts index ce93a2d2..59f7df3f 100644 --- a/packages/core/src/conversations/summaries/summary.steps.test.ts +++ b/packages/core/src/conversations/summaries/summary.steps.test.ts @@ -63,7 +63,7 @@ describe("summarise", () => { await execution; expect(signal?.aborted).toBe(true); expect(stream).toHaveBeenCalledTimes(1); - expect(ended).toEqual([{ failed: "The model didn't answer in time. Try again." }]); + expect(ended).toEqual([{ failed: "The model didn't answer in time." }]); expect(summaries.complete).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -113,7 +113,7 @@ describe("summarise", () => { // whole summary being retried over one bad answer. expect(stream).toHaveBeenCalledTimes(3); expect(summaries.complete).not.toHaveBeenCalled(); - expect(ended).toEqual([{ failed: "The model answered in a form we couldn't use. Try again." }]); + expect(ended).toEqual([{ failed: "The model answered in a form we couldn't use." }]); }); it("takes the answer as soon as one of the asks comes back usable", async () => { diff --git a/packages/core/src/conversations/turns/turn.segment.test.ts b/packages/core/src/conversations/turns/turn.segment.test.ts index f0f43eec..92b3b48f 100644 --- a/packages/core/src/conversations/turns/turn.segment.test.ts +++ b/packages/core/src/conversations/turns/turn.segment.test.ts @@ -1,7 +1,7 @@ import { DisplayName, userText } from "@sugabots/errors"; import { tool } from "ai"; import { eq } from "drizzle-orm"; -import { Context, Effect, Layer, Schema } from "effect"; +import { Context, Duration, Effect, Layer, Schema } from "effect"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EventBus } from "../../database/events/bus.ts"; import type { CommittedEvent } from "../../database/events/outbox.ts"; @@ -196,6 +196,25 @@ describe.skipIf(!process.env.DATABASE_URL)("a turn's segment, against Postgres", ); }); + it("fails the turn for good when the provider asks to be left too long", async () => { + const outcome = await segmentWith( + Models.fromStream(() => + Effect.fail( + new Models.ProviderRateLimited({ + ...testProvider, + retryAfter: Duration.minutes(5), + cause: new Error("429"), + }), + ), + ), + ); + + expect(outcome).toEqual({ _tag: "Finished" }); + expect(deliveredEvents()).toContainEqual( + expect.objectContaining({ type: "message.failed", willRetry: false }), + ); + }); + it("fails a reply the model finished without writing anything, for good", async () => { const outcome = await segmentWith( Models.fromStream(() => Effect.sync(() => streamed(chunks(" \n")))), diff --git a/packages/core/src/conversations/turns/turn.steps.ts b/packages/core/src/conversations/turns/turn.steps.ts index 48cc6447..99737470 100644 --- a/packages/core/src/conversations/turns/turn.steps.ts +++ b/packages/core/src/conversations/turns/turn.steps.ts @@ -66,6 +66,11 @@ const MESSAGE_FLUSH_CHARACTERS = 500; */ const CANCELLATION_CHECK_INTERVAL = Duration.seconds(15); const TURN_TIMEOUT = Duration.minutes(10); +/** + * The longest a turn waits for a rate-limited provider before running again. + * Past it, the turn ends and its message tells people when to try. + */ +const MAX_RETRY_WAIT = Duration.minutes(1); /** How many model calls a turn may make, across its segments. */ const TURN_MODEL_CALLS = 20; @@ -261,18 +266,24 @@ const generateReply = ( * Logs the failure and records what people are told of it; the turn * runs again only while that is safe and could help. A reply without * an answer does not: running it again would repeat every tool call it - * made. Nor does a model request that would fail the same way again. + * made. Nor does a model request that would fail the same way again, + * or that asks to be left longer than the turn should wait. */ const failed = (failure: TurnFailure) => { const request: DomainError | undefined = Models.isRequestFailure(failure) ? failure : undefined; + const waitsTooLong = + request?.retryAfter !== undefined && + Duration.isGreaterThan(request.retryAfter, MAX_RETRY_WAIT); return logTurnFailure(prepared, failure.message).pipe( Effect.andThen( turns.fail(replyTurn, draft, { userMessage: failure.userMessage, mayRunAgain: - !(failure instanceof ReplyWithoutAnswer) && (request?.isRetryable ?? true), + !(failure instanceof ReplyWithoutAnswer) && + (request?.isRetryable ?? true) && + !waitsTooLong, }), ), Effect.map( diff --git a/packages/core/src/conversations/turns/turn.workflow.test.ts b/packages/core/src/conversations/turns/turn.workflow.test.ts index 394f7a2f..a0692f2a 100644 --- a/packages/core/src/conversations/turns/turn.workflow.test.ts +++ b/packages/core/src/conversations/turns/turn.workflow.test.ts @@ -81,6 +81,23 @@ describe("the turn workflow", () => { expect(abandon).not.toHaveBeenCalled(); }); + it("waits as long as a rate-limited provider asked before running again", async () => { + segment.mockReturnValueOnce(Effect.succeed({ _tag: "Retry", after: Duration.seconds(30) })); + await runtime.runPromise(Turn.execute(request(), { discard: true })); + await vi.waitFor(() => expect(segment).toHaveBeenCalledTimes(1)); + + let waitedSeconds = 0; + await vi.waitFor( + async () => { + await runtime.runPromise(TestClock.adjust(Duration.seconds(1))); + waitedSeconds++; + expect(segment).toHaveBeenCalledTimes(2); + }, + { timeout: 5_000, interval: 1 }, + ); + expect(waitedSeconds).toBeGreaterThanOrEqual(30); + }); + it("keeps the lane until every approval is decided, in whatever order", async () => { segment.mockReturnValueOnce( Effect.succeed({ _tag: "Suspended", approvals: ["first", "second"] }), diff --git a/packages/core/src/providers/model-providers/model-provider-repository.ts b/packages/core/src/providers/model-providers/model-provider-repository.ts index 06e4c57d..975a563e 100644 --- a/packages/core/src/providers/model-providers/model-provider-repository.ts +++ b/packages/core/src/providers/model-providers/model-provider-repository.ts @@ -132,7 +132,11 @@ export interface Interface { workspaceId: string, providerId: string, ) => Effect.Effect; - /** How to reach whichever provider offers `modelId`, while the workspace offers it. */ + /** + * The provider that offers `modelId` while the workspace offers it, with the + * names people know both by, and how to reach it if it has the key or + * sign-in it needs. + */ readonly resolve: ( workspaceId: string, modelId: string, @@ -575,10 +579,9 @@ export const make = Effect.gen(function* () { .innerJoin(modelProvider, eq(modelProvider.id, providerModel.providerId)) .where(and(offeredIn(workspaceId), eq(providerModel.modelId, modelId))), ); - const reached = row && (yield* endpoint(workspaceId, row.providerId)); - if (!reached) return undefined; + if (!row) return undefined; return { - endpoint: reached, + endpoint: yield* endpoint(workspaceId, row.providerId), providerName: DisplayName.fromRecord(row.providerName), modelName: DisplayName.fromRecord(row.modelName ?? modelId), }; @@ -637,7 +640,8 @@ export const layer = Layer.effect(Service, make); /** The provider that offers a model, and the names people know both by. */ export interface ResolvedModel { - endpoint: ProviderEndpoint; + /** Nothing while the provider lacks the key or sign-in its preset needs. */ + endpoint: ProviderEndpoint | undefined; providerName: DisplayName; modelName: DisplayName; } diff --git a/packages/core/src/providers/models/models.test.ts b/packages/core/src/providers/models/models.test.ts index 13404147..d503740a 100644 --- a/packages/core/src/providers/models/models.test.ts +++ b/packages/core/src/providers/models/models.test.ts @@ -35,6 +35,7 @@ vi.mock("ai", async (importOriginal) => ({ })); import { APICallError } from "ai"; +import type { ModelProviderRepository } from "../model-providers/model-provider-repository.ts"; import { Models } from "./models.ts"; import { resolvedModel } from "./testing.ts"; @@ -94,6 +95,70 @@ describe("models", () => { }, ); + const endpoint = { + providerId: "provider-id", + preset: null, + baseUrl: "https://models.example/v1", + apiFormat: "openai" as const, + apiKey: "secret", + headers: {}, + configurationUpdatedAt: new Date(), + }; + const modelResolving = (resolved: ModelProviderRepository.ResolvedModel) => + Models.make({ + modelProviders: { + renewOAuthTokens: () => Effect.die(new Error("Not a ChatGPT provider")), + resolve: () => Effect.succeed(resolved), + }, + httpClients: { for: () => vi.fn() }, + requests: { start: () => Effect.succeed("request-id"), finish: () => Effect.void }, + registry: emptyRegistry, + }); + const ask = (model: Models.Interface) => + Effect.scoped( + Effect.flatMap( + model.stream({ + workspaceId: "workspace-id", + activity: { purpose: "provider-check" }, + model: "model-id", + system: "", + messages: [], + maxSteps: 1, + }), + (streamed) => streamed.finished, + ), + ); + + it("says a provider has no key, rather than that the model was switched off", async () => { + const model = modelResolving({ ...resolvedModel(endpoint), endpoint: undefined }); + + const failure = await run(Effect.flip(ask(model))); + + expect(failure).toMatchObject({ _tag: "ProviderCredentialsMissing", isRetryable: false }); + }); + + it("names a failure the provider reports partway through the response", async () => { + sdk.streamText.mockImplementationOnce(((options: { onError: (event: unknown) => void }) => { + options.onError({ + error: new APICallError({ + message: "Service Unavailable", + url: "https://models.example/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + }), + }); + return { + textStream: [], + steps: Promise.resolve([]), + responseMessages: Promise.resolve([]), + }; + }) as never); + + const failure = await run(Effect.flip(ask(modelResolving(resolvedModel(endpoint))))); + + expect(failure).toMatchObject({ _tag: "ProviderServerError", isRetryable: true }); + }); + it("asks the Codex backend the way Codex does for a ChatGPT subscription", async () => { const tokens = { access: "access-token", @@ -182,11 +247,11 @@ describe("a failed model request", () => { ); expect(failure).toMatchObject({ - _tag: "ProviderCredentialsRejected", + _tag: "ProviderAccessDenied", message: "Provider returned 403: This model requires you to complete the following before use: 18+ age confirmation.", userMessage: - "OpenRouter didn't accept the workspace's API key. A workspace admin can update it in Models.", + "OpenRouter won't let this workspace use Gemini 3.8 Flash, for example because of a setting on the account or where it's used from. A workspace admin can check the account with OpenRouter, or you can choose another model.", isRetryable: false, }); }); @@ -194,6 +259,10 @@ describe("a failed model request", () => { it.each([ ["a 402", answered(402, { error: { message: "Insufficient balance" } })], ["an exhausted quota", answered(429, { error: { code: "insufficient_quota" } })], + [ + "Anthropic's low credit", + answered(400, { error: { message: "Your credit balance is too low to access the API." } }), + ], ])("says plainly when a provider wants payment, for %s", (_, refused) => { expect(Models.classifyRequestFailure(refused, context)).toMatchObject({ _tag: "ProviderQuotaExhausted", @@ -203,6 +272,8 @@ describe("a failed model request", () => { it.each([ [400, "ProviderRejectedRequest", false], + [401, "ProviderCredentialsRejected", false], + [403, "ProviderAccessDenied", false], [404, "ProviderModelNotFound", false], [408, "ProviderServerError", true], [413, "ProviderContextLengthExceeded", false], @@ -215,15 +286,18 @@ describe("a failed model request", () => { }); }); - it("knows a prompt over the model's window from the provider's words", () => { - const tooLong = answered(400, { error: { message: "prompt is too long: 210000 tokens" } }); + it.each([ + "prompt is too long: 210000 tokens", + "This endpoint's maximum context length is 131072 tokens.", + ])("knows a prompt over the model's window from the provider's words: %s", (said) => { + const tooLong = answered(400, { error: { message: said } }); expect(Models.classifyRequestFailure(tooLong, context)._tag).toBe( "ProviderContextLengthExceeded", ); }); - it("waits as long as a rate-limited provider asks, and says how long", () => { + it("reads how long a rate-limited provider asks to be left, and says so", () => { const failure = Models.classifyRequestFailure( answered(429, {}, { "retry-after": "90" }), context, @@ -241,6 +315,7 @@ describe("a failed model request", () => { ["ECONNREFUSED", "ProviderUnreachable"], ["ENOTFOUND", "ProviderUnreachable"], ["UND_ERR_CONNECT_TIMEOUT", "ProviderUnreachable"], + ["ETIMEDOUT", "ProviderUnreachable"], ["ECONNRESET", "ProviderConnectionLost"], ["UND_ERR_SOCKET", "ProviderConnectionLost"], ])("tells a connection never made from one that dropped: %s", (code, tag) => { diff --git a/packages/core/src/providers/models/models.ts b/packages/core/src/providers/models/models.ts index d6b09adb..b2695a6f 100644 --- a/packages/core/src/providers/models/models.ts +++ b/packages/core/src/providers/models/models.ts @@ -46,6 +46,7 @@ import { classifyRequestFailure, isRequestFailure, ModelNotOffered, + ProviderCredentialsMissing, ProviderOAuthRefreshFailed, type RequestContext, type RequestFailure, @@ -257,6 +258,7 @@ export function make({ modelProviders, httpClients, requests, registry }: Option provider: resolved.providerName, model: resolved.modelName, }; + if (!resolved.endpoint) return yield* new ProviderCredentialsMissing(context); const connection = yield* withSignInAccess( modelProviders, httpClients, @@ -566,7 +568,7 @@ export class UnusableAnswer return this.reason; } get userMessage() { - return userText`The model answered in a form we couldn't use. Try again.`; + return userText`The model answered in a form we couldn't use.`; } } @@ -584,7 +586,7 @@ export class AnswerTimedOut return `The ${this.purpose} answer timed out`; } get userMessage() { - return userText`The model didn't answer in time. Try again.`; + return userText`The model didn't answer in time.`; } } diff --git a/packages/core/src/providers/models/request-failures.ts b/packages/core/src/providers/models/request-failures.ts index f4ccd797..e4a2e006 100644 --- a/packages/core/src/providers/models/request-failures.ts +++ b/packages/core/src/providers/models/request-failures.ts @@ -9,8 +9,8 @@ export interface RequestContext { readonly model: DisplayName; } -const context = { provider: DisplayName.schema, model: DisplayName.schema }; -const wrapped = { ...context, cause: Schema.Defect() }; +const requestFields = { provider: DisplayName.schema, model: DisplayName.schema }; +const requestFieldsWithCause = { ...requestFields, cause: Schema.Defect() }; /** No provider in the workspace offers the model, or the one that does is switched off. */ export class ModelNotOffered @@ -27,9 +27,30 @@ export class ModelNotOffered } } +/** The provider that offers the model has no API key or sign-in yet. */ +export class ProviderCredentialsMissing + extends Schema.TaggedError()( + "ProviderCredentialsMissing", + requestFields, + ) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Info" as const; + override get message() { + return `${this.provider} has no API key or sign-in`; + } + get userMessage() { + return userText`${this.provider}, which provides ${this.model}, has no API key or sign-in. A workspace admin can add one in Models.`; + } +} + /** The provider's sign-in couldn't be renewed. */ export class ProviderOAuthRefreshFailed - extends Schema.TaggedError()("ProviderOAuthRefreshFailed", wrapped) + extends Schema.TaggedError()( + "ProviderOAuthRefreshFailed", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = false; @@ -45,7 +66,7 @@ export class ProviderOAuthRefreshFailed /** The network policy refused the provider's address before anything was sent. */ export class ProviderAddressRefused extends Schema.TaggedError()("ProviderAddressRefused", { - ...context, + ...requestFields, cause: Schema.instanceOf(EgressRefused), }) implements DomainError @@ -62,7 +83,7 @@ export class ProviderAddressRefused /** No connection could be made: refused, no such host, or no answer in time. */ export class ProviderUnreachable - extends Schema.TaggedError()("ProviderUnreachable", wrapped) + extends Schema.TaggedError()("ProviderUnreachable", requestFieldsWithCause) implements DomainError { readonly isRetryable = true; @@ -77,7 +98,10 @@ export class ProviderUnreachable /** The connection dropped after it was made, partway through the response. */ export class ProviderConnectionLost - extends Schema.TaggedError()("ProviderConnectionLost", wrapped) + extends Schema.TaggedError()( + "ProviderConnectionLost", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = true; @@ -90,9 +114,12 @@ export class ProviderConnectionLost } } -/** The provider answered 401 or 403. */ +/** The provider answered 401: it doesn't accept the key or sign-in. */ export class ProviderCredentialsRejected - extends Schema.TaggedError()("ProviderCredentialsRejected", wrapped) + extends Schema.TaggedError()( + "ProviderCredentialsRejected", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = false; @@ -105,9 +132,30 @@ export class ProviderCredentialsRejected } } -/** The provider wants payment: a 402, or a 429 for an exhausted quota. */ +/** + * The key is fine, but the provider won't let the account use the model: a + * 403, such as for the account's region or an age check. + */ +export class ProviderAccessDenied + extends Schema.TaggedError()("ProviderAccessDenied", requestFieldsWithCause) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Warn" as const; + override get message() { + return describe(this.cause); + } + get userMessage() { + return userText`${this.provider} won't let this workspace use ${this.model}, for example because of a setting on the account or where it's used from. A workspace admin can check the account with ${this.provider}, or you can choose another model.`; + } +} + +/** The provider wants payment: a 402, a 429 for an exhausted quota, or a 400 saying so. */ export class ProviderQuotaExhausted - extends Schema.TaggedError()("ProviderQuotaExhausted", wrapped) + extends Schema.TaggedError()( + "ProviderQuotaExhausted", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = false; @@ -123,7 +171,7 @@ export class ProviderQuotaExhausted /** The provider answered 429 for asking too often. */ export class ProviderRateLimited extends Schema.TaggedError()("ProviderRateLimited", { - ...wrapped, + ...requestFieldsWithCause, retryAfter: Schema.optional(Schema.Duration), }) implements DomainError @@ -144,7 +192,10 @@ export class ProviderRateLimited /** The provider doesn't recognise the model: a 404. */ export class ProviderModelNotFound - extends Schema.TaggedError()("ProviderModelNotFound", wrapped) + extends Schema.TaggedError()( + "ProviderModelNotFound", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = false; @@ -161,7 +212,7 @@ export class ProviderModelNotFound export class ProviderContextLengthExceeded extends Schema.TaggedError()( "ProviderContextLengthExceeded", - wrapped, + requestFieldsWithCause, ) implements DomainError { @@ -177,7 +228,10 @@ export class ProviderContextLengthExceeded /** The provider refused the request itself: any other 4xx. */ export class ProviderRejectedRequest - extends Schema.TaggedError()("ProviderRejectedRequest", wrapped) + extends Schema.TaggedError()( + "ProviderRejectedRequest", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = false; @@ -186,13 +240,13 @@ export class ProviderRejectedRequest return describe(this.cause); } get userMessage() { - return userText`${this.provider} didn't accept the request for ${this.model}. This is a problem on our end, and we've logged it. If it keeps happening, choose another model.`; + return userText`${this.provider} didn't accept the request for ${this.model}. We've logged what it said. If it keeps happening, choose another model.`; } } /** The provider failed or timed out on its side: a 5xx or a 408. */ export class ProviderServerError - extends Schema.TaggedError()("ProviderServerError", wrapped) + extends Schema.TaggedError()("ProviderServerError", requestFieldsWithCause) implements DomainError { readonly isRetryable = true; @@ -207,7 +261,10 @@ export class ProviderServerError /** A failure none of the others describe, such as a response the SDK couldn't read. */ export class ProviderRequestFailed - extends Schema.TaggedError()("ProviderRequestFailed", wrapped) + extends Schema.TaggedError()( + "ProviderRequestFailed", + requestFieldsWithCause, + ) implements DomainError { readonly isRetryable = true; @@ -223,11 +280,13 @@ export class ProviderRequestFailed /** Why asking a model failed. */ export const RequestFailure = Schema.Union([ ModelNotOffered, + ProviderCredentialsMissing, ProviderOAuthRefreshFailed, ProviderAddressRefused, ProviderUnreachable, ProviderConnectionLost, ProviderCredentialsRejected, + ProviderAccessDenied, ProviderQuotaExhausted, ProviderRateLimited, ProviderModelNotFound, @@ -248,6 +307,7 @@ const NOT_CONNECTED = new Set([ "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", + "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", ]); @@ -255,7 +315,6 @@ const NOT_CONNECTED = new Set([ const CONNECTION_DROPPED = new Set([ "ECONNRESET", "EPIPE", - "ETIMEDOUT", "UND_ERR_SOCKET", "UND_ERR_BODY_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", @@ -268,12 +327,12 @@ export function classifyRequestFailure(cause: unknown, context: RequestContext): return classifyStatus(cause.statusCode, cause, fields); } const refused = findInChain(cause, (link) => link instanceof EgressRefused); - if (refused instanceof EgressRefused) { + if (refused) { return refused.reason === "unresolved" ? new ProviderUnreachable(fields) : new ProviderAddressRefused({ ...context, cause: refused }); } - const code = findErrorCode(cause); + const code = findInChain(cause, hasErrorCode)?.code; if (code && NOT_CONNECTED.has(code)) return new ProviderUnreachable(fields); if (code && CONNECTION_DROPPED.has(code)) return new ProviderConnectionLost(fields); return new ProviderRequestFailed(fields); @@ -285,29 +344,44 @@ function classifyStatus( fields: RequestContext & { cause: unknown }, ): RequestFailure { const code = errorCode(cause.responseBody); - if (status === 401 || status === 403) return new ProviderCredentialsRejected(fields); - // OpenAI, and the servers that copy its errors, report an exhausted quota as a 429. - if (status === 402 || (status === 429 && code === "insufficient_quota")) { + if (status === 401) return new ProviderCredentialsRejected(fields); + if (status === 403) return new ProviderAccessDenied(fields); + // OpenAI, and the servers that copy its errors, report an exhausted quota as a + // 429; Anthropic reports one as a 400. + if ( + status === 402 || + (status === 429 && code === "insufficient_quota") || + (status === 400 && providerSays(cause, /credit balance is too low/i)) + ) { return new ProviderQuotaExhausted(fields); } if (status === 429) { - return new ProviderRateLimited({ ...fields, retryAfter: retryAfter(cause.responseHeaders) }); + return new ProviderRateLimited({ + ...fields, + retryAfter: parseRetryAfter(cause.responseHeaders), + }); } if (status === 404) return new ProviderModelNotFound(fields); - if (status === 413 || code === "context_length_exceeded" || saysPromptTooLong(cause)) { + if ( + status === 413 || + code === "context_length_exceeded" || + providerSays(cause, /prompt is too long|maximum context length/i) + ) { return new ProviderContextLengthExceeded(fields); } if (status === 408 || status >= 500) return new ProviderServerError(fields); return new ProviderRejectedRequest(fields); } -/** Anthropic reports a prompt over the model's window as a 400 saying so. */ -function saysPromptTooLong(cause: APICallError): boolean { - return /prompt is too long/i.test(providerSaid(cause.responseBody) ?? ""); +/** Whether the provider's own sentence about the failure matches `pattern`. */ +function providerSays(cause: APICallError, pattern: RegExp): boolean { + return pattern.test(providerSaid(cause.responseBody) ?? ""); } /** How long a 429 asks to be left: `retry-after-ms`, which OpenAI sends, or `retry-after` in seconds. */ -function retryAfter(headers: Record | undefined): Duration.Duration | undefined { +function parseRetryAfter( + headers: Record | undefined, +): Duration.Duration | undefined { const milliseconds = Number(headers?.["retry-after-ms"]); if (Number.isFinite(milliseconds) && milliseconds > 0) return Duration.millis(milliseconds); const seconds = Number(headers?.["retry-after"]); @@ -317,7 +391,8 @@ function retryAfter(headers: Record | undefined): Duration.Durat /** How many causes deep the classifier looks, so a cycle can't run forever. */ const MAX_CAUSE_DEPTH = 5; -function findInChain(error: unknown, matches: (link: unknown) => boolean): unknown { +/** The first of `error` and the errors it was caused by that `matches`. */ +function findInChain(error: unknown, matches: (link: unknown) => link is T): T | undefined { for (let link = error, depth = 0; link !== undefined && depth < MAX_CAUSE_DEPTH; depth++) { if (matches(link)) return link; link = link instanceof Error ? link.cause : undefined; @@ -325,24 +400,28 @@ function findInChain(error: unknown, matches: (link: unknown) => boolean): unkno return undefined; } -/** The first socket or undici error code in `error`'s cause chain. */ -function findErrorCode(error: unknown): string | undefined { - const withCode = findInChain( - error, - (link) => - typeof link === "object" && - link !== null && - typeof (link as { code?: unknown }).code === "string", +/** Whether `link` carries a socket or undici error code, such as `ECONNREFUSED`. */ +function hasErrorCode(link: unknown): link is { code: string } { + return ( + typeof link === "object" && + link !== null && + typeof (link as { code?: unknown }).code === "string" ); - return (withCode as { code?: string } | undefined)?.code; } /** What the provider said about a failed request, for the logs: its own sentence when it gave one. */ function describe(cause: unknown): string { - if (!APICallError.isInstance(cause)) - return cause instanceof Error ? cause.message : String(cause); - const said = providerSaid(cause.responseBody) ?? cause.message; - return `${cause.statusCode ? `Provider returned ${cause.statusCode}` : "Provider refused"}: ${said}`; + if (APICallError.isInstance(cause)) { + const said = providerSaid(cause.responseBody) ?? cause.message; + return cause.statusCode ? `Provider returned ${cause.statusCode}: ${said}` : said; + } + if (cause instanceof Error) return cause.message; + // An error reported mid-stream is often a plain object, such as `{ type: "overloaded_error" }`. + try { + return JSON.stringify(cause) ?? String(cause); + } catch { + return String(cause); + } } /** errorCode returns the `code`, else the `type`, of the error in an OpenAI-style error `body`. */