/** * MiniMax HTTP client. * * MiniMax exposes an OpenAI-compatible Chat Completions API. We use the * official `openai` SDK with a custom `baseURL`, so we get streaming, * function-calling, and error semantics for free. The SDK is configured * with a custom `User-Agent` so MiniMax can see the adapter version in * their request logs. */ import OpenAI from "openai"; import type { AdapterConfig, ChatMessage, ToolDefinition, ToolCall, Usage } from "./types.js"; export interface ChatRequest { model: string; messages: ChatMessage[]; tools?: ToolDefinition[]; tool_choice?: "auto" | "none" | { type: "function"; function: { name: string } }; temperature?: number; max_tokens?: number; top_p?: number; stream?: boolean; /** Anything MiniMax supports but isn't in the OpenAI type. */ extra?: Record; } export interface ChatResponse { id: string; model: string; content: string | null; toolCalls: ToolCall[]; reasoning: string | null; usage: Usage; finishReason: string; raw: unknown; } export interface StreamChunk { delta: { content?: string; reasoning?: string; toolCalls?: ToolCall[] }; finishReason: string | null; usage: Usage | null; } export class MiniMaxClient { private readonly sdk: OpenAI; public readonly config: AdapterConfig; constructor(config: AdapterConfig) { this.config = config; this.sdk = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl, defaultHeaders: { "User-Agent": `paperclip-adapter-minimax/0.1.0`, }, timeout: 120_000, } as any); } async chat(req: ChatRequest): Promise { const completion = await this.sdk.chat.completions.create({ model: req.model, messages: req.messages as any, tools: req.tools as any, tool_choice: req.tool_choice as any, temperature: req.temperature, max_tokens: req.max_tokens, top_p: req.top_p, stream: false, ...(req.extra ?? {}), } as any); const choice = completion.choices?.[0]; const message: any = choice?.message ?? {}; const toolCalls: ToolCall[] = (message.tool_calls ?? []).map((tc: any) => ({ id: tc.id, type: "function" as const, function: { name: tc.function.name, arguments: tc.function.arguments ?? "{}" }, })); return { id: completion.id, model: completion.model, content: message.content ?? null, toolCalls, reasoning: message.reasoning ?? null, usage: { inputTokens: completion.usage?.prompt_tokens ?? 0, outputTokens: completion.usage?.completion_tokens ?? 0, cachedInputTokens: completion.usage?.prompt_tokens_details?.cached_tokens, totalTokens: completion.usage?.total_tokens, }, finishReason: choice?.finish_reason ?? "stop", raw: completion, }; } async *stream(req: ChatRequest): AsyncGenerator { const stream = await this.sdk.chat.completions.create({ model: req.model, messages: req.messages as any, tools: req.tools as any, tool_choice: req.tool_choice as any, temperature: req.temperature, max_tokens: req.max_tokens, top_p: req.top_p, stream: true, stream_options: { include_usage: true }, ...(req.extra ?? {}), } as any); let model = req.model; let finishReason: string = "stop"; let lastUsage: Usage = { inputTokens: 0, outputTokens: 0 }; const toolCallBuffers: Map = new Map(); const reasoningBuffers: string[] = []; const contentBuffers: string[] = []; for await (const chunk of stream as any) { if (chunk.model) model = chunk.model; const choice = chunk.choices?.[0]; if (!choice) continue; const delta = choice.delta ?? {}; if (delta.content) contentBuffers.push(delta.content); if (delta.reasoning) reasoningBuffers.push(delta.reasoning); // Tool-call deltas come in pieces: first the id+name, then chunks of args. for (const tc of delta.tool_calls ?? []) { const buf = toolCallBuffers.get(tc.index) ?? { args: "" }; if (tc.id) buf.id = tc.id; if (tc.function?.name) buf.name = tc.function.name; if (tc.function?.arguments) buf.args += tc.function.arguments; toolCallBuffers.set(tc.index, buf); } if (choice.finish_reason) finishReason = choice.finish_reason; if (chunk.usage) { lastUsage = { inputTokens: chunk.usage.prompt_tokens ?? 0, outputTokens: chunk.usage.completion_tokens ?? 0, cachedInputTokens: chunk.usage.prompt_tokens_details?.cached_tokens, totalTokens: chunk.usage.total_tokens, }; } yield { delta: { content: delta.content, reasoning: delta.reasoning, }, finishReason: choice.finish_reason ?? null, usage: chunk.usage ? lastUsage : null, }; } const toolCalls: ToolCall[] = []; for (const [, buf] of [...toolCallBuffers.entries()].sort(([a], [b]) => a - b)) { if (!buf.name || !buf.id) continue; toolCalls.push({ id: buf.id, type: "function", function: { name: buf.name, arguments: buf.args || "{}" }, }); } return { id: "stream", model, content: contentBuffers.join("") || null, toolCalls, reasoning: reasoningBuffers.join("") || null, usage: lastUsage, finishReason, raw: null, }; } /** Validate credentials by listing models. */ async validate(): Promise<{ ok: boolean; models: string[]; message: string }> { try { const list = await this.sdk.models.list(); const models = (list.data ?? []).map((m) => m.id); const has = models.some((id) => id === this.config.model); return { ok: true, models, message: has ? `connected; ${this.config.model} is available` : `connected; ${this.config.model} NOT in catalog (will use it anyway)`, }; } catch (err) { return { ok: false, models: [], message: err instanceof Error ? err.message : String(err), }; } } }