官方sdk测试

This commit is contained in:
2026-04-23 03:11:00 +08:00
commit 5bc69bcd5b
13 changed files with 1987 additions and 0 deletions

125
src/codebuddy.ts Normal file
View File

@@ -0,0 +1,125 @@
import { unstable_v2_createSession } from '@tencent-ai/agent-sdk';
import type { Message, PermissionMode, Session } from '@tencent-ai/agent-sdk';
import type { AccountConfig, AppConfig } from './config';
export type TextHandler = (text: string) => void;
export class CodeBuddyPool {
private readonly workers: AccountWorker[];
private next = 0;
constructor(config: AppConfig) {
this.workers = config.accounts.map((account) => new AccountWorker(config, account));
}
async run(prompt: string, requestedModel: string | undefined, onText?: TextHandler): Promise<string> {
const worker = this.workers[this.next % this.workers.length];
this.next += 1;
return worker.run(prompt, requestedModel, onText);
}
async close(): Promise<void> {
for (const worker of this.workers) worker.close();
}
}
class AccountWorker {
private session?: Session;
private queue: Promise<void> = Promise.resolve();
constructor(
private readonly app: AppConfig,
private readonly account: AccountConfig,
) {}
async run(prompt: string, requestedModel: string | undefined, onText?: TextHandler): Promise<string> {
return this.withLock(async () => {
const session = await this.getSession(requestedModel);
await session.send(prompt);
let resultText = '';
let assistantText = '';
let streamedAny = false;
for await (const message of session.stream()) {
if (message.type === 'stream_event') {
const delta = message.event.type === 'content_block_delta' && message.event.delta.type === 'text_delta'
? message.event.delta.text
: '';
if (delta) {
streamedAny = true;
resultText += delta;
onText?.(delta);
}
continue;
}
if (message.type === 'assistant') {
assistantText += extractAssistantText(message);
if (onText && !streamedAny) {
const text = extractAssistantText(message);
if (text) onText(text);
}
continue;
}
if (message.type === 'result') {
if (message.subtype === 'success') {
return resultText || message.result || assistantText;
}
throw new Error(message.errors?.join('; ') || message.subtype);
}
}
return resultText || assistantText;
});
}
close(): void {
this.session?.close();
this.session = undefined;
}
private async getSession(requestedModel: string | undefined): Promise<Session> {
if (!this.session) {
this.session = unstable_v2_createSession({
cwd: this.app.cwd,
model: requestedModel || this.app.model,
permissionMode: this.app.permissionMode as PermissionMode,
includePartialMessages: true,
settingSources: [],
env: {
CODEBUDDY_API_KEY: this.account.apiKey,
CODEBUDDY_AUTH_TOKEN: this.account.authToken,
CODEBUDDY_INTERNET_ENVIRONMENT: this.account.internetEnvironment,
CODEBUDDY_CONFIG_DIR: this.account.configDir,
},
});
await this.session.connect();
} else if (requestedModel && requestedModel !== this.session.getModel()) {
await this.session.setModel(requestedModel);
}
return this.session;
}
private async withLock<T>(task: () => Promise<T>): Promise<T> {
const previous = this.queue;
let release!: () => void;
this.queue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await task();
} finally {
release();
}
}
}
function extractAssistantText(message: Extract<Message, { type: 'assistant' }>): string {
return message.message.content
.map((block) => block.type === 'text' ? block.text : '')
.filter(Boolean)
.join('');
}

124
src/config.ts Normal file
View File

