mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 20:25:36 -04:00
fce506b3f9
Replace the LLMError { module, method, reason } wrapper with a flat
tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied,
LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy,
LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError,
LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError
guard. Add one shared classifyApiFailure classifier used by the HTTP
executor and the AI SDK adapter so both surfaces classify identically,
preserving status, headers, body, and retry-after.
Core policy moves onto tags: retry RateLimit | ServerError |
ConnectionError | TimeoutError; toSessionError adds
provider.context-overflow, provider.timeout, and provider.not-found.
The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Effect, Layer, Ref } from "effect"
|
|
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
import { LLM, isLLMError, type LLMError } from "../src"
|
|
import { LLMClient, RequestExecutor } from "../src/route"
|
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
|
import { dynamicResponse } from "./lib/http"
|
|
import { deltaChunk } from "./lib/openai-chunks"
|
|
import { sseRaw } from "./lib/sse"
|
|
import { it } from "./lib/effect"
|
|
|
|
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
|
|
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })),
|
|
)
|
|
|
|
const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe(
|
|
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
|
)
|
|
|
|
const responsesLayer = (responses: ReadonlyArray<Response>) =>
|
|
RequestExecutor.layer.pipe(
|
|
Layer.provide(
|
|
Layer.unwrap(
|
|
Effect.gen(function* () {
|
|
const cursor = yield* Ref.make(0)
|
|
return Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
HttpClient.make((request) =>
|
|
Effect.gen(function* () {
|
|
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
|
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
|
|
RequestExecutor.layer.pipe(
|
|
Layer.provide(
|
|
Layer.unwrap(
|
|
Effect.gen(function* () {
|
|
const cursor = yield* Ref.make(0)
|
|
return Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
HttpClient.make((request) =>
|
|
Effect.gen(function* () {
|
|
yield* Ref.update(attempts, (value) => value + 1)
|
|
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
|
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
const expectLLMError = (error: unknown) => {
|
|
expect(isLLMError(error)).toBe(true)
|
|
if (!isLLMError(error)) throw new Error("expected LLMError")
|
|
return error
|
|
}
|
|
|
|
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
|
|
|
|
describe("RequestExecutor", () => {
|
|
it.effect("classifies context overflow responses", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
|
|
status: 400,
|
|
}),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
|
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
|
)
|
|
|
|
it.effect("does not classify ordinary invalid requests as context overflow", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
|
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
|
)
|
|
|
|
it.effect("returns redacted diagnostics for rate limits", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({
|
|
_tag: "LLM.RateLimit",
|
|
retryAfterMs: 0,
|
|
rateLimit: { retryAfterMs: 0 },
|
|
http: {
|
|
requestId: "req_123",
|
|
request: {
|
|
method: "POST",
|
|
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
|
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
|
},
|
|
response: {
|
|
status: 429,
|
|
headers: {
|
|
"retry-after-ms": "0",
|
|
"x-request-id": "req_123",
|
|
"x-api-key": "<redacted>",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
expect(errorHttp(error)?.body).toBe("rate limited")
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("rate limited", {
|
|
status: 429,
|
|
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
|
|
}),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("honors current redacted header names in diagnostics", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
|
|
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
|
|
}).pipe(
|
|
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
|
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
|
),
|
|
)
|
|
|
|
it.effect("extracts OpenAI-style rate-limit diagnostics", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
|
|
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
|
|
retryAfterMs: 0,
|
|
limit: { requests: "500", tokens: "30000" },
|
|
remaining: { requests: "499", tokens: "29900" },
|
|
reset: { requests: "1s", tokens: "10s" },
|
|
})
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("rate limited", {
|
|
status: 429,
|
|
headers: {
|
|
"retry-after-ms": "0",
|
|
"x-ratelimit-limit-requests": "500",
|
|
"x-ratelimit-limit-tokens": "30000",
|
|
"x-ratelimit-remaining-requests": "499",
|
|
"x-ratelimit-remaining-tokens": "29900",
|
|
"x-ratelimit-reset-requests": "1s",
|
|
"x-ratelimit-reset-tokens": "10s",
|
|
},
|
|
}),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("extracts Anthropic-style rate-limit diagnostics", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
|
|
expect(errorHttp(error)?.rateLimit).toEqual({
|
|
retryAfterMs: 0,
|
|
limit: { requests: "100", "input-tokens": "10000" },
|
|
remaining: { requests: "12", "input-tokens": "9000" },
|
|
reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" },
|
|
})
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("overloaded", {
|
|
status: 529,
|
|
headers: {
|
|
"retry-after-ms": "0",
|
|
"anthropic-ratelimit-requests-limit": "100",
|
|
"anthropic-ratelimit-requests-remaining": "12",
|
|
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
|
|
"anthropic-ratelimit-input-tokens-limit": "10000",
|
|
"anthropic-ratelimit-input-tokens-remaining": "9000",
|
|
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
|
|
},
|
|
}),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("returns provider status failures without retrying", () =>
|
|
Effect.gen(function* () {
|
|
const attempts = yield* Ref.make(0)
|
|
const error = yield* Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
return yield* executor.execute(request).pipe(Effect.flip)
|
|
}).pipe(
|
|
Effect.provide(
|
|
countedResponsesLayer(attempts, [
|
|
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
|
|
new Response("ok", { status: 200 }),
|
|
]),
|
|
),
|
|
)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
|
|
expect(yield* Ref.get(attempts)).toBe(1)
|
|
}),
|
|
)
|
|
|
|
it.effect("marks 504 and 529 status responses as server errors", () =>
|
|
Effect.gen(function* () {
|
|
const failWith = (status: number) =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("provider failure", {
|
|
status,
|
|
headers: { "retry-after-ms": "0" },
|
|
}),
|
|
]),
|
|
),
|
|
)
|
|
|
|
yield* failWith(504)
|
|
yield* failWith(529)
|
|
}),
|
|
)
|
|
|
|
it.effect("truncates large authentication error bodies", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
|
|
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
|
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("x".repeat(20_000), { status: 401 }),
|
|
new Response("should not retry", { status: 200 }),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("redacts common secret fields in response bodies", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
|
|
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
|
|
expect(errorHttp(error)?.body).not.toContain("body-secret")
|
|
expect(errorHttp(error)?.body).not.toContain("query-secret")
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
|
|
status: 400,
|
|
}),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("redacts echoed request secret values in response bodies", () =>
|
|
Effect.gen(function* () {
|
|
const executor = yield* RequestExecutor.Service
|
|
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
|
|
|
expectLLMError(error)
|
|
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
|
|
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
|
|
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
|
|
expect(errorHttp(error)?.body).not.toContain("header-secret-456")
|
|
}).pipe(
|
|
Effect.provide(
|
|
responsesLayer([
|
|
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
|
|
Effect.gen(function* () {
|
|
const attempts = yield* Ref.make(0)
|
|
const model = OpenAIChat.route
|
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
|
.model({ id: "gpt-4o-mini" })
|
|
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
|
Effect.provide(
|
|
dynamicResponse((input) =>
|
|
Ref.update(attempts, (value) => value + 1).pipe(
|
|
Effect.as(
|
|
input.respond(
|
|
sseRaw(
|
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
|
|
"data: not-json",
|
|
),
|
|
{ headers: { "content-type": "text/event-stream" } },
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Effect.flip,
|
|
)
|
|
|
|
expectLLMError(error)
|
|
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
|
expect(yield* Ref.get(attempts)).toBe(1)
|
|
}),
|
|
)
|
|
})
|