mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 16:07:29 +00:00
* feat(auth): add API key workspace Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data. Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace. BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again. Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views. Co-authored-by: Codex <noreply@openai.com> * feat(console): refine key workspace experience Unify role-aware console routes and use mode/role in authentication contracts. Reuse request state, stable pagination and performance projections across workspaces. Refine quota cards, usage panels, empty states and key-aware theme previews. Skip session restoration on login and retain form inputs until unmount. Keep the full health timeline grid visible without samples. Co-authored-by: Codex <noreply@openai.com> * feat(key-usage): replace shared workspace with focused usage page Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders. Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
import type { AxiosError, AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios'
|
||
|
||
export type ApiErrorKind = 'api' | 'timeout' | 'network' | 'http' | 'cancelled'
|
||
|
||
interface AdminErrorEnvelope {
|
||
code: number
|
||
message: string
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public readonly status: number,
|
||
public readonly code?: number,
|
||
public readonly requestId?: string,
|
||
public readonly kind: ApiErrorKind = 'api',
|
||
) {
|
||
super(message)
|
||
this.name = 'ApiError'
|
||
}
|
||
}
|
||
|
||
export function normalizeApiError(error: AxiosError<unknown>) {
|
||
const status = error.response?.status ?? 0
|
||
const requestId = responseHeader(error.response?.headers, 'x-request-id')
|
||
|
||
if (error.code === 'ERR_CANCELED') {
|
||
return new ApiError('请求已取消', status, undefined, requestId, 'cancelled')
|
||
}
|
||
const apiError = error.response && normalizeApiResponseError(error.response)
|
||
if (apiError)
|
||
return apiError
|
||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_TIMEOUT') {
|
||
return new ApiError('请求超时,请稍后重试', status, undefined, requestId, 'timeout')
|
||
}
|
||
if (!error.response) {
|
||
return new ApiError('网络连接失败,请检查网络后重试', 0, undefined, requestId, 'network')
|
||
}
|
||
return new ApiError(httpFallback(status), status, undefined, requestId, 'http')
|
||
}
|
||
|
||
export function normalizeApiResponseError(response: AxiosResponse<unknown>) {
|
||
const envelope = adminErrorEnvelope(response.data)
|
||
if (!envelope || envelope.code === 200)
|
||
return undefined
|
||
return new ApiError(
|
||
envelope.message || httpFallback(response.status >= 400 ? response.status : 0),
|
||
response.status,
|
||
envelope.code,
|
||
responseHeader(response.headers, 'x-request-id'),
|
||
'api',
|
||
)
|
||
}
|
||
|
||
function adminErrorEnvelope(value: unknown): AdminErrorEnvelope | null {
|
||
if (!value || typeof value !== 'object')
|
||
return null
|
||
const record = value as Record<string, unknown>
|
||
if (typeof record.code !== 'number' || !Number.isSafeInteger(record.code))
|
||
return null
|
||
const message = typeof record.message === 'string' ? record.message.trim() : ''
|
||
return { code: record.code, message }
|
||
}
|
||
|
||
function responseHeader(
|
||
headers: RawAxiosResponseHeaders | AxiosResponseHeaders | undefined,
|
||
name: string,
|
||
) {
|
||
if (!headers)
|
||
return undefined
|
||
const value = typeof headers.get === 'function' ? headers.get(name) : headers[name]
|
||
return typeof value === 'string' && value ? value : undefined
|
||
}
|
||
|
||
function httpFallback(status: number) {
|
||
switch (status) {
|
||
case 400:
|
||
return '请求参数不合法'
|
||
case 401:
|
||
return '需要登录'
|
||
case 403:
|
||
return '没有权限执行此操作'
|
||
case 404:
|
||
return '请求的接口不存在'
|
||
case 408:
|
||
return '请求超时,请稍后重试'
|
||
case 409:
|
||
return '资源状态冲突,请刷新后重试'
|
||
case 413:
|
||
return '请求内容过大'
|
||
case 415:
|
||
return '请求格式不受支持'
|
||
case 422:
|
||
return '请求内容不合法'
|
||
case 429:
|
||
return '请求过于频繁,请稍后重试'
|
||
case 500:
|
||
return '服务内部错误'
|
||
case 502:
|
||
return '上游服务请求失败'
|
||
case 503:
|
||
return '服务暂不可用,请稍后重试'
|
||
case 504:
|
||
return '上游服务响应超时'
|
||
default:
|
||
return status > 0 ? `请求失败(HTTP ${status})` : '请求失败'
|
||
}
|
||
}
|