diff --git a/packages/ai/baidu/src/index.test.ts b/packages/ai/baidu/src/index.test.ts index f43ad207..6fde67db 100644 --- a/packages/ai/baidu/src/index.test.ts +++ b/packages/ai/baidu/src/index.test.ts @@ -1,4 +1,123 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'ai' }); + +const ctx = ( + secrets: Record = { QIANFAN_API_KEY: 'test-token' }, + dryRun = false, +) => ({ + secret: (key: string) => secrets[key], + log: () => {}, + dryRun, +}); + +describe('Baidu Qianfan chat completions generation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('short-circuits dry-run before network calls', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx({ QIANFAN_API_KEY: 'test-token' }, true), + 'hello', + {}, + {}, + ); + + expect(result).toEqual({ + text: '[dry-run]', + model: 'ernie-4.0-turbo-8k', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts OpenAI-compatible chat completions requests and maps usage tokens', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + model: 'ernie-4.0-turbo-8k', + choices: [{ message: { role: 'assistant', content: 'hi from qianfan' } }], + usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx(), + 'hello', + { + model: 'ernie-4.0-turbo-8k', + system: 'be concise', + maxTokens: 80, + temperature: 0.3, + extra: { top_p: 0.8, penalty_score: 1.1 }, + }, + { appId: 'app-test' }, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const call = fetchMock.mock.calls[0]; + expect(call).toBeDefined(); + const [url, request] = call!; + expect(url).toBe('https://qianfan.baidubce.com/v2/chat/completions'); + expect(request.headers.authorization).toBe('Bearer test-token'); + expect(request.headers['content-type']).toBe('application/json'); + expect(request.headers.appid).toBe('app-test'); + expect(JSON.parse(request.body)).toEqual({ + model: 'ernie-4.0-turbo-8k', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello' }, + ], + stream: false, + max_tokens: 80, + temperature: 0.3, + top_p: 0.8, + penalty_score: 1.1, + }); + expect(result).toEqual({ + text: 'hi from qianfan', + model: 'ernie-4.0-turbo-8k', + inputTokens: 11, + outputTokens: 7, + }); + }); + + it('supports compatible text-style choices and custom base URLs', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ text: 'legacy text response' }], + }), + })); + + const result = await adapter.generate( + ctx(), + 'hello', + { model: 'deepseek-v3.1-250821' }, + { baseUrl: 'https://qianfan.test/v2' }, + ); + + expect(result).toEqual({ + text: 'legacy text response', + model: 'deepseek-v3.1-250821', + }); + }); + + it('includes status and response body excerpt on errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'invalid bearer token'.repeat(30), + })); + + await expect(adapter.generate(ctx(), 'hello', {}, {})).rejects.toThrow( + /Baidu Qianfan 401: invalid bearer token/, + ); + }); +}); diff --git a/packages/ai/baidu/src/index.ts b/packages/ai/baidu/src/index.ts index 53ba9ca9..ac056d3c 100644 --- a/packages/ai/baidu/src/index.ts +++ b/packages/ai/baidu/src/index.ts @@ -2,29 +2,94 @@ import { defineAi, tokenSetup } from '@profullstack/sh1pt-core'; interface Config { baseUrl?: string; + appId?: string; } +const DEFAULT_BASE = 'https://qianfan.baidubce.com/v2'; +const DEFAULT_MODEL = 'ernie-4.0-turbo-8k'; + export default defineAi({ id: 'ai-baidu', label: 'Baidu Qianfan', - defaultModel: 'ernie-4.5', - models: ['ernie-4.5'], + defaultModel: DEFAULT_MODEL, + models: [ + DEFAULT_MODEL, + 'ernie-4.0-8k', + 'ernie-3.5-8k', + 'ernie-speed-8k', + 'deepseek-v3.1-250821', + ], - async generate(ctx, prompt, _opts, _config) { + async generate(ctx, prompt, opts, config) { const apiKey = ctx.secret('QIANFAN_API_KEY'); - if (!apiKey) throw new Error('QIANFAN_API_KEY not in vault — run `sh1pt promote ai setup`'); - ctx.log(`[stub] ai-baidu · ${prompt.length} chars in — integration pending`); - return { text: '[stub — ai-baidu integration not yet implemented]', model: 'ernie-4.5' }; + if (!apiKey) throw new Error('QIANFAN_API_KEY not in vault'); + const model = opts.model ?? DEFAULT_MODEL; + ctx.log(`baidu-qianfan - model=${model} - ${prompt.length} chars in`); + if (ctx.dryRun) return { text: '[dry-run]', model }; + + const messages: QianfanMessage[] = []; + if (opts.system) messages.push({ role: 'system', content: opts.system }); + messages.push({ role: 'user', content: prompt }); + + const headers: Record = { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }; + if (config.appId) headers.appid = config.appId; + + const res = await fetch(`${config.baseUrl ?? DEFAULT_BASE}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + messages, + stream: false, + ...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}), + ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}), + ...opts.extra, + }), + }); + if (!res.ok) throw new Error(`Baidu Qianfan ${res.status}: ${(await res.text()).slice(0, 200)}`); + + const data = await res.json() as QianfanChatResponse; + const choice = data.choices[0]; + return { + text: choice?.message?.content ?? choice?.text ?? '', + model: data.model ?? model, + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + }; }, setup: tokenSetup({ secretKey: 'QIANFAN_API_KEY', label: 'Baidu Qianfan', - vendorDocUrl: 'https://qianfan.cloud.baidu.com', + vendorDocUrl: 'https://cloud.baidu.com/doc/qianfan-docs/s/Mm8r1mejk', steps: [ - 'Sign in at https://qianfan.cloud.baidu.com and create an API key', - 'Copy the key — usually shown once', + 'Create a Qianfan IAM Bearer token in Baidu Cloud', + 'Copy the bce-v3 token — usually shown once', 'Paste below; sh1pt encrypts it in the vault', ], }), }); + +type QianfanRole = 'system' | 'user' | 'assistant' | 'tool'; + +interface QianfanMessage { + role: QianfanRole; + content: string; +} + +interface QianfanChatResponse { + model?: string; + choices: Array<{ + message?: { + content?: string; + }; + text?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + }; +}