From 42bb67cb6a3f6373a8253daeb006bf5063b80fd7 Mon Sep 17 00:00:00 2001 From: samuel mbabhazi <111171386+samuelmbabhazi@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:12:26 +0200 Subject: [PATCH] feat(wiki): add a native anthropic provider for the Messages API (#3816) * feat(wiki): add a native anthropic provider for the Messages API gitnexus wiki could only reach Claude through the Claude Code CLI or an OpenAI-compatible gateway. The anthropic provider calls the Messages API directly: x-api-key auth with ANTHROPIC_API_KEY (then GITNEXUS_API_KEY, never OPENAI_API_KEY), a top-level system prompt, max_tokens, and no sampling parameters. Only text blocks become the page; max_tokens and refusal stops fail the call, like finish_reason "length" does today. readSSEStream now takes a StreamProtocol describing what each data: payload contributes, so chat completions and Messages share one reader (the consolidation asked for on #1404). The chat-completions request is built by the same code as before. Closes #3413 * fix(wiki): accept only https image URLs for the anthropic provider --------- Co-authored-by: Gergo Magyar --- .claude/skills/gitnexus-cli/SKILL.md | 2 +- .../skills/gitnexus-cli/SKILL.md | 2 +- gitnexus/README.md | 1 + gitnexus/skills/gitnexus-cli.md | 2 +- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/wiki.ts | 26 +- gitnexus/src/core/wiki/llm-client.ts | 447 +++++++++++++---- gitnexus/src/storage/repo-manager.ts | 3 +- gitnexus/test/unit/wiki-flags.test.ts | 19 + gitnexus/test/unit/wiki-llm-anthropic.test.ts | 451 ++++++++++++++++++ .../unit/wiki-truncation-recovery.test.ts | 68 +++ 13 files changed, 920 insertions(+), 107 deletions(-) create mode 100644 gitnexus/test/unit/wiki-llm-anthropic.test.ts diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md index 53a581bd6..03d84b36d 100644 --- a/.claude/skills/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -80,7 +80,7 @@ Generates repository documentation from the knowledge graph using an LLM. HTTP p | Flag | Effect | | ------------------- | ----------------------------------------- | | `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language | -| `--provider ` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. | +| `--provider ` | LLM provider: minimax, anthropic, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. `anthropic` calls the Anthropic Messages API with `ANTHROPIC_API_KEY` (or `GITNEXUS_API_KEY` / `--api-key`); default model `claude-opus-5-5`. | | `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 53a581bd6..03d84b36d 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -80,7 +80,7 @@ Generates repository documentation from the knowledge graph using an LLM. HTTP p | Flag | Effect | | ------------------- | ----------------------------------------- | | `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language | -| `--provider ` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. | +| `--provider ` | LLM provider: minimax, anthropic, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. `anthropic` calls the Anthropic Messages API with `ANTHROPIC_API_KEY` (or `GITNEXUS_API_KEY` / `--api-key`); default model `claude-opus-5-5`. | | `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | diff --git a/gitnexus/README.md b/gitnexus/README.md index 4cbc9d66b..d75c91a3c 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -311,6 +311,7 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: minimax/minimax-m2.5) gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no API key) +gitnexus wiki --provider anthropic # Anthropic Messages API (ANTHROPIC_API_KEY; default model claude-opus-5-5) gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local # Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION gitnexus doctor # Show runtime platform capabilities and embedding configuration diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 53a581bd6..03d84b36d 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -80,7 +80,7 @@ Generates repository documentation from the knowledge graph using an LLM. HTTP p | Flag | Effect | | ------------------- | ----------------------------------------- | | `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language | -| `--provider ` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. | +| `--provider ` | LLM provider: minimax, anthropic, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. `anthropic` calls the Anthropic Messages API with `ANTHROPIC_API_KEY` (or `GITNEXUS_API_KEY` / `--api-key`); default model `claude-opus-5-5`. | | `--model ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index f9a3fac43..58105eccc 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -349,7 +349,7 @@ export const en = { 'Delete the index left in /.gitnexus after this checkout moved into a shared store', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': - 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', + 'LLM provider: minimax, anthropic, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', 'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index d5789ca5d..f06374781 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -318,7 +318,7 @@ export const zhCN = { 'help.option.clean.localIndex': '删除此检出迁入共享存储后遗留在 /.gitnexus 中的索引', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)', + 'LLM 提供商:minimax、anthropic、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)', 'help.option.wiki.model': 'LLM 模型或 deployment 名称(默认:MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 0cb3786ae..d89641332 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -410,7 +410,7 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', + 'LLM provider: minimax, anthropic, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', ) .option('--model ', 'LLM model or deployment name (default: MiniMax-M3)') .option( diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 37badb70c..8369ab4ad 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -19,6 +19,8 @@ import { } from '../storage/storage-resolver.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { + ANTHROPIC_BASE_URL, + ANTHROPIC_DEFAULT_MODEL, MINIMAX_MODEL_IDS, MINIMAX_OPENAI_BASE_URLS, parseLLMAllowedInsecureHttpHosts, @@ -252,6 +254,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) updates.model = MINIMAX_MODEL_IDS[0]; } } + if (options.provider === 'anthropic') { + if (!options.baseUrl && (providerChanged || !existing.baseUrl)) { + updates.baseUrl = ANTHROPIC_BASE_URL; + } + if (!options.model && (providerChanged || !existing.model)) { + updates.model = ANTHROPIC_DEFAULT_MODEL; + } + } // Save model to appropriate field based on provider. if (options.model) { const targetProvider = options.provider ?? existing.provider; @@ -304,7 +314,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive mode — need either API key or Cursor CLI if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); - console.log(' Set MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,'); + console.log( + ' Set MINIMAX_API_KEY, ANTHROPIC_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,', + ); console.log( ' or pass --api-key , or use --provider cursor|claude|codex|opencode|grok.\n', ); @@ -314,7 +326,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive with env var or cursor — just use it } else { console.log(" No LLM configured. Let's set it up.\n"); - console.log(' Supports MiniMax, OpenAI-compatible APIs, and local agent CLIs.\n'); + console.log(' Supports MiniMax, Anthropic, OpenAI-compatible APIs, and local agent CLIs.\n'); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); @@ -334,7 +346,8 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) console.log(' [4] Custom endpoint'); console.log(' [5] MiniMax Global (api.minimax.io)'); console.log(' [6] MiniMax China (api.minimaxi.com)'); - let nextChoice = 7; + console.log(' [7] Anthropic (api.anthropic.com)'); + let nextChoice = 8; if (hasCursor) { const choice = String(nextChoice++); localChoices.push({ @@ -482,6 +495,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) choice === '6' ? MINIMAX_OPENAI_BASE_URLS.cn_zh : MINIMAX_OPENAI_BASE_URLS.global_en; defaultModel = MINIMAX_MODEL_IDS[0]; provider = 'minimax'; + } else if (choice === '7') { + baseUrl = ANTHROPIC_BASE_URL; + defaultModel = ANTHROPIC_DEFAULT_MODEL; + provider = 'anthropic'; } else { baseUrl = 'https://api.openai.com/v1'; defaultModel = 'gpt-4o-mini'; @@ -502,8 +519,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // API key — pre-fill hint if env var exists const envKey = (provider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || + (provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : undefined) || process.env.GITNEXUS_API_KEY || - process.env.OPENAI_API_KEY || + (provider === 'anthropic' ? undefined : process.env.OPENAI_API_KEY) || ''; if (envKey) { const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index a635874ea..d3d043473 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -4,7 +4,8 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from ' * LLM Client for Wiki Generation * * OpenAI-compatible API client using native fetch. - * Supports MiniMax and other OpenAI-compatible endpoints. + * Supports MiniMax and other OpenAI-compatible endpoints, and the Anthropic + * Messages API natively (provider 'anthropic'). * * Config priority: CLI flags > env vars > defaults */ @@ -19,7 +20,8 @@ export type LLMProvider = | 'codex' | 'opencode' | 'grok' - | 'minimax'; + | 'minimax' + | 'anthropic'; export const MINIMAX_OPENAI_BASE_URLS = { global_en: 'https://api.minimax.io/v1', @@ -28,6 +30,11 @@ export const MINIMAX_OPENAI_BASE_URLS = { export const MINIMAX_MODEL_IDS = ['MiniMax-M3', 'MiniMax-M2.7'] as const; +export const ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; +export const ANTHROPIC_DEFAULT_MODEL = 'claude-opus-5-5'; +/** `anthropic-version` header sent with every Messages API request. */ +export const ANTHROPIC_API_VERSION = '2023-06-01'; + export type MiniMaxThinkingMode = 'adaptive' | 'disabled' | 'always_on'; export type LLMUserContent = @@ -126,8 +133,11 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< const apiKey = overrides?.apiKey || (savedProvider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || + (savedProvider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : undefined) || (savedProvider !== 'minimax' ? process.env.GITNEXUS_API_KEY : undefined) || - (savedProvider !== 'minimax' ? process.env.OPENAI_API_KEY : undefined) || + (savedProvider !== 'minimax' && savedProvider !== 'anthropic' + ? process.env.OPENAI_API_KEY + : undefined) || (reuseSavedHttpConfig ? savedConfig.apiKey : undefined) || ''; @@ -139,7 +149,9 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< (reuseSavedHttpConfig ? savedConfig.baseUrl : undefined) || (savedProvider === 'minimax' ? MINIMAX_OPENAI_BASE_URLS.global_en - : 'https://openrouter.ai/api/v1'), + : savedProvider === 'anthropic' + ? ANTHROPIC_BASE_URL + : 'https://openrouter.ai/api/v1'), model: overrides?.model || (localProvider ? undefined : process.env.GITNEXUS_MODEL) || @@ -147,7 +159,11 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< (localProvider ? '' : (reuseSavedHttpConfig ? savedConfig.model : undefined) || - (savedProvider === 'minimax' ? MINIMAX_MODEL_IDS[0] : '')), + (savedProvider === 'minimax' + ? MINIMAX_MODEL_IDS[0] + : savedProvider === 'anthropic' + ? ANTHROPIC_DEFAULT_MODEL + : '')), maxTokens: readOverrideMaxCompletionTokens(overrides?.maxTokens) ?? readEnvMaxCompletionTokens(process.env[LLM_MAX_COMPLETION_TOKENS_ENV]) ?? @@ -344,7 +360,15 @@ export interface CallLLMOptions { } /** - * Call an OpenAI-compatible LLM API. + * Build the Anthropic Messages API URL. + */ +export function buildAnthropicMessagesUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/messages`; +} + +/** + * Call the configured LLM: an OpenAI-compatible chat completions API, or the + * Anthropic Messages API when provider is 'anthropic'. * Uses streaming when onChunk callback is provided for real-time progress. * Retries up to 3 times on transient failures (429, 5xx, network errors). */ @@ -357,62 +381,14 @@ export async function callLLM( // Validate base URL before any fetch (CodeQL js/http-to-file-access) validateLLMBaseUrl(config.baseUrl, config.allowedInsecureHttpHosts); - const messages: Array<{ role: string; content: LLMUserContent }> = []; - if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); - } - messages.push({ role: 'user', content: prompt }); - + const anthropic = config.provider === 'anthropic'; // Detect Azure endpoint (by provider field or URL pattern) - const azure = config.provider === 'azure' || isAzureProvider(config.baseUrl); - - // Warn when using Azure legacy deployment URL without api-version - if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) { - logger.warn( - '[gitnexus] Warning: Azure legacy deployment URL detected but no api-version set. Add --api-version 2024-10-21 or use the v1 API format.', - ); - } - - const miniMaxThinkingMode = - config.provider === 'minimax' - ? resolveMiniMaxThinkingMode(config.model, config.isReasoningModel) - : undefined; - - // Detect reasoning models or explicit provider-specific thinking configuration. - const reasoning = miniMaxThinkingMode - ? miniMaxThinkingMode !== 'disabled' - : isReasoningModel(config.model, config.isReasoningModel); - - const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined); + const azure = !anthropic && (config.provider === 'azure' || isAzureProvider(config.baseUrl)); const useStream = !!options?.onChunk; - // Build request body — reasoning models reject temperature and use max_completion_tokens - const body: Record = { - model: config.model, - messages, - }; - - if (miniMaxThinkingMode === 'adaptive' || miniMaxThinkingMode === 'disabled') { - body.thinking = { type: miniMaxThinkingMode }; - } - if (config.provider === 'minimax') { - body.reasoning_split = true; - } - - // max_tokens is deprecated; use max_completion_tokens for all models - body.max_completion_tokens = config.maxTokens; - - // Only send temperature for non-Azure providers — some Azure models reject non-default values - if (!reasoning && !azure && config.temperature !== undefined) { - body.temperature = config.temperature; - } - - if (useStream) body.stream = true; - - // Build auth headers — Azure uses api-key header, everyone else uses Authorization: Bearer - const authHeaders: Record = azure - ? { 'api-key': config.apiKey } - : { Authorization: `Bearer ${config.apiKey}` }; + const { url, headers, body } = anthropic + ? buildAnthropicRequest(prompt, config, systemPrompt, useStream) + : buildChatCompletionsRequest(prompt, config, systemPrompt, useStream, azure); // Network resilience (bounded retries with exponential-backoff jitter, // 5xx + 429 + Retry-After handling, in-process circuit breaker on the @@ -427,9 +403,12 @@ export async function callLLM( method: 'POST', headers: { 'Content-Type': 'application/json', - ...authHeaders, + ...headers, }, body: JSON.stringify(body), + // Fetch preserves x-api-key across origins. Only send credentials and + // source code to the configured endpoint, never a redirect destination. + ...(anthropic ? { redirect: 'manual' as const } : {}), // Request timeout is opt-in for wiki generation. Large local // model runs can legitimately take well over a minute, so the // default runtime path must not impose a hidden 60s ceiling. @@ -485,14 +464,22 @@ export async function callLLM( // Streaming path if (useStream && response.body) { - return await readSSEStream(response.body, options!.onChunk!, config.maxTokens); + return await readSSEStream( + response.body, + options!.onChunk!, + config.maxTokens, + anthropic ? anthropicStream : chatCompletionsStream, + ); } // Non-streaming path const json = (await response.json()) as any; + if (anthropic) { + return readAnthropicMessage(json as AnthropicMessage | null, config.maxTokens); + } const choice = json.choices?.[0]; if (choice?.finish_reason === 'length') { - throw truncatedResponseError(config.maxTokens); + throw truncatedResponseError(config.maxTokens, chatCompletionsStream.truncationReason); } if (!choice?.message?.content) { throw new Error('LLM returned empty response'); @@ -505,21 +492,268 @@ export async function callLLM( }; } -// A response cut off by the completion limit reads like a finished one, and a -// reasoning model can spend the whole limit before the answer starts. -function truncatedResponseError(maxTokens: number): Error { - return new Error( - `LLM response stopped at the completion token limit (${maxTokens}, finish_reason "length") and is incomplete.`, - ); +interface LLMRequest { + url: string; + headers: Record; + body: Record; +} + +function buildChatCompletionsRequest( + prompt: LLMUserContent, + config: LLMConfig, + systemPrompt: string | undefined, + useStream: boolean, + azure: boolean, +): LLMRequest { + const messages: Array<{ role: string; content: LLMUserContent }> = []; + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + + // Warn when using Azure legacy deployment URL without api-version + if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) { + logger.warn( + '[gitnexus] Warning: Azure legacy deployment URL detected but no api-version set. Add --api-version 2024-10-21 or use the v1 API format.', + ); + } + + const miniMaxThinkingMode = + config.provider === 'minimax' + ? resolveMiniMaxThinkingMode(config.model, config.isReasoningModel) + : undefined; + + // Detect reasoning models or explicit provider-specific thinking configuration. + const reasoning = miniMaxThinkingMode + ? miniMaxThinkingMode !== 'disabled' + : isReasoningModel(config.model, config.isReasoningModel); + + const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined); + + // Build request body — reasoning models reject temperature and use max_completion_tokens + const body: Record = { + model: config.model, + messages, + }; + + if (miniMaxThinkingMode === 'adaptive' || miniMaxThinkingMode === 'disabled') { + body.thinking = { type: miniMaxThinkingMode }; + } + if (config.provider === 'minimax') { + body.reasoning_split = true; + } + + // max_tokens is deprecated; use max_completion_tokens for all models + body.max_completion_tokens = config.maxTokens; + + // Only send temperature for non-Azure providers — some Azure models reject non-default values + if (!reasoning && !azure && config.temperature !== undefined) { + body.temperature = config.temperature; + } + + if (useStream) body.stream = true; + + // Build auth headers — Azure uses api-key header, everyone else uses Authorization: Bearer + const headers: Record = azure + ? { 'api-key': config.apiKey } + : { Authorization: `Bearer ${config.apiKey}` }; + + return { url, headers, body }; } /** - * Read an SSE stream from an OpenAI-compatible streaming response. + * Messages API request. The system prompt is a top-level field, and no sampling + * parameter is sent: current Claude models reject temperature, and the wiki's + * temperature 0 is an OpenAI-path default, not a user setting. + */ +function buildAnthropicRequest( + prompt: LLMUserContent, + config: LLMConfig, + systemPrompt: string | undefined, + useStream: boolean, +): LLMRequest { + const body: Record = { + model: config.model, + max_tokens: config.maxTokens, + messages: [{ role: 'user', content: toAnthropicContent(prompt) }], + }; + if (systemPrompt) body.system = systemPrompt; + if (useStream) body.stream = true; + + return { + url: buildAnthropicMessagesUrl(config.baseUrl), + headers: { 'x-api-key': config.apiKey, 'anthropic-version': ANTHROPIC_API_VERSION }, + body, + }; +} + +const ANTHROPIC_IMAGE_DATA_URL = /^data:(image\/(?:png|jpeg|gif|webp));base64,(.+)$/; + +function toAnthropicContent(prompt: LLMUserContent): string | Array> { + if (typeof prompt === 'string') return prompt; + return prompt.map((part) => { + if (part.type === 'text') return { type: 'text', text: part.text }; + if (part.type === 'image_url') { + const dataUrl = ANTHROPIC_IMAGE_DATA_URL.exec(part.image_url.url); + if (dataUrl) { + return { + type: 'image', + source: { type: 'base64', media_type: dataUrl[1], data: dataUrl[2] }, + }; + } + if (/^https:\/\//i.test(part.image_url.url)) { + return { type: 'image', source: { type: 'url', url: part.image_url.url } }; + } + throw new Error( + 'The anthropic provider accepts images as https:// URLs or base64 PNG, JPEG, GIF or WebP data URLs', + ); + } + throw new Error('The anthropic provider does not accept video input'); + }); +} + +/** The fields of a Messages API response the wiki reads. */ +interface AnthropicMessage { + content?: Array<{ type?: string; text?: unknown }>; + stop_reason?: string | null; + usage?: { input_tokens?: number; output_tokens?: number }; +} + +function readAnthropicMessage(json: AnthropicMessage | null, maxTokens: number): LLMResponse { + if (json?.stop_reason === 'max_tokens') { + throw truncatedResponseError(maxTokens, anthropicStream.truncationReason); + } + if (json?.stop_reason === 'refusal') { + throw anthropicRefusalError(); + } + if (json?.stop_reason === 'model_context_window_exceeded') { + throw anthropicContextWindowError(); + } + // Thinking blocks come back alongside the answer; only text blocks are the page. + const content = Array.isArray(json?.content) + ? json.content + .map((block) => + block?.type === 'text' && typeof block.text === 'string' ? block.text : '', + ) + .join('') + : ''; + if (!content) { + throw new Error('LLM returned empty response'); + } + return { + content, + promptTokens: json?.usage?.input_tokens, + completionTokens: json?.usage?.output_tokens, + }; +} + +function anthropicRefusalError(): Error { + return new Error( + 'The model declined this request (stop_reason "refusal"). Adjust the prompt or use another model.', + ); +} + +function anthropicContextWindowError(): Error { + return new Error( + 'LLM response reached the context window limit (stop_reason "model_context_window_exceeded") and is incomplete. Reduce the input size or use a model with a larger context window.', + ); +} + +// A response cut off by the completion limit reads like a finished one, and a +// reasoning model can spend the whole limit before the answer starts. +function truncatedResponseError(maxTokens: number, stopReason: string): Error { + return new Error( + `LLM response stopped at the completion token limit (${maxTokens}, ${stopReason}) and is incomplete.`, + ); +} + +/** What one SSE `data:` payload adds to the response being streamed. */ +interface StreamEvent { + text?: string; + /** Set when the provider stopped for a reason that makes the answer unusable. */ + stop?: 'length' | 'content_filter' | 'refusal' | 'context_window'; + complete?: boolean; +} + +/** How one provider's stream is read. */ +interface StreamProtocol { + /** + * Reads one parsed `data:` payload. Returns undefined for payloads that carry + * neither text nor a stop reason; throws on a stream error. + */ + readEvent: (data: unknown) => StreamEvent | undefined; + /** The provider's own name for a stop at the token limit, quoted in the error. */ + truncationReason: string; + /** When set, EOF before this terminal event means the response is incomplete. */ + completionEvent?: string; +} + +/** OpenAI-compatible chat completions chunks. */ +/** The fields of a chat completions stream chunk the wiki reads. */ +interface ChatCompletionsChunk { + choices?: Array<{ finish_reason?: string | null; delta?: { content?: string | null } }>; +} + +/** The fields of an Anthropic stream event the wiki reads. */ +interface AnthropicStreamPayload { + type?: string; + delta?: { type?: string; text?: unknown; stop_reason?: string | null }; + error?: { type?: string; message?: string }; +} + +const chatCompletionsStream: StreamProtocol = { + readEvent: (payload) => { + const choice = (payload as ChatCompletionsChunk | null)?.choices?.[0]; + // A content-filtered chunk's delta is dropped. + if (choice?.finish_reason === 'content_filter') return { stop: 'content_filter' }; + const text = choice?.delta?.content; + return { + text: text || undefined, + stop: choice?.finish_reason === 'length' ? 'length' : undefined, + }; + }, + truncationReason: 'finish_reason "length"', +}; + +/** Anthropic Messages API events. Thinking deltas are not part of the answer. */ +const anthropicStream: StreamProtocol = { + readEvent: (payload) => { + const data = payload as AnthropicStreamPayload | null; + switch (data?.type) { + case 'content_block_delta': + return data.delta?.type === 'text_delta' && typeof data.delta.text === 'string' + ? { text: data.delta.text } + : undefined; + case 'message_delta': + if (data.delta?.stop_reason === 'max_tokens') return { stop: 'length' }; + if (data.delta?.stop_reason === 'refusal') return { stop: 'refusal' }; + if (data.delta?.stop_reason === 'model_context_window_exceeded') { + return { stop: 'context_window' }; + } + return undefined; + case 'message_stop': + return { complete: true }; + case 'error': + throw new Error( + `LLM API error mid-stream: ${data.error?.type ?? 'error'}: ${data.error?.message ?? 'no message'}`, + ); + default: + return undefined; + } + }, + truncationReason: 'stop_reason "max_tokens"', + completionEvent: 'message_stop', +}; + +/** + * Read an SSE stream, letting the provider's protocol say what each `data:` + * payload contributes. */ async function readSSEStream( body: ReadableStream, onChunk: (charsReceived: number) => void, maxTokens: number, + protocol: StreamProtocol, ): Promise { const decoder = new TextDecoder(); const reader = body.getReader(); @@ -527,43 +761,52 @@ async function readSSEStream( let buffer = ''; let contentFilterTriggered = false; let lengthLimitReached = false; + let refused = false; + let contextWindowExceeded = false; + let complete = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + try { + stream: while (true) { + const { done, value } = await reader.read(); + if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); - if (data === '[DONE]') continue; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + const data = trimmed.slice(5).trimStart(); + if (data === '[DONE]') continue; - try { - const parsed = JSON.parse(data); - const choice = parsed.choices?.[0]; - - // Detect content filter finish reason — skip delta from this chunk - if (choice?.finish_reason === 'content_filter') { - contentFilterTriggered = true; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + // Skip malformed SSE chunks continue; } - if (choice?.finish_reason === 'length') { - lengthLimitReached = true; - } - const delta = choice?.delta?.content; - if (delta) { - content += delta; + const event = protocol.readEvent(parsed); + if (event?.stop === 'content_filter') contentFilterTriggered = true; + if (event?.stop === 'length') lengthLimitReached = true; + if (event?.stop === 'refusal') refused = true; + if (event?.stop === 'context_window') contextWindowExceeded = true; + if (event?.text) { + content += event.text; onChunk(content.length); } - } catch { - // Skip malformed SSE chunks + if (event?.complete) { + complete = true; + break stream; + } } } + } finally { + // Release the HTTP response on protocol errors and terminal events, too. + await reader.cancel().catch(() => {}); + reader.releaseLock(); } if (contentFilterTriggered) { @@ -572,8 +815,20 @@ async function readSSEStream( ); } + if (refused) { + throw anthropicRefusalError(); + } + if (lengthLimitReached) { - throw truncatedResponseError(maxTokens); + throw truncatedResponseError(maxTokens, protocol.truncationReason); + } + + if (contextWindowExceeded) { + throw anthropicContextWindowError(); + } + + if (protocol.completionEvent && !complete) { + throw new Error(`LLM stream ended before ${protocol.completionEvent} and is incomplete.`); } if (!content) { diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 7fb4ac5f9..f2f0086e5 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -1838,7 +1838,8 @@ export interface CLIConfig { | 'codex' | 'opencode' | 'grok' - | 'minimax'; + | 'minimax' + | 'anthropic'; cursorModel?: string; claudeModel?: string; codexModel?: string; diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 1721f504d..745d70de2 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -507,6 +507,25 @@ describe('wikiCommand provider switch persistence', () => { }); }); + it('fills in the Anthropic endpoint and default model when switching to anthropic', async () => { + const saveCLIConfig = await saveProviderSwitch( + { + provider: 'minimax', + apiKey: 'old-minimax-key', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M3', + }, + { provider: 'anthropic', apiKey: 'sk-ant-new' }, + ); + + expect(saveCLIConfig).toHaveBeenCalledWith({ + provider: 'anthropic', + apiKey: 'sk-ant-new', + baseUrl: 'https://api.anthropic.com/v1', + model: 'claude-opus-5-5', + }); + }); + it('saves grok model to grokModel instead of the HTTP model field', async () => { const saveCLIConfig = await saveProviderSwitch( { diff --git a/gitnexus/test/unit/wiki-llm-anthropic.test.ts b/gitnexus/test/unit/wiki-llm-anthropic.test.ts new file mode 100644 index 000000000..365070da1 --- /dev/null +++ b/gitnexus/test/unit/wiki-llm-anthropic.test.ts @@ -0,0 +1,451 @@ +/** + * The 'anthropic' wiki provider talks to the Anthropic Messages API directly + * (#3413): x-api-key auth, a top-level system prompt, max_tokens, and the + * Messages streaming events, through the same SSE reader as the + * OpenAI-compatible providers. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + ANTHROPIC_API_VERSION, + ANTHROPIC_BASE_URL, + ANTHROPIC_DEFAULT_MODEL, + callLLM, + type LLMConfig, +} from '../../src/core/wiki/llm-client.js'; + +const config: LLMConfig = { + apiKey: 'sk-ant-test', + baseUrl: ANTHROPIC_BASE_URL, + model: ANTHROPIC_DEFAULT_MODEL, + maxTokens: 4096, + temperature: 0, + provider: 'anthropic', + maxAttempts: 1, +}; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function sseResponse(events: unknown[]): Response { + const text = events + .map( + (event) => `event: ${(event as { type: string }).type}\ndata: ${JSON.stringify(event)}\n\n`, + ) + .join(''); + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + // Split mid-event so the reader has to carry a partial line over. + const cut = Math.floor(text.length / 2); + controller.enqueue(encoder.encode(text.slice(0, cut))); + controller.enqueue(encoder.encode(text.slice(cut))); + controller.close(); + }, + }), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); +} + +function stubFetch(response: Response) { + const fetchSpy = vi.fn().mockResolvedValue(response); + vi.stubGlobal('fetch', fetchSpy); + return fetchSpy; +} + +function sentRequest(fetchSpy: ReturnType) { + const [url, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + return { url, headers: init.headers, body: JSON.parse(init.body as string) }; +} + +const messageStart = { + type: 'message_start', + message: { id: 'msg_1', type: 'message', role: 'assistant', content: [] }, +}; +const textDelta = (index: number, text: string) => ({ + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text }, +}); +const messageDelta = (stopReason: string) => ({ + type: 'message_delta', + delta: { stop_reason: stopReason }, + usage: { output_tokens: 12 }, +}); + +describe('resolveLLMConfig — anthropic provider', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + async function resolveWithEmptySavedConfig(overrides: Partial) { + vi.spyOn(await import('../../src/storage/repo-manager.js'), 'loadCLIConfig').mockResolvedValue( + {}, + ); + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + return resolveLLMConfig(overrides); + } + + it('defaults to the Anthropic endpoint, the default model and ANTHROPIC_API_KEY', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-env-key'); + vi.stubEnv('GITNEXUS_API_KEY', 'gitnexus-env-key'); + vi.stubEnv('GITNEXUS_LLM_BASE_URL', ''); + vi.stubEnv('GITNEXUS_MODEL', ''); + + const resolved = await resolveWithEmptySavedConfig({ provider: 'anthropic' }); + + expect(resolved).toMatchObject({ + provider: 'anthropic', + apiKey: 'anthropic-env-key', + baseUrl: 'https://api.anthropic.com/v1', + model: 'claude-opus-5-5', + }); + }); + + it('falls back to GITNEXUS_API_KEY but never to OPENAI_API_KEY', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', ''); + vi.stubEnv('OPENAI_API_KEY', 'openai-env-key'); + vi.stubEnv('GITNEXUS_API_KEY', ''); + + expect((await resolveWithEmptySavedConfig({ provider: 'anthropic' })).apiKey).toBe(''); + + vi.stubEnv('GITNEXUS_API_KEY', 'gitnexus-env-key'); + expect((await resolveWithEmptySavedConfig({ provider: 'anthropic' })).apiKey).toBe( + 'gitnexus-env-key', + ); + }); +}); + +describe('callLLM — anthropic provider', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('sends a Messages API request: x-api-key, version header, top-level system, max_tokens', async () => { + const fetchSpy = stubFetch( + jsonResponse({ + content: [{ type: 'text', text: '# Page' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 3 }, + }), + ); + + await callLLM('Document this module', config, 'You write wiki pages.'); + + const { url, headers, body } = sentRequest(fetchSpy); + expect(url).toBe('https://api.anthropic.com/v1/messages'); + expect(headers['x-api-key']).toBe('sk-ant-test'); + expect(headers['anthropic-version']).toBe(ANTHROPIC_API_VERSION); + expect(headers.Authorization).toBeUndefined(); + expect(body).toEqual({ + model: 'claude-opus-5-5', + max_tokens: 4096, + system: 'You write wiki pages.', + messages: [{ role: 'user', content: 'Document this module' }], + }); + }); + + it('honours a base URL override with a trailing slash', async () => { + const fetchSpy = stubFetch( + jsonResponse({ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }), + ); + + await callLLM('p', { ...config, baseUrl: 'https://llm-proxy.example.com/anthropic/v1/' }); + + expect(sentRequest(fetchSpy).url).toBe('https://llm-proxy.example.com/anthropic/v1/messages'); + }); + + it('returns only the text blocks of a response that also carries thinking', async () => { + stubFetch( + jsonResponse({ + content: [ + { type: 'thinking', thinking: '', signature: 'sig' }, + { type: 'text', text: '# Module\n\n' }, + { type: 'text', text: 'Body.' }, + ], + stop_reason: 'end_turn', + usage: { input_tokens: 120, output_tokens: 45 }, + }), + ); + + await expect(callLLM('p', config)).resolves.toEqual({ + content: '# Module\n\nBody.', + promptTokens: 120, + completionTokens: 45, + }); + }); + + it('rejects a response cut off at max_tokens', async () => { + stubFetch( + jsonResponse({ + content: [{ type: 'text', text: '| a | b |\n| -' }], + stop_reason: 'max_tokens', + }), + ); + + await expect(callLLM('p', config)).rejects.toThrow( + 'LLM response stopped at the completion token limit (4096, stop_reason "max_tokens") and is incomplete.', + ); + }); + + it('rejects a refusal instead of writing it as a page', async () => { + stubFetch(jsonResponse({ content: [], stop_reason: 'refusal' })); + + await expect(callLLM('p', config)).rejects.toThrow('stop_reason "refusal"'); + }); + + it.each([false, true])('rejects context-window truncation (streaming: %s)', async (streaming) => { + stubFetch( + streaming + ? sseResponse([ + messageStart, + textDelta(0, 'Incomplete page'), + messageDelta('model_context_window_exceeded'), + { type: 'message_stop' }, + ]) + : jsonResponse({ + content: [{ type: 'text', text: 'Incomplete page' }], + stop_reason: 'model_context_window_exceeded', + }), + ); + + await expect( + callLLM('p', config, undefined, { onChunk: streaming ? vi.fn() : undefined }), + ).rejects.toThrow('stop_reason "model_context_window_exceeded"'); + }); + + it('does not forward the API key or prompt to a redirect target', async () => { + const receivedKeys: unknown[] = []; + const target = createServer((request, response) => { + receivedKeys.push(request.headers['x-api-key']); + request.resume(); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + content: [{ type: 'text', text: 'redirected' }], + stop_reason: 'end_turn', + }), + ); + }); + const endpoint = createServer((request, response) => { + request.resume(); + response.writeHead(307, { + Location: `http://127.0.0.1:${(target.address() as AddressInfo).port}/messages`, + }); + response.end(); + }); + try { + await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve)); + await new Promise((resolve) => endpoint.listen(0, '127.0.0.1', resolve)); + const result = await callLLM('private source code', { + ...config, + baseUrl: `http://127.0.0.1:${(endpoint.address() as AddressInfo).port}/v1`, + }).catch((error: unknown) => error); + + expect(receivedKeys).toEqual([]); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toContain('307'); + } finally { + await Promise.all( + [target, endpoint].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); + } + }); + + it('converts image parts and refuses video', async () => { + const fetchSpy = stubFetch( + jsonResponse({ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }), + ); + + await callLLM( + [ + { type: 'text', text: 'Describe these.' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } }, + { type: 'image_url', image_url: { url: 'https://example.com/diagram.png' } }, + ], + config, + ); + + expect(sentRequest(fetchSpy).body.messages[0].content).toEqual([ + { type: 'text', text: 'Describe these.' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' }, + }, + { type: 'image', source: { type: 'url', url: 'https://example.com/diagram.png' } }, + ]); + + await expect( + callLLM([{ type: 'video_url', video_url: { url: 'https://example.com/demo.mp4' } }], config), + ).rejects.toThrow('does not accept video input'); + await expect( + callLLM( + [{ type: 'image_url', image_url: { url: 'http://example.com/diagram.png' } }], + config, + ), + ).rejects.toThrow('accepts images as https:// URLs'); + }); +}); + +describe('callLLM — anthropic streaming', () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each([false, true])( + 'rejects EOF without message_stop (stop reason received: %s)', + async (hasStopReason) => { + stubFetch( + sseResponse([ + messageStart, + textDelta(0, 'Incomplete page'), + ...(hasStopReason ? [messageDelta('end_turn')] : []), + ]), + ); + + await expect(callLLM('p', config, undefined, { onChunk: vi.fn() })).rejects.toThrow( + 'message_stop', + ); + }, + ); + + it('cancels and unlocks a response when a stream error interrupts reading', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n', + ), + ); + }, + cancel, + }); + stubFetch(new Response(body)); + + await expect(callLLM('p', config, undefined, { onChunk: vi.fn() })).rejects.toThrow( + 'Overloaded', + ); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }); + + it('accepts SSE data fields without optional whitespace', async () => { + const events = [ + messageStart, + textDelta(0, 'Complete page'), + messageDelta('end_turn'), + { type: 'message_stop' }, + ]; + stubFetch( + new Response(events.map((event) => `data:${JSON.stringify(event)}\r\n\r\n`).join('')), + ); + + await expect(callLLM('p', config, undefined, { onChunk: vi.fn() })).resolves.toEqual({ + content: 'Complete page', + }); + }); + + it('finishes at message_stop without waiting for the server to close the connection', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + const events = [ + textDelta(0, 'Complete page'), + messageDelta('end_turn'), + { type: 'message_stop' }, + ]; + controller.enqueue( + new TextEncoder().encode( + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), + ), + ); + }, + cancel, + }); + stubFetch(new Response(body)); + + await expect(callLLM('p', config, undefined, { onChunk: vi.fn() })).resolves.toEqual({ + content: 'Complete page', + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }, 1000); + + it('streams the text deltas and skips thinking deltas', async () => { + const fetchSpy = stubFetch( + sseResponse([ + messageStart, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'plan' }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'ping' }, + { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }, + textDelta(1, '# Module'), + textDelta(1, '\n\nBody.'), + { type: 'content_block_stop', index: 1 }, + messageDelta('end_turn'), + { type: 'message_stop' }, + ]), + ); + const progress: number[] = []; + + const result = await callLLM('p', config, undefined, { onChunk: (n) => progress.push(n) }); + + expect(result.content).toBe('# Module\n\nBody.'); + expect(progress).toEqual([8, 15]); + expect(sentRequest(fetchSpy).body.stream).toBe(true); + }); + + it('rejects a stream that stops at max_tokens', async () => { + stubFetch( + sseResponse([messageStart, textDelta(0, '| a | b |\n| -'), messageDelta('max_tokens')]), + ); + + await expect(callLLM('p', config, undefined, { onChunk: () => {} })).rejects.toThrow( + 'stop_reason "max_tokens"', + ); + }); + + it('rejects a stream that ends in a refusal', async () => { + stubFetch(sseResponse([messageStart, messageDelta('refusal')])); + + await expect(callLLM('p', config, undefined, { onChunk: () => {} })).rejects.toThrow( + 'stop_reason "refusal"', + ); + }); + + it('surfaces an error event sent mid-stream', async () => { + stubFetch( + sseResponse([ + messageStart, + textDelta(0, 'partial'), + { type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }, + ]), + ); + + await expect(callLLM('p', config, undefined, { onChunk: () => {} })).rejects.toThrow( + 'LLM API error mid-stream: overloaded_error: Overloaded', + ); + }); +}); diff --git a/gitnexus/test/unit/wiki-truncation-recovery.test.ts b/gitnexus/test/unit/wiki-truncation-recovery.test.ts index ad54926eb..66e39e4e2 100644 --- a/gitnexus/test/unit/wiki-truncation-recovery.test.ts +++ b/gitnexus/test/unit/wiki-truncation-recovery.test.ts @@ -133,6 +133,74 @@ describe('wiki generation after a completion-token limit', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it.each(['interrupted', 'context-window'] as const)( + 'retries an incomplete Anthropic page after %s without saving partial output', + async (failure) => { + const anthropicResponse = (content: string, stopReason: string | null): Response => { + const events: unknown[] = [ + { type: 'content_block_delta', delta: { type: 'text_delta', text: content } }, + ]; + if (stopReason !== null) { + events.push( + { type: 'message_delta', delta: { stop_reason: stopReason } }, + { type: 'message_stop' }, + ); + } + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }; + const anthropicGenerator = () => + new WikiGenerator( + repoPath, + storagePath, + '/unused/lbug', + { + apiKey: 'test', + baseUrl: 'https://wiki.example.test/v1', + model: 'test-model', + maxTokens: 4096, + temperature: 0, + provider: 'anthropic', + }, + { concurrency: 1 }, + ); + fetchMock.mockImplementation(async () => + anthropicResponse('Complete documentation.', 'end_turn'), + ); + fetchMock.mockResolvedValueOnce( + anthropicResponse( + 'Incomplete page', + failure === 'interrupted' ? null : 'model_context_window_exceeded', + ), + ); + + expect(await anthropicGenerator().run()).toEqual({ + pagesGenerated: 2, + mode: 'full', + failedModules: ['Billing'], + }); + await expect(fs.readFile(path.join(wikiPath, 'billing.md'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect((await metadata()).failedModules).toEqual(['Billing']); + const shippingBefore = await fs.readFile(path.join(wikiPath, 'shipping.md'), 'utf8'); + fetchMock.mockClear(); + + expect(await anthropicGenerator().run()).toEqual({ + pagesGenerated: 2, + mode: 'incremental', + failedModules: [], + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await fs.readFile(path.join(wikiPath, 'billing.md'), 'utf8')).toContain( + 'Complete documentation.', + ); + expect(await fs.readFile(path.join(wikiPath, 'shipping.md'), 'utf8')).toBe(shippingBefore); + expect((await metadata()).failedModules).toEqual([]); + }, + ); + it('retries a truncated incremental page after the new commit was recorded', async () => { await generator().run(); await fs.writeFile(path.join(repoPath, 'billing.ts'), 'export const bill = 2;\n');