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 <gergomagyar0@gmail.com>
This commit is contained in:
samuel mbabhazi 2026-11-04 15:12:26 +02:00 • committed by GitHub
parent 450702e10c
commit 42bb67cb6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 920 additions and 107 deletions

View file

@ -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 <name>` | 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 <name>` | 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 <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |

View file

@ -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 <name>` | 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 <name>` | 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 <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |

View file

@ -311,6 +311,7 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <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

View file

@ -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 <name>` | 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 <name>` | 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 <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |

View file

@ -349,7 +349,7 @@ export const en = {
'Delete the index left in <repo>/.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',

View file

@ -318,7 +318,7 @@ export const zhCN = {
'help.option.clean.localIndex': '删除此检出迁入共享存储后遗留在 <repo>/.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',

View file

@ -410,7 +410,7 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <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 <model>', 'LLM model or deployment name (default: MiniMax-M3)')
.option(

View file

@ -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 <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);

View file

@ -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<LLMConfig>): 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<LLMConfig>): 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<LLMConfig>): 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<string, unknown> = {
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<string, string> = 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<string, string>;
body: Record<string, unknown>;
}
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<string, unknown> = {
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<string, string> = 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<string, unknown> = {
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<Record<string, unknown>> {
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<Uint8Array>,
onChunk: (charsReceived: number) => void,
maxTokens: number,
protocol: StreamProtocol,
): Promise<LLMResponse> {
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) {

View file

@ -1838,7 +1838,8 @@ export interface CLIConfig {
| 'codex'
| 'opencode'
| 'grok'
| 'minimax';
| 'minimax'
| 'anthropic';
cursorModel?: string;
claudeModel?: string;
codexModel?: string;

View file

@ -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(
{

View file

@ -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<typeof vi.fn>) {
const [url, init] = fetchSpy.mock.calls[0] as [
string,
RequestInit & { headers: Record<string, string> },
];
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<LLMConfig>) {
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<void>((resolve) => target.listen(0, '127.0.0.1', resolve));
await new Promise<void>((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<void>((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<Uint8Array>({
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<Uint8Array>({
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',
);
});
});

View file

@ -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');