@@ -0,0 +1,124 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
export type AccountConfig = {
id: string;
apiKey?: string;
authToken?: string;
internetEnvironment?: string;
configDir: string;
};
export type AppConfig = {
port: number;
proxyApiKey?: string;
model?: string;
passRequestModel: boolean;
permissionMode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'delegate' | 'dontAsk';
cwd: string;
accounts: AccountConfig[];
};
type AccountInput = {
id?: string;
apiKey?: string;
authToken?: string;
internetEnvironment?: string;
configDir?: string;
};
export function loadConfig(): AppConfig {
const cwd = process.cwd();
const accounts = loadAccounts(cwd);
if (accounts.length === 0) {
throw new Error('No CodeBuddy credential found. Provide CODEBUDDY_ACCOUNTS_JSON, CODEBUDDY_API_KEY, CODEBUDDY_AUTH_TOKEN, or an apikey file.');
}
return {
port: Number(process.env.PORT ?? 8787),
proxyApiKey: process.env.PROXY_API_KEY,
model: emptyToUndefined(process.env.CODEBUDDY_MODEL),
passRequestModel: process.env.CODEBUDDY_PASS_REQUEST_MODEL === '1',
permissionMode: (process.env.CODEBUDDY_PERMISSION_MODE as AppConfig['permissionMode'] | undefined) ?? 'bypassPermissions',
cwd,
accounts,
};
}
function loadAccounts(cwd: string): AccountConfig[] {
const fromJson = parseAccountsJson(process.env.CODEBUDDY_ACCOUNTS_JSON, cwd);
if (fromJson.length > 0) return fromJson;
if (process.env.CODEBUDDY_API_KEY || process.env.CODEBUDDY_AUTH_TOKEN) {
return [normalizeAccount({
id: 'env-1',
apiKey: process.env.CODEBUDDY_API_KEY,
authToken: process.env.CODEBUDDY_AUTH_TOKEN,
internetEnvironment: process.env.CODEBUDDY_INTERNET_ENVIRONMENT,
}, cwd, 0)];
}
const file = resolve(cwd, process.env.CODEBUDDY_APIKEY_FILE ?? 'apikey');
if (!existsSync(file)) return [];
const raw = readFileSync(file, 'utf8').trim();
if (!raw) return [];
const parsed = parseAccountsJson(raw, cwd);
if (parsed.length > 0) return parsed;
const tokenKind = process.env.CODEBUDDY_TOKEN_KIND === 'auth_token' ? 'auth_token' : 'api_key';
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line, index) => parseTokenLine(line, tokenKind, index, cwd));
}
function parseAccountsJson(raw: string | undefined, cwd: string): AccountConfig[] {
if (!raw) return [];
try {
const value = JSON.parse(raw) as unknown;
const items = Array.isArray(value)
? value
: typeof value === 'object' && value !== null && Array.isArray((value as { accounts?: unknown }).accounts)
? (value as { accounts: unknown[] }).accounts
: [];
return items.map((item, index) => normalizeAccount(item as AccountInput, cwd, index));
} catch {
return [];
}
}
function parseTokenLine(line: string, tokenKind: 'api_key' | 'auth_token', index: number, cwd: string): AccountConfig {
const eq = line.indexOf('=');
if (eq > 0) {
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key === 'CODEBUDDY_AUTH_TOKEN') return normalizeAccount({ id: `file-${index + 1}`, authToken: value }, cwd, index);
if (key === 'CODEBUDDY_API_KEY') return normalizeAccount({ id: `file-${index + 1}`, apiKey: value }, cwd, index);
}
return normalizeAccount({
id: `file-${index + 1}`,
apiKey: tokenKind === 'api_key' ? line : undefined,
authToken: tokenKind === 'auth_token' ? line : undefined,
}, cwd, index);
}
function normalizeAccount(input: AccountInput, cwd: string, index: number): AccountConfig {
const id = input.id ?? `account-${index + 1}`;
const configDir = input.configDir ?? join(cwd, '.codebuddy-accounts', id);
mkdirSync(configDir, { recursive: true });
return {
id,
apiKey: emptyToUndefined(input.apiKey),
authToken: emptyToUndefined(input.authToken),
internetEnvironment: emptyToUndefined(input.internetEnvironment),
configDir,
};
}
function emptyToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

39
src/prompt.ts Normal file
View File

@@ -0,0 +1,39 @@
export type ChatMessage = {
role: string;
content?: unknown;
};
export function openAIChatToPrompt(messages: ChatMessage[]): string {
return messages.map((message) => {
const role = message.role || 'user';
return `${role.toUpperCase()}:\n${contentToText(message.content)}`;
}).join('\n\n');
}
export function anthropicMessagesToPrompt(system: unknown, messages: ChatMessage[]): string {
const parts: string[] = [];
const systemText = contentToText(system);
if (systemText) parts.push(`SYSTEM:\n${systemText}`);
parts.push(openAIChatToPrompt(messages));
return parts.filter(Boolean).join('\n\n');
}
export function contentToText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === 'string') return part;
if (typeof part === 'object' && part !== null) {
const value = part as Record<string, unknown>;
if (typeof value.text === 'string') return value.text;
if (typeof value.content === 'string') return value.content;
}
return '';
}).filter(Boolean).join('\n');
}
if (typeof content === 'object' && content !== null) {
const value = content as Record<string, unknown>;
if (typeof value.text === 'string') return value.text;
}
return '';
}

104
src/protocols.ts Normal file
View File

