mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: KbClient with per-call auth and error translation
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user