From cdc1f10882a619698d378920614f186c3bb3d1d1 Mon Sep 17 00:00:00 2001 From: "zeyu.fz" Date: Sat, 15 Aug 2026 18:31:15 +0800 Subject: [PATCH] feat: KbClient with per-call auth and error translation --- packages/tool-bailian-kb/src/client.ts | 79 +++++++++++++++++++ packages/tool-bailian-kb/tests/client.test.ts | 48 +++++++++++ 2 files changed, 127 insertions(+) create mode 100644 packages/tool-bailian-kb/src/client.ts create mode 100644 packages/tool-bailian-kb/tests/client.test.ts diff --git a/packages/tool-bailian-kb/src/client.ts b/packages/tool-bailian-kb/src/client.ts new file mode 100644 index 0000000..ee91c6c --- /dev/null +++ b/packages/tool-bailian-kb/src/client.ts @@ -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 + /** 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 { + 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(path: string, body: unknown, signal?: AbortSignal): Promise { + 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 { + return await this.post(path, body, 'text/event-stream', signal) + } +} diff --git a/packages/tool-bailian-kb/tests/client.test.ts b/packages/tool-bailian-kb/tests/client.test.ts new file mode 100644 index 0000000..c1aa003 --- /dev/null +++ b/packages/tool-bailian-kb/tests/client.test.ts @@ -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).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) + }) +})