server-errors.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. export type ConfigInvalidError = {
  2. name: "ConfigInvalidError"
  3. data: {
  4. path?: string
  5. message?: string
  6. issues?: Array<{ message: string; path: string[] }>
  7. }
  8. }
  9. export type ProviderModelNotFoundError = {
  10. name: "ProviderModelNotFoundError"
  11. data: {
  12. providerID: string
  13. modelID: string
  14. suggestions?: string[]
  15. }
  16. }
  17. type Translator = (key: string, vars?: Record<string, string | number>) => string
  18. function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>) {
  19. if (!translator) return text
  20. const out = translator(key, vars)
  21. if (!out || out === key) return text
  22. return out
  23. }
  24. export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
  25. const unwrapped = unwrapNamedError(error)
  26. if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
  27. if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
  28. if (error instanceof Error && error.message) return error.message
  29. if (typeof error === "string" && error) return error
  30. if (fallback) return fallback
  31. return tr(translate, "error.chain.unknown", "Unknown error")
  32. }
  33. function unwrapNamedError(error: unknown): unknown {
  34. if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
  35. return (error.cause as Record<string, unknown>).body
  36. }
  37. return error
  38. }
  39. // Client-synthesized session not-found errors share one constructor and
  40. // predicate so the message contract cannot drift between the sync store
  41. // (server-session.ts), the route lineage (session-lineage.ts), and the
  42. // not-found fallback matching (session.tsx).
  43. const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}`
  44. export function sessionNotFoundError(sessionID: string) {
  45. return new Error(sessionNotFoundMessage(sessionID))
  46. }
  47. export function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
  48. return error instanceof Error && error.message === sessionNotFoundMessage(sessionID)
  49. }
  50. export function isSessionNotFoundError(error: unknown, sessionID: string) {
  51. const unwrapped = unwrapNamedError(error)
  52. if (typeof unwrapped !== "object" || unwrapped === null) return false
  53. const value = unwrapped as Record<string, unknown>
  54. return value._tag === "SessionNotFoundError" && value.sessionID === sessionID
  55. }
  56. function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
  57. if (typeof error !== "object" || error === null) return false
  58. const o = error as Record<string, unknown>
  59. return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
  60. }
  61. function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
  62. if (typeof error !== "object" || error === null) return false
  63. const o = error as Record<string, unknown>
  64. return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null
  65. }
  66. export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) {
  67. const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
  68. const detail = errorInput.data.message?.trim() ?? ""
  69. const issues = (errorInput.data.issues ?? [])
  70. .map((issue) => {
  71. const msg = issue.message.trim()
  72. if (!issue.path.length) return msg
  73. return `${issue.path.join(".")}: ${msg}`
  74. })
  75. .filter(Boolean)
  76. const msg = issues.length ? issues.join("\n") : detail
  77. if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file })
  78. return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, {
  79. path: file,
  80. message: msg,
  81. })
  82. }
  83. function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) {
  84. const p = errorInput.data.providerID.trim()
  85. const m = errorInput.data.modelID.trim()
  86. const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean)
  87. const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m })
  88. const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names")
  89. if (list.length) {
  90. const suggestions = list.slice(0, 5).join(", ")
  91. return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join(
  92. "\n",
  93. )
  94. }
  95. return [body, tail].join("\n")
  96. }