From 89acb29746def55800c81002ed0dfb7c20e63da8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 15:02:08 +0000 Subject: [PATCH] Add v2 chat tool and expand v2 endpoint parity Co-authored-by: Felix Waweru --- README.md | 5 +- src/client.ts | 200 +++++++++++++++++++++++++++++++++++++-- src/formatters.ts | 17 ++++ src/tools/auth.ts | 21 +++- src/tools/chat.ts | 65 ++++++++++--- src/tools/files.ts | 4 +- src/tools/search.ts | 8 +- src/tools/stats.ts | 4 +- src/types.ts | 61 +++++++++++- tests/client.test.ts | 113 +++++++++++++++++++++- tests/formatters.test.ts | 24 ++++- 11 files changed, 486 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3f2f3a7..f460c22 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ npx fastmcp inspect src/index.ts # Open MCP Inspector UI npx fastmcp dev src/index.ts # Test with MCP CLI ``` -## Tools Reference (22 tools) +## Tools Reference (23 tools) -Covers the Code-Fundi **V2** API: search (including search-with-chat / research), repositories (list, index, status, readme), files, history, statistics, API keys, authentication, plus **Fundi chat** and the model catalog (`POST /v1/fundi/chat`, `GET /v1/fundi/models` — there is no separate `/v2/chat` in the published OpenAPI). +Covers the Code-Fundi **V2** API: search (including search-with-chat / research), chat v2, repositories (list, index, status, readme), files, history, statistics, API keys, and authentication. Also includes legacy Fundi chat + model catalog (`POST /v1/fundi/chat`, `GET /v1/fundi/models`) for backward compatibility. ### Search @@ -197,6 +197,7 @@ Covers the Code-Fundi **V2** API: search (including search-with-chat / research) | Tool | Description | |------|-------------| +| `code-fundi-chat-v2` | Primary v2 chat endpoint (`POST /v2/chat`) with normalized JSON/NDJSON responses | | `code-fundi-chat` | Fundi AI chat (`POST /v1/fundi/chat`; streamed responses are collected to text) | | `code-fundi-list-models` | List available AI models | diff --git a/src/client.ts b/src/client.ts index 807877b..0479aa7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,8 +17,11 @@ import type { V2AuthVerifyResponse, V2AuthResendResponse, V2AuthMode, ChatRequest, ChatResponse, ModelsResponse, SearchFieldsParam, SortOrder, StatsRange, RepoScope, HistoryCategory, ErrorResponse, + V2AuthClientRequestOptions, V2ChatRequest, V2ChatResult, } from "./types.js"; +export const CODEFUNDI_DEFAULT_CHAT_MODEL_ID = "openai/gpt-oss-120b:free" as const; + // ============================================================================ // Error // ============================================================================ @@ -146,6 +149,16 @@ export class CodeFundiClient { return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null)); } + private mergeV2AuthClientHeaders( + base: Record, + options?: V2AuthClientRequestOptions, + ): Record { + const out = { ...base }; + if (options?.idempotencyKey) out["Idempotency-Key"] = options.idempotencyKey; + if (options?.fingerprint) out["X-Fingerprint"] = options.fingerprint; + return out; + } + private async postUnauth(endpoint: string, body: unknown, extra?: Record): Promise { const url = `${this.baseUrl}${endpoint}`; const res = await fetch(url, { @@ -164,6 +177,64 @@ export class CodeFundiClient { return json as T; } + private extractStringCandidate(...candidates: unknown[]): string | undefined { + for (const value of candidates) { + if (typeof value === "string" && value.trim()) { + return value; + } + } + return undefined; + } + + private normalizeV2ChatJson(payload: unknown): V2ChatResult { + if (!payload || typeof payload !== "object") { + return { text: "", raw: payload }; + } + + const root = payload as Record; + const data = (root.data && typeof root.data === "object") + ? root.data as Record + : undefined; + + const text = this.extractStringCandidate( + root.response, root.text, root.message, root.content, + data?.response, data?.text, data?.message, data?.content, + ) ?? ""; + + const model = this.extractStringCandidate(root.model, data?.model); + const conversationId = this.extractStringCandidate( + root.conversation_id, + root.conversationId, + data?.conversation_id, + data?.conversationId, + ); + + const searchResultsRaw = + Array.isArray(root.search_results) ? root.search_results + : Array.isArray(root.results) ? root.results + : Array.isArray(data?.search_results) ? data.search_results + : Array.isArray(data?.results) ? data.results + : undefined; + + const searchResults = Array.isArray(searchResultsRaw) + ? searchResultsRaw as SearchResult[] + : undefined; + + const contextFilesRaw = + typeof root.context_files === "number" ? root.context_files + : typeof data?.context_files === "number" ? data.context_files + : undefined; + + return { + text, + model, + conversationId, + contextFiles: contextFilesRaw, + searchResults, + raw: payload, + }; + } + // ---- NDJSON ---- async collectNdjsonStream(stream: ReadableStream): Promise { @@ -202,6 +273,19 @@ export class CodeFundiClient { return { text, searchResults, searchMeta, model, contextFiles }; } + async collectV2ChatNdjsonStream(stream: ReadableStream): Promise { + const research = await this.collectNdjsonStream(stream); + return { + text: research.text, + model: research.model, + contextFiles: research.contextFiles, + searchResults: research.searchResults, + raw: { + searchMeta: research.searchMeta, + }, + }; + } + // ==== V2 Search ==== async search(req: SearchRequest): Promise { @@ -308,20 +392,120 @@ export class CodeFundiClient { // ==== V2 Auth (unauthenticated) ==== - async authAuthenticate(p: { email: string; auth_mode: V2AuthMode; should_create_user?: boolean; authPassword?: string }): Promise { + async authAuthenticate( + p: { + email: string; + auth_mode?: V2AuthMode; + mode?: V2AuthMode; + should_create_user?: boolean; + authPassword?: string; + data?: Record; + }, + request?: V2AuthClientRequestOptions, + ): Promise { + const authMode = p.auth_mode ?? p.mode; + if (!authMode) { + throw new TypeError("authAuthenticate requires auth_mode or mode"); + } + const passHeader = + request?.passwordHeader === "x-auth-password" + ? "X-Auth-Password" + : "X-CodeFundi-Auth-Password"; const h: Record = {}; - if (p.auth_mode === "password" && p.authPassword) h["X-CodeFundi-Auth-Password"] = p.authPassword; + if (authMode === "password" && p.authPassword) h[passHeader] = p.authPassword; return this.postUnauth("/v2/auth/authenticate", { - auth_mode: p.auth_mode, email: p.email, should_create_user: p.should_create_user ?? false, - }, h); + auth_mode: authMode, + email: p.email, + should_create_user: p.should_create_user ?? false, + ...(p.data ? { data: p.data } : {}), + }, this.mergeV2AuthClientHeaders(h, request)); + } + + async authVerify( + p: { email: string; token: string }, + request?: V2AuthClientRequestOptions, + ): Promise { + return this.postUnauth( + "/v2/auth/verify", + p, + this.mergeV2AuthClientHeaders({}, request), + ); } - async authVerify(p: { email: string; token: string }): Promise { - return this.postUnauth("/v2/auth/verify", p); + async authResend( + p: { email: string; type?: "signup" | "email_change" | "email" }, + request?: V2AuthClientRequestOptions, + ): Promise { + return this.postUnauth( + "/v2/auth/resend", + { email: p.email, type: p.type ?? "signup" }, + this.mergeV2AuthClientHeaders({}, request), + ); } - async authResend(p: { email: string; type?: "signup" | "email_change" | "email" }): Promise { - return this.postUnauth("/v2/auth/resend", { email: p.email, type: p.type ?? "signup" }); + // ==== V2 Chat ==== + + async chatV2( + req: V2ChatRequest, + options?: { fallbackToV1?: boolean }, + ): Promise { + this.requireApiKey(); + const body: Record = { + prompt: req.prompt, + model: req.model ?? CODEFUNDI_DEFAULT_CHAT_MODEL_ID, + conversation_id: req.conversation_id, + repo_ids: req.repo_ids, + repo_urls: req.repo_urls, + context: req.context, + stream: req.stream, + ...(req.extra || {}), + }; + + const normalizedBody = Object.fromEntries( + Object.entries(body).filter(([, value]) => value !== undefined && value !== null), + ); + + try { + const res = await fetch(`${this.baseUrl}/v2/chat`, { + method: "POST", + headers: this.authHeaders(), + body: JSON.stringify(normalizedBody), + }); + if (!res.ok) { + const { msg, code, retryAfter } = await this.readHttpError(res); + throw new CodeFundiApiError(msg, res.status, { code, retryAfter }); + } + + const ct = (res.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("ndjson")) { + if (!res.body) return { text: "" }; + return this.collectV2ChatNdjsonStream(res.body); + } + if (ct.includes("application/json")) { + const payload = await res.json(); + return this.normalizeV2ChatJson(payload); + } + const text = await res.text(); + return { text, raw: text }; + } catch (err) { + const canFallback = + options?.fallbackToV1 !== false && + err instanceof CodeFundiApiError && + [404, 405, 501].includes(err.statusCode); + if (!canFallback) throw err; + + const legacy = await this.chat({ + prompt: req.prompt, + model: req.model, + conversation: req.conversation_id, + context: req.context, + }); + return { + text: legacy.response || "", + model: legacy.model, + raw: legacy, + }; + } } // ==== V1 Chat & Models (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ==== diff --git a/src/formatters.ts b/src/formatters.ts index ebdb665..520e082 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -10,6 +10,7 @@ import type { UsageByType, ActivityByDay, LanguageStat, ApiKey, AIModel, ReadmeData, RepoStatusData, RepositoryIndexInitRepo, Pagination, TierName, + V2ChatResult, } from "./types.js"; import { CodeFundiApiError } from "./client.js"; @@ -75,6 +76,22 @@ export function formatResearchResult(result: ResearchResult): string { return parts.join("\n"); } +export function formatV2ChatResult(result: V2ChatResult): string { + const parts: string[] = []; + if (result.text.trim()) { + parts.push(result.text.trim()); + } else { + parts.push("_No response generated._"); + } + if (result.model) parts.push(`\n_Model: ${result.model}_`); + if (result.conversationId) parts.push(`_Conversation: ${result.conversationId}_`); + if (result.contextFiles !== undefined) parts.push(`_Context files: ${result.contextFiles}_`); + if (result.searchResults?.length) { + parts.push(`\n_Context sources: ${result.searchResults.length} file(s)._`); + } + return parts.join("\n"); +} + // ============================================================================ // Repos // ============================================================================ diff --git a/src/tools/auth.ts b/src/tools/auth.ts index 9de3ffa..41a1dfb 100644 --- a/src/tools/auth.ts +++ b/src/tools/auth.ts @@ -25,6 +25,11 @@ export function registerAuthTools(server: FastMCP): void { auth_mode: z.enum(["otp", "password"]).describe("Authentication mode: 'otp' for email code, 'password' for password-based"), should_create_user: z.boolean().optional().describe("Set to true for new user registration (default: false)"), password: z.string().optional().describe("Password (only used when auth_mode is 'password')"), + idempotency_key: z.string().optional().describe("Optional Idempotency-Key header value"), + fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"), + password_header: z.enum(["x-codefundi-auth-password", "x-auth-password"]).optional().describe( + "Header to carry password when auth_mode=password (default: x-codefundi-auth-password)", + ), }), annotations: { title: "Authenticate", readOnlyHint: false, openWorldHint: true }, execute: async (args) => { @@ -35,6 +40,10 @@ export function registerAuthTools(server: FastMCP): void { auth_mode: args.auth_mode, should_create_user: args.should_create_user, authPassword: args.password, + }, { + idempotencyKey: args.idempotency_key, + fingerprint: args.fingerprint, + passwordHeader: args.password_header, }); const d = res.data; @@ -76,12 +85,16 @@ export function registerAuthTools(server: FastMCP): void { parameters: z.object({ email: z.string().email().describe("Email address used in the authenticate step"), token: z.string().min(6).max(6).describe("6-digit OTP verification code"), + fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"), }), annotations: { title: "Verify OTP", readOnlyHint: false }, execute: async (args) => { try { const client = getClient(); - const res = await client.authVerify({ email: args.email, token: args.token }); + const res = await client.authVerify( + { email: args.email, token: args.token }, + { fingerprint: args.fingerprint }, + ); const d = res.data; const parts: string[] = []; @@ -112,12 +125,16 @@ export function registerAuthTools(server: FastMCP): void { parameters: z.object({ email: z.string().email().describe("Email address to resend the code to"), type: z.enum(["signup", "email_change", "email"]).optional().describe("Resend type (default: signup)"), + fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"), }), annotations: { title: "Resend OTP", readOnlyHint: false }, execute: async (args) => { try { const client = getClient(); - await client.authResend({ email: args.email, type: args.type }); + await client.authResend( + { email: args.email, type: args.type }, + { fingerprint: args.fingerprint }, + ); return `📧 Verification code resent to **${args.email}**. Use \`code-fundi-auth-verify\` with the new code.`; } catch (err) { return formatError(err); } }, diff --git a/src/tools/chat.ts b/src/tools/chat.ts index 3478b76..7ade0e2 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -1,21 +1,67 @@ /** * Code-Fundi MCP — Chat & Models * - * Chat uses `POST /v1/fundi/chat` (OpenAPI "AI Chat"). The production API streams HTML; - * the client collects the full body into a single string for MCP tools. - * Model catalog uses `GET /v1/fundi/models` (no separate `/v2/chat` in the published OpenAPI). + * Includes: + * - v2 chat (primary): `POST /v2/chat` with JSON/NDJSON/text normalization + v1 fallback + * - v1 Fundi chat (legacy): `POST /v1/fundi/chat` + * - model catalog: `GET /v1/fundi/models` */ import type { FastMCP } from "fastmcp"; import { z } from "zod"; -import { getClient } from "../client.js"; -import { formatModels, formatError } from "../formatters.js"; +import { CODEFUNDI_DEFAULT_CHAT_MODEL_ID, getClient } from "../client.js"; +import { formatModels, formatError, formatV2ChatResult } from "../formatters.js"; export function registerChatTools(server: FastMCP): void { + const contextSchema = z.object({ + role: z.enum(["user", "assistant", "system"]).optional(), + content: z.string().optional(), + }).passthrough(); + + server.addTool({ + name: "code-fundi-chat-v2", + description: + "Send a chat message via Code-Fundi v2 chat endpoint (POST /v2/chat). " + + "Supports repo-aware context (`repo_ids` / `repo_urls`) and conversation continuation. " + + "Automatically falls back to v1 chat when v2 chat is not available in a deployment.", + parameters: z.object({ + prompt: z.string().describe("User message"), + model: z.string().optional().describe( + `Model ID (default: ${CODEFUNDI_DEFAULT_CHAT_MODEL_ID})`, + ), + conversation_id: z.string().optional().describe("Conversation/thread ID to continue an existing chat"), + repo_ids: z.array(z.string()).optional().describe("Repository source UUIDs for context scoping"), + repo_urls: z.array(z.string()).optional().describe("Repository clone URLs for context scoping"), + context: z.array(contextSchema).optional().describe("Optional prior conversation messages"), + stream: z.boolean().optional().describe("Hint for server-side streaming behavior"), + fallback_to_v1: z.boolean().optional().describe("Fallback to v1 chat on v2 not-supported errors (default: true)"), + extra: z.record(z.unknown()).optional().describe("Additional deployment-specific request keys merged into the v2 payload"), + }), + annotations: { title: "Code-Fundi Chat v2", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const res = await client.chatV2({ + prompt: args.prompt, + model: args.model, + conversation_id: args.conversation_id, + repo_ids: args.repo_ids, + repo_urls: args.repo_urls, + context: args.context, + stream: args.stream, + extra: args.extra, + }, { + fallbackToV1: args.fallback_to_v1, + }); + return formatV2ChatResult(res); + } catch (err) { return formatError(err); } + }, + }); + server.addTool({ name: "code-fundi-chat", description: - "Send a message to Code-Fundi AI (Fundi chat: POST /v1/fundi/chat). " + + "Send a message to legacy Fundi chat (POST /v1/fundi/chat). " + "Supports threading, optional code context, indexed repository knowledge (`knowledge_id`), embeddings memory, and voice mode. " + "Responses are streamed by the API and returned as plain text (or JSON when the server uses JSON mode).", parameters: z.object({ @@ -28,12 +74,9 @@ export function registerChatTools(server: FastMCP): void { ), embed: z.boolean().optional().describe("Enable conversation memory via embeddings (default false)"), voice: z.boolean().optional().describe("Request voice/audio path on the server (default false)"), - context: z.array(z.object({ - role: z.enum(["user", "assistant", "system"]).optional(), - content: z.string().optional(), - })).optional().describe("Previous conversation messages for context"), + context: z.array(contextSchema).optional().describe("Previous conversation messages for context"), }), - annotations: { title: "Code-Fundi Chat", readOnlyHint: true, openWorldHint: true }, + annotations: { title: "Code-Fundi Chat (Legacy v1)", readOnlyHint: true, openWorldHint: true }, execute: async (args) => { try { const client = getClient(); diff --git a/src/tools/files.ts b/src/tools/files.ts index b666041..34c1a04 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -41,8 +41,8 @@ export function registerFileTools(server: FastMCP): void { parameters: z.object({ repo_key: z.string().describe("Repository UUID or clone URL"), file_id: z.string().describe("File UUID (from code-fundi-list-files results)"), - fields: z.enum(["basic", "summary", "full"]).optional().describe( - "Field preset (default: full).", + fields: z.string().optional().describe( + "Documentation field preset or custom field path list (basic|summary|full|raw or comma-separated paths).", ), }), annotations: { title: "File Documentation", readOnlyHint: true }, diff --git a/src/tools/search.ts b/src/tools/search.ts index b2cba59..94075ea 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -23,8 +23,8 @@ export function registerSearchTools(server: FastMCP): void { scan_mode: z.enum(["semantic", "grep_docs", "grep_code"]).optional().describe("Search mode (default: semantic)"), repo_ids: z.array(z.string()).optional().describe("Filter by repository UUIDs"), repo_urls: z.array(z.string()).optional().describe("Filter by repository clone URLs"), - fields: z.enum(["basic", "summary", "full"]).optional().describe( - "Documentation detail level per result (default: full).", + fields: z.string().optional().describe( + "Documentation field preset or custom field path list (basic|summary|full|raw or comma-separated paths).", ), similarity_threshold: z.number().min(0).max(1).optional().describe("Minimum similarity score (0-1)"), file_types: z.array(z.string()).optional().describe("Filter by file extensions (e.g. ['.ts', '.py'])"), @@ -79,7 +79,9 @@ export function registerSearchTools(server: FastMCP): void { model: z.string().optional().describe("AI model to use for analysis"), repo_ids: z.array(z.string()).optional().describe("Filter by repository UUIDs"), repo_urls: z.array(z.string()).optional().describe("Filter by repository clone URLs"), - fields: z.enum(["basic", "summary", "full"]).optional().describe("Documentation detail level"), + fields: z.string().optional().describe( + "Documentation field preset or custom field path list (basic|summary|full|raw or comma-separated paths).", + ), dependencies: z.array(z.string()).optional().describe("Filter by dependencies in code summaries"), function_names: z.array(z.string()).optional().describe("Filter by function names"), has_functions: z.boolean().optional().describe("Only files with functions"), diff --git a/src/tools/stats.ts b/src/tools/stats.ts index 34e95d2..bbccbcd 100644 --- a/src/tools/stats.ts +++ b/src/tools/stats.ts @@ -12,7 +12,7 @@ export function registerStatsTools(server: FastMCP): void { name: "code-fundi-usage-stats", description: "Get query-type usage statistics for a given date range. Shows breakdown by query type with counts, credit costs, and average durations.", parameters: z.object({ - range: z.enum(["7d", "30d", "90d"]).optional().describe("Time range per OpenAPI (default: 7d)"), + range: z.string().regex(/^\d+d$/i).optional().describe("Time range in days, e.g. 1d, 7d, 30d, 60d, 90d, 365d"), }), annotations: { title: "Usage Statistics", readOnlyHint: true }, execute: async (args) => { @@ -29,7 +29,7 @@ export function registerStatsTools(server: FastMCP): void { name: "code-fundi-activity-stats", description: "Get daily activity statistics for a given date range. Shows queries per day with credit costs.", parameters: z.object({ - range: z.enum(["7d", "30d", "90d"]).optional().describe("Time range per OpenAPI (default: 7d)"), + range: z.string().regex(/^\d+d$/i).optional().describe("Time range in days, e.g. 1d, 7d, 30d, 60d, 90d, 365d"), }), annotations: { title: "Activity Statistics", readOnlyHint: true }, execute: async (args) => { diff --git a/src/types.ts b/src/types.ts index 03a3467..36a4b26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,19 +9,19 @@ // Common Types // ============================================================================ -export type TierName = "FREE" | "DEV" | "PRO" | "ENTERPRISE"; +export type TierName = "FREE" | "DEV" | "PRO" | "ENTERPRISE" | "ADMIN"; export type SearchScope = "all" | "repos" | "files" | "code" | "functions"; export type ScanMode = "semantic" | "grep_docs" | "grep_code"; -export type FieldsPreset = "basic" | "summary" | "full"; +export type FieldsPreset = "basic" | "summary" | "full" | "raw"; export type SearchFieldsParam = FieldsPreset | string; export type SortOrder = "asc" | "desc"; -/** OpenAPI `range` for `/v2/stats/usage` and `/v2/stats/activity` (7d | 30d | 90d). */ -export type StatsRange = "7d" | "30d" | "90d"; +/** Matches backend parseStatsRange format /^(\d+)d$/i (common values listed). */ +export type StatsRange = "1d" | "7d" | "30d" | "60d" | "90d" | "365d"; export type RepoScope = "private" | "public"; @@ -552,6 +552,59 @@ export interface V2AuthResendResponse { data: Record; } +/** Optional request headers for /v2/auth/* calls. */ +export interface V2AuthClientRequestOptions { + /** Sent as Idempotency-Key. */ + idempotencyKey?: string; + /** Sent as X-Fingerprint for rate-limit identity pairing. */ + fingerprint?: string; + /** + * Header used for password auth mode. + * - x-codefundi-auth-password => X-CodeFundi-Auth-Password (default) + * - x-auth-password => X-Auth-Password + */ + passwordHeader?: "x-codefundi-auth-password" | "x-auth-password"; +} + +// ============================================================================ +// V2 Chat Types +// ============================================================================ + +export interface V2ChatMessage { + role?: "user" | "assistant" | "system"; + content?: string; + [key: string]: unknown; +} + +/** + * V2 chat payload. This is intentionally flexible because deployments may expose + * additional request keys beyond the stable set represented here. + */ +export interface V2ChatRequest { + prompt: string; + model?: string; + conversation_id?: string; + repo_ids?: string[]; + repo_urls?: string[]; + context?: V2ChatMessage[]; + stream?: boolean; + /** + * Optional provider/deployment-specific keys merged into the request body. + * Prefer explicit top-level keys when available. + */ + extra?: Record; +} + +/** Normalized result returned from chatV2 after JSON/NDJSON/text parsing. */ +export interface V2ChatResult { + text: string; + model?: string; + conversationId?: string; + contextFiles?: number; + searchResults?: SearchResult[]; + raw?: unknown; +} + // ============================================================================ // V1 Chat Types (no V2 equivalent) // ============================================================================ diff --git a/tests/client.test.ts b/tests/client.test.ts index 3f53663..ac59a50 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -3,7 +3,11 @@ */ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { CodeFundiClient, CodeFundiApiError } from "../src/client.js"; +import { + CODEFUNDI_DEFAULT_CHAT_MODEL_ID, + CodeFundiClient, + CodeFundiApiError, +} from "../src/client.js"; describe("CodeFundiClient", () => { let client: CodeFundiClient; @@ -200,6 +204,89 @@ describe("CodeFundiClient", () => { vi.unstubAllGlobals(); }); + it("should POST /v2/chat and parse JSON response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + status: "success", + data: { + response: "Hello from v2", + model: "gpt-4o-mini", + conversation_id: "conv_123", + }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const res = await client.chatV2({ prompt: "Hello v2" }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.test.codefundi.app/v2/chat"); + expect(options.method).toBe("POST"); + const body = JSON.parse(options.body as string); + expect(body.prompt).toBe("Hello v2"); + expect(body.model).toBe(CODEFUNDI_DEFAULT_CHAT_MODEL_ID); + expect(res.text).toBe("Hello from v2"); + expect(res.model).toBe("gpt-4o-mini"); + expect(res.conversationId).toBe("conv_123"); + + vi.unstubAllGlobals(); + }); + + it("should parse NDJSON response from /v2/chat", async () => { + const lines = [ + JSON.stringify({ type: "chunk", text: "Hello " }), + JSON.stringify({ type: "chunk", text: "world" }), + JSON.stringify({ type: "done", model: "gpt-4.1", context_files: 0 }), + ].join("\n") + "\n"; + const encoder = new TextEncoder(); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/x-ndjson" }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(lines)); + controller.close(); + }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const res = await client.chatV2({ prompt: "stream me" }); + + expect(res.text).toBe("Hello world"); + expect(res.model).toBe("gpt-4.1"); + expect(res.contextFiles).toBe(0); + vi.unstubAllGlobals(); + }); + + it("should fallback to v1 chat when /v2/chat is unavailable", async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({ status: "error", message: "not found" }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "text/html; charset=utf-8" }), + text: async () => "legacy fallback response", + }); + vi.stubGlobal("fetch", mockFetch); + + const res = await client.chatV2({ prompt: "fallback please" }); + + expect(res.text).toBe("legacy fallback response"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toBe("https://api.test.codefundi.app/v2/chat"); + expect(mockFetch.mock.calls[1][0]).toBe("https://api.test.codefundi.app/v1/fundi/chat"); + + vi.unstubAllGlobals(); + }); + it("should DELETE /v2/keys/{id}", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -278,6 +365,30 @@ describe("CodeFundiClient", () => { vi.unstubAllGlobals(); }); + it("auth authenticate should apply optional auth headers", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "ok", data: { user_id: "u1", email: "a@b.com", session: null, verification_required: true, api_key: null } }), + }); + vi.stubGlobal("fetch", mockFetch); + + await client.authAuthenticate( + { email: "a@b.com", auth_mode: "password", authPassword: "pw123" }, + { + idempotencyKey: "idem-1", + fingerprint: "fp-1", + passwordHeader: "x-auth-password", + }, + ); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers["Idempotency-Key"]).toBe("idem-1"); + expect(options.headers["X-Fingerprint"]).toBe("fp-1"); + expect(options.headers["X-Auth-Password"]).toBe("pw123"); + + vi.unstubAllGlobals(); + }); + it("should encode repo key URL for file paths", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts index 167d8f8..1955e8d 100644 --- a/tests/formatters.test.ts +++ b/tests/formatters.test.ts @@ -9,7 +9,7 @@ import { formatFileList, formatFileDocumentation, formatHistoryList, formatHistoryDetail, formatConversation, formatUsageStats, formatActivityStats, formatLanguageStats, - formatApiKeys, formatModels, formatError, + formatApiKeys, formatModels, formatError, formatV2ChatResult, } from "../src/formatters.js"; import { CodeFundiApiError } from "../src/client.js"; import type { @@ -80,6 +80,28 @@ describe("formatResearchResult", () => { }); }); +describe("formatV2ChatResult", () => { + it("should format text/model/conversation metadata", () => { + const result = formatV2ChatResult({ + text: "Primary answer", + model: "gpt-4.1", + conversationId: "conv_1", + contextFiles: 3, + searchResults: [{ id: "1" } as SearchResult], + }); + expect(result).toContain("Primary answer"); + expect(result).toContain("gpt-4.1"); + expect(result).toContain("conv_1"); + expect(result).toContain("Context files: 3"); + expect(result).toContain("Context sources: 1"); + }); + + it("should handle empty text responses", () => { + const result = formatV2ChatResult({ text: " " }); + expect(result).toContain("No response generated"); + }); +}); + // ==== Repos ==== describe("formatRepoList", () => {