@@ -0,0 +1,104 @@
import { randomUUID } from 'node:crypto';
import type { ServerResponse } from 'node:http';
import { anthropicMessagesToPrompt, openAIChatToPrompt, type ChatMessage } from './prompt';
export type OpenAIChatRequest = {
model?: string;
messages?: ChatMessage[];
stream?: boolean;
};
export type AnthropicMessagesRequest = {
model?: string;
system?: unknown;
messages?: ChatMessage[];
stream?: boolean;
};
export function openAIPrompt(req: OpenAIChatRequest): string {
return openAIChatToPrompt(req.messages ?? []);
}
export function anthropicPrompt(req: AnthropicMessagesRequest): string {
return anthropicMessagesToPrompt(req.system, req.messages ?? []);
}
export function writeOpenAIResponse(res: ServerResponse, model: string, text: string): void {
writeJson(res, 200, {
id: `chatcmpl-${randomUUID()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
export function startOpenAIStream(res: ServerResponse, model: string): (text: string) => void {
const id = `chatcmpl-${randomUUID()}`;
sseHeaders(res);
return (text: string) => {
res.write(`data: ${JSON.stringify({
id,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})}\n\n`);
};
}
export function endOpenAIStream(res: ServerResponse): void {
res.write('data: [DONE]\n\n');
res.end();
}
export function writeAnthropicResponse(res: ServerResponse, model: string, text: string): void {
writeJson(res, 200, {
id: `msg_${randomUUID().replaceAll('-', '')}`,
type: 'message',
role: 'assistant',
model,
content: [{ type: 'text', text }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
});
}
export function startAnthropicStream(res: ServerResponse, model: string): (text: string) => void {
const id = `msg_${randomUUID().replaceAll('-', '')}`;
sseHeaders(res);
res.write(`event: message_start\ndata: ${JSON.stringify({
type: 'message_start',
message: { id, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } },
})}\n\n`);
res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`);
return (text: string) => {
res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } })}\n\n`);
};
}
export function endAnthropicStream(res: ServerResponse): void {
res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`);
res.write(`event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } })}\n\n`);
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
res.end();
}
export function writeJson(res: ServerResponse, statusCode: number, value: unknown): void {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(value));
}
export function writeError(res: ServerResponse, statusCode: number, message: string): void {
writeJson(res, statusCode, { error: { type: 'api_error', message } });
}
function sseHeaders(res: ServerResponse): void {
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
});
}

113
src/server.ts Normal file
View File

@@ -0,0 +1,113 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { loadConfig } from './config';
import { CodeBuddyPool } from './codebuddy';
import {
anthropicPrompt,
endAnthropicStream,
endOpenAIStream,
openAIPrompt,
startAnthropicStream,
startOpenAIStream,
writeAnthropicResponse,
writeError,
writeJson,
writeOpenAIResponse,
type AnthropicMessagesRequest,
type OpenAIChatRequest,
} from './protocols';
const config = loadConfig();
const pool = new CodeBuddyPool(config);
const server = createServer(async (req, res) => {
try {
if (!authorize(req)) {
writeError(res, 401, 'Unauthorized');
return;
}
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
if (req.method === 'GET' && url.pathname === '/health') {
writeJson(res, 200, { ok: true, accounts: config.accounts.length });
return;
}
if (req.method === 'GET' && url.pathname === '/debug/memory') {
writeJson(res, 200, { pid: process.pid, memory: process.memoryUsage() });
return;
}
if (req.method === 'GET' && url.pathname === '/v1/models') {
writeJson(res, 200, { object: 'list', data: [{ id: config.model ?? 'codebuddy', object: 'model', owned_by: 'codebuddy' }] });
return;
}
if (req.method === 'POST' && (url.pathname === '/v1/chat/completions' || url.pathname === '/chat/completions')) {
await handleOpenAI(req, res);
return;
}
if (req.method === 'POST' && url.pathname === '/v1/messages') {
await handleAnthropic(req, res);
return;
}
writeError(res, 404, 'Not found');
} catch (error) {
if (!res.headersSent) writeError(res, 500, error instanceof Error ? error.message : String(error));
else res.end();
}
});
server.listen(config.port, '127.0.0.1', () => {
console.log(`codebuddy2api listening on http://127.0.0.1:${config.port}`);
console.log(`accounts loaded: ${config.accounts.length}`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
async function handleOpenAI(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readJson<OpenAIChatRequest>(req);
const model = body.model || config.model || 'codebuddy';
const prompt = openAIPrompt(body);
if (body.stream) {
const write = startOpenAIStream(res, model);
await pool.run(prompt, sdkModel(body.model), write);
endOpenAIStream(res);
return;
}
const text = await pool.run(prompt, sdkModel(body.model));
writeOpenAIResponse(res, model, text);
}
async function handleAnthropic(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readJson<AnthropicMessagesRequest>(req);
const model = body.model || config.model || 'codebuddy';
const prompt = anthropicPrompt(body);
if (body.stream) {
const write = startAnthropicStream(res, model);
await pool.run(prompt, sdkModel(body.model), write);
endAnthropicStream(res);
return;
}
const text = await pool.run(prompt, sdkModel(body.model));
writeAnthropicResponse(res, model, text);
}
function sdkModel(requestModel: string | undefined): string | undefined {
return config.passRequestModel ? requestModel : undefined;
}
function authorize(req: IncomingMessage): boolean {
if (!config.proxyApiKey) return true;
const header = req.headers.authorization ?? '';
return header === `Bearer ${config.proxyApiKey}`;
}
async function readJson<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
if (chunks.length === 0) return {} as T;
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T;
}
function shutdown(): void {
pool.close().finally(() => server.close(() => process.exit(0)));
}