feat: KbClient with per-call auth and error translation

This commit is contained in:
zeyu.fz
2026-08-15 18:31:15 +08:00
parent bbe96d35a5
commit cdc1f10882
2 changed files with 127 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
/** Shared HTTP client for the knowledge endpoints: per-call Bearer auth, JSON/SSE POST, and error translation. */
import { kbEndpoint } from './endpoints.js'
/** Maximum error-body characters kept in a translated message. */
const ERROR_BODY_LIMIT = 500
/** One knowledge API failure: HTTP status plus a bounded server-body summary. */
export class KbApiError extends Error {
constructor(message: string, readonly status?: number) {
super(message)
this.name = 'KbApiError'
}
}
export interface KbClientOptions {
workspaceId: string
endpointHost: string
/** Service version forwarded on search/chat when set (deployment debug choice). */
agentVersion?: string
/** Resolves the current DASHSCOPE_API_KEY per call; throws with guidance when unconfigured. */
resolveApiKey: () => Promise<string>
/** Test seam; defaults to global fetch. */
fetchImpl?: typeof fetch
}
export class KbClient {
constructor(private readonly opts: KbClientOptions) {}
/** The deployment's configured service version, exposed for request builders. */
get agentVersion(): string | undefined {
return this.opts.agentVersion
}
private async post(path: string, body: unknown, accept: string, signal?: AbortSignal): Promise<Response> {
const apiKey = await this.opts.resolveApiKey()
const fetchImpl = this.opts.fetchImpl ?? fetch
const url = kbEndpoint(this.opts.endpointHost, this.opts.workspaceId, path)
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Accept': accept },
body: JSON.stringify(body),
signal,
})
if (!res.ok) {
const raw = (await res.text().catch(() => '')).slice(0, ERROR_BODY_LIMIT)
let detail = raw
try {
const parsed = JSON.parse(raw) as { message?: string; code?: string }
if (parsed.message) detail = parsed.code ? `${parsed.code}: ${parsed.message}` : parsed.message
} catch { /* non-JSON error body: keep the bounded raw text */ }
throw new KbApiError(`knowledge API ${path} failed (HTTP ${res.status}): ${detail}`, res.status)
}
return res
}
/**
* POST one JSON request and parse the JSON response.
* @param path - one KB_PATHS value.
* @param body - JSON-serializable request body.
* @param signal - optional abort/timeout signal.
* @returns the parsed response.
*/
async postJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await this.post(path, body, 'application/json', signal)
return await res.json() as T
}
/**
* POST one JSON request expecting an SSE response stream.
* @param path - one KB_PATHS value.
* @param body - JSON-serializable request body.
* @param signal - abort/timeout signal (kb_chat passes its configured timeout).
* @returns the raw Response whose body is the SSE stream.
*/
async postSse(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
return await this.post(path, body, 'text/event-stream', signal)
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { KbApiError, KbClient } from '../src/client.js'
function makeClient(fetchImpl: typeof fetch) {
return new KbClient({
workspaceId: 'ws-1',
endpointHost: 'cn-beijing.maas.aliyuncs.com',
resolveApiKey: async () => 'sk-test',
fetchImpl,
})
}
describe('KbClient.postJson', () => {
it('sends Bearer auth to the workspace endpoint and returns parsed JSON', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: 1 }), { status: 200 }))
const client = makeClient(fetchImpl as unknown as typeof fetch)
const result = await client.postJson<{ ok: number }>('/api/v1/indices/knowledge/search', { query: 'q' })
expect(result.ok).toBe(1)
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test')
expect(init.method).toBe('POST')
})
it('translates a non-2xx into KbApiError with status and a bounded body summary', async () => {
const body = JSON.stringify({ code: 'InvalidParameter', message: 'agent not found' })
const fetchImpl = vi.fn(async () => new Response(body, { status: 400 }))
const client = makeClient(fetchImpl as unknown as typeof fetch)
const err = await client.postJson('/api/v1/indices/knowledge/search', {}).catch((e: unknown) => e)
expect(err).toBeInstanceOf(KbApiError)
expect((err as KbApiError).status).toBe(400)
expect((err as KbApiError).message).toContain('agent not found')
})
it('re-resolves the API key per call (credential hot-swap contract)', async () => {
const resolveApiKey = vi.fn(async () => 'sk-test')
const fetchImpl = vi.fn(async () => new Response('{}', { status: 200 }))
const client = new KbClient({
workspaceId: 'ws-1',
endpointHost: 'h',
resolveApiKey,
fetchImpl: fetchImpl as unknown as typeof fetch,
})
await client.postJson('/p', {})
await client.postJson('/p', {})
expect(resolveApiKey).toHaveBeenCalledTimes(2)
})
})