mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat(llm-client): add anthropic provider for Claude Messages API
Adds 'anthropic' to LLMProvider so users can point gitnexus at
api.anthropic.com (or any Anthropic-protocol proxy / gateway) without
running an OpenAI-shim like LiteLLM.
Changes in src/core/wiki/llm-client.ts:
- LLMProvider union gains 'anthropic'.
- Adds DEFAULT_ANTHROPIC_VERSION constant ('2023-06-01') and
LLMConfig.anthropicVersion override.
- buildRequestUrl: routes to '/messages' when provider === 'anthropic'.
- callLLM: branches headers (x-api-key + anthropic-version),
request body (top-level system, max_tokens, content blocks),
and response parsing (content[].text, usage.input_tokens /
output_tokens).
- New readAnthropicSSEStream parser handles message_start /
content_block_delta / message_delta / error events.
Changes in src/storage/repo-manager.ts:
- CLIConfig provider union gains 'anthropic'.
- New optional CLIConfig.anthropicVersion field.
Existing openai / openrouter / azure / cursor / custom paths are
untouched.
Resolves usage of native Anthropic endpoints from the wiki CLI.
refactor(repo-manager): import LLMProvider instead of inlining the union
Removes the duplicated 'openai' | 'openrouter' | 'azure' | 'anthropic' |
'custom' | 'cursor' literal in CLIConfig and re-uses the single source
of truth in llm-client.ts via a type-only import. Future provider
additions only need one edit.
This commit is contained in:
parent
68e4a5aece
commit
b118464d40
6 changed files with 488 additions and 60 deletions
|
|
@ -707,7 +707,7 @@ gitnexus wiki
|
|||
|
||||
# Use a custom model or provider
|
||||
gitnexus wiki --model gpt-4o
|
||||
gitnexus wiki --base-url https://api.anthropic.com/v1
|
||||
gitnexus wiki --provider anthropic --base-url https://api.anthropic.com --model claude-sonnet-4-5
|
||||
|
||||
# Force full regeneration
|
||||
gitnexus wiki --force
|
||||
|
|
|
|||
|
|
@ -133,11 +133,14 @@ program
|
|||
.command('wiki [path]')
|
||||
.description('Generate repository wiki from knowledge graph')
|
||||
.option('-f, --force', 'Force full regeneration even if up to date')
|
||||
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
|
||||
.option(
|
||||
'--provider <provider>',
|
||||
'LLM provider: openai, openrouter, azure, anthropic, custom, or cursor (default: openai)',
|
||||
)
|
||||
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
||||
.option(
|
||||
'--base-url <url>',
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1. Anthropic: https://api.anthropic.com',
|
||||
)
|
||||
.option('--api-key <key>', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)')
|
||||
.option(
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
} else {
|
||||
console.log(" No LLM configured. Let's set it up.\n");
|
||||
console.log(
|
||||
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n',
|
||||
' Supports OpenAI, OpenRouter, Azure, Anthropic, any OpenAI-compatible API, or Cursor CLI.\n',
|
||||
);
|
||||
|
||||
// Check if Cursor CLI is available
|
||||
|
|
@ -203,12 +203,14 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
console.log(' [2] OpenRouter (openrouter.ai)');
|
||||
console.log(' [3] Azure OpenAI');
|
||||
console.log(' [4] Custom endpoint');
|
||||
const anthropicChoice = hasCursor ? '6' : '5';
|
||||
if (hasCursor) {
|
||||
console.log(' [5] Cursor CLI (local, uses your Cursor subscription)');
|
||||
}
|
||||
console.log(` [${anthropicChoice}] Anthropic (api.anthropic.com)`);
|
||||
console.log('');
|
||||
|
||||
const maxChoice = hasCursor ? '5' : '4';
|
||||
const maxChoice = anthropicChoice;
|
||||
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
||||
|
||||
let baseUrl: string;
|
||||
|
|
@ -292,6 +294,54 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
model: deploymentName,
|
||||
provider: 'azure',
|
||||
};
|
||||
} else if (choice === anthropicChoice) {
|
||||
// Anthropic — fixed base URL, prompt for model + key
|
||||
const anthropicBaseUrl = 'https://api.anthropic.com';
|
||||
const defaultAnthropicModel = 'claude-sonnet-4-5';
|
||||
|
||||
const modelInput = await prompt(` Model (default: ${defaultAnthropicModel}): `);
|
||||
const model = modelInput || defaultAnthropicModel;
|
||||
|
||||
// API key — prefer ANTHROPIC_API_KEY, fall back to generic GitNexus/OpenAI vars
|
||||
const envKey =
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.GITNEXUS_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
'';
|
||||
let anthropicKey: string;
|
||||
if (envKey) {
|
||||
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
||||
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
||||
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
||||
anthropicKey = envKey;
|
||||
} else {
|
||||
anthropicKey = await prompt(' API key: ', true);
|
||||
}
|
||||
} else {
|
||||
anthropicKey = await prompt(' API key: ', true);
|
||||
}
|
||||
|
||||
if (!anthropicKey) {
|
||||
console.log('\n No key provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await saveCLIConfig({
|
||||
apiKey: anthropicKey,
|
||||
baseUrl: anthropicBaseUrl,
|
||||
model,
|
||||
provider: 'anthropic',
|
||||
});
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = {
|
||||
...llmConfig,
|
||||
apiKey: anthropicKey,
|
||||
baseUrl: anthropicBaseUrl,
|
||||
model,
|
||||
provider: 'anthropic',
|
||||
};
|
||||
} else {
|
||||
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
|
||||
if (choice === '2') {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
* Config priority: CLI flags > env vars > defaults
|
||||
*/
|
||||
|
||||
export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'anthropic' | 'custom' | 'cursor';
|
||||
|
||||
/** Anthropic API version sent in the `anthropic-version` header. */
|
||||
export const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
export interface LLMConfig {
|
||||
apiKey: string;
|
||||
|
|
@ -16,9 +19,11 @@ export interface LLMConfig {
|
|||
maxTokens: number;
|
||||
temperature: number;
|
||||
/** Provider type — controls auth header behaviour */
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
provider?: LLMProvider;
|
||||
/** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */
|
||||
apiVersion?: string;
|
||||
/** Anthropic API version (e.g. '2023-06-01'). Sent as `anthropic-version` header when provider is 'anthropic'. */
|
||||
anthropicVersion?: string;
|
||||
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
|
||||
isReasoningModel?: boolean;
|
||||
}
|
||||
|
|
@ -64,6 +69,10 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
|
|||
provider: overrides?.provider ?? savedConfig.provider ?? 'openai',
|
||||
apiVersion:
|
||||
overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion,
|
||||
anthropicVersion:
|
||||
overrides?.anthropicVersion ||
|
||||
process.env.GITNEXUS_ANTHROPIC_VERSION ||
|
||||
savedConfig.anthropicVersion,
|
||||
isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
|
||||
};
|
||||
}
|
||||
|
|
@ -102,10 +111,25 @@ export function isReasoningModel(model: string, override?: boolean): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the full chat completions URL, appending ?api-version when provided.
|
||||
* Build the full request URL.
|
||||
*
|
||||
* - For Anthropic: `${baseUrl}/v1/messages`. The `/v1` segment is auto-prepended when
|
||||
* the base URL lacks any `/vN` version segment, so a user can configure
|
||||
* `https://api.anthropic.com` and still hit the correct endpoint.
|
||||
* - For OpenAI-compatible providers: `${baseUrl}/chat/completions`, with optional Azure
|
||||
* `?api-version=` query param when provided.
|
||||
*/
|
||||
export function buildRequestUrl(baseUrl: string, apiVersion: string | undefined): string {
|
||||
const base = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
||||
export function buildRequestUrl(
|
||||
baseUrl: string,
|
||||
apiVersion: string | undefined,
|
||||
provider?: LLMProvider,
|
||||
): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, '');
|
||||
if (provider === 'anthropic') {
|
||||
const versioned = /\/v\d+$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||
return `${versioned}/messages`;
|
||||
}
|
||||
const base = `${trimmed}/chat/completions`;
|
||||
return apiVersion ? `${base}?api-version=${encodeURIComponent(apiVersion)}` : base;
|
||||
}
|
||||
|
||||
|
|
@ -124,14 +148,9 @@ export async function callLLM(
|
|||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
// Detect Azure endpoint (by provider field or URL pattern)
|
||||
const azure = config.provider === 'azure' || isAzureProvider(config.baseUrl);
|
||||
const anthropic = config.provider === 'anthropic';
|
||||
// Detect Azure endpoint (by provider field or URL pattern). Anthropic short-circuits Azure.
|
||||
const azure = !anthropic && (config.provider === 'azure' || isAzureProvider(config.baseUrl));
|
||||
|
||||
// Warn when using Azure legacy deployment URL without api-version
|
||||
if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) {
|
||||
|
|
@ -143,29 +162,56 @@ export async function callLLM(
|
|||
// Detect reasoning model (o1, o3, o4-mini etc.) or explicit override
|
||||
const reasoning = isReasoningModel(config.model, config.isReasoningModel);
|
||||
|
||||
const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined);
|
||||
const url = buildRequestUrl(
|
||||
config.baseUrl,
|
||||
azure ? config.apiVersion : undefined,
|
||||
config.provider,
|
||||
);
|
||||
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,
|
||||
};
|
||||
// Build request body — Anthropic uses a different shape than OpenAI-compatible APIs.
|
||||
let body: Record<string, unknown>;
|
||||
if (anthropic) {
|
||||
body = {
|
||||
model: config.model,
|
||||
max_tokens: config.maxTokens,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
};
|
||||
if (systemPrompt) body.system = systemPrompt;
|
||||
if (config.temperature !== undefined) body.temperature = config.temperature;
|
||||
if (useStream) body.stream = true;
|
||||
} else {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
// max_tokens is deprecated; use max_completion_tokens for all models
|
||||
body.max_completion_tokens = config.maxTokens;
|
||||
body = {
|
||||
model: config.model,
|
||||
messages,
|
||||
};
|
||||
|
||||
// Only send temperature for non-Azure providers — some Azure models reject non-default values
|
||||
if (!reasoning && !azure && config.temperature !== undefined) {
|
||||
body.temperature = config.temperature;
|
||||
// max_tokens is deprecated; use max_completion_tokens for all OpenAI-compatible 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;
|
||||
}
|
||||
|
||||
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}` };
|
||||
// Build auth headers — provider determines header style.
|
||||
const authHeaders: Record<string, string> = anthropic
|
||||
? {
|
||||
'x-api-key': config.apiKey,
|
||||
'anthropic-version': config.anthropicVersion || DEFAULT_ANTHROPIC_VERSION,
|
||||
}
|
||||
: azure
|
||||
? { 'api-key': config.apiKey }
|
||||
: { Authorization: `Bearer ${config.apiKey}` };
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: Error | null = null;
|
||||
|
|
@ -213,13 +259,32 @@ export async function callLLM(
|
|||
throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
// Streaming path
|
||||
// Streaming path — same reader, provider-specific event parser
|
||||
if (useStream && response.body) {
|
||||
return await readSSEStream(response.body, options!.onChunk!);
|
||||
const parse = anthropic ? parseAnthropicSSEEvent : parseOpenAISSEEvent;
|
||||
return await readSSEStream(response.body, options!.onChunk!, parse);
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
if (anthropic) {
|
||||
const text = Array.isArray(json.content)
|
||||
? json.content
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { text: string }) => b.text)
|
||||
.join('')
|
||||
: '';
|
||||
if (!text) {
|
||||
throw new Error('LLM returned empty response');
|
||||
}
|
||||
return {
|
||||
content: text,
|
||||
promptTokens: json.usage?.input_tokens,
|
||||
completionTokens: json.usage?.output_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
if (!choice?.message?.content) {
|
||||
throw new Error('LLM returned empty response');
|
||||
|
|
@ -250,17 +315,90 @@ export async function callLLM(
|
|||
}
|
||||
|
||||
/**
|
||||
* Read an SSE stream from an OpenAI-compatible streaming response.
|
||||
* Outcome a provider-specific parser may report for one SSE event.
|
||||
*
|
||||
* The shared {@link readSSEStream} loop accumulates `delta` text, tracks token
|
||||
* counts, throws immediately on `error`, and throws after stream end if a
|
||||
* `refusalReason` was set — independent of the wire format.
|
||||
*/
|
||||
interface SSEEventResult {
|
||||
/** Text fragment to append to the accumulated content. */
|
||||
delta?: string;
|
||||
/** Prompt/input token count, when reported in this event. */
|
||||
inputTokens?: number;
|
||||
/** Completion/output token count, when reported in this event. */
|
||||
outputTokens?: number;
|
||||
/**
|
||||
* If set, the stream is treated as refused/blocked: any `delta` on this event
|
||||
* is dropped and the reader throws this message after the stream finishes.
|
||||
*/
|
||||
refusalReason?: string;
|
||||
/** If set, the reader throws this message immediately. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type SSEEventParser = (event: any) => SSEEventResult;
|
||||
|
||||
/** Parse one OpenAI-compatible streaming event (`choices[].delta.content`). */
|
||||
function parseOpenAISSEEvent(event: any): SSEEventResult {
|
||||
const choice = event?.choices?.[0];
|
||||
if (choice?.finish_reason === 'content_filter') {
|
||||
return {
|
||||
refusalReason:
|
||||
'content filter triggered mid-stream. The generated content was blocked by content policy. Adjust your prompt and retry.',
|
||||
};
|
||||
}
|
||||
const delta = choice?.delta?.content;
|
||||
return delta ? { delta } : {};
|
||||
}
|
||||
|
||||
/** Parse one Anthropic Messages streaming event (`message_start`/`content_block_delta`/`message_delta`/`error`). */
|
||||
function parseAnthropicSSEEvent(event: any): SSEEventResult {
|
||||
switch (event?.type) {
|
||||
case 'message_start':
|
||||
return { inputTokens: event.message?.usage?.input_tokens };
|
||||
case 'content_block_delta':
|
||||
if (event.delta?.type === 'text_delta' && typeof event.delta.text === 'string') {
|
||||
return { delta: event.delta.text };
|
||||
}
|
||||
return {};
|
||||
case 'message_delta': {
|
||||
const result: SSEEventResult = {};
|
||||
if (event.usage?.output_tokens !== undefined) {
|
||||
result.outputTokens = event.usage.output_tokens;
|
||||
}
|
||||
if (event.delta?.stop_reason === 'refusal') {
|
||||
result.refusalReason =
|
||||
'Anthropic refused to generate content for this prompt. Adjust your prompt and retry.';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
error: `Anthropic streaming error: ${event.error?.message || JSON.stringify(event.error)}`,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an SSE stream and accumulate `delta` text returned by the provider-specific
|
||||
* `parse` function. Provider differences live entirely in `parse`; the loop, buffer
|
||||
* handling, refusal/error semantics, and final response shape are shared.
|
||||
*/
|
||||
async function readSSEStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onChunk: (charsReceived: number) => void,
|
||||
parse: SSEEventParser,
|
||||
): Promise<LLMResponse> {
|
||||
const decoder = new TextDecoder();
|
||||
const reader = body.getReader();
|
||||
let content = '';
|
||||
let buffer = '';
|
||||
let contentFilterTriggered = false;
|
||||
let inputTokens: number | undefined;
|
||||
let outputTokens: number | undefined;
|
||||
let refusalReason: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
|
@ -276,38 +414,41 @@ async function readSSEStream(
|
|||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
let event: unknown;
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = choice?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
onChunk(content.length);
|
||||
}
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
// Skip malformed SSE chunks
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = parse(event);
|
||||
|
||||
if (result.error) throw new Error(result.error);
|
||||
|
||||
if (result.refusalReason) {
|
||||
// Latch the first refusal; drop any delta on this event
|
||||
refusalReason = refusalReason || result.refusalReason;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.inputTokens !== undefined) inputTokens = result.inputTokens;
|
||||
if (result.outputTokens !== undefined) outputTokens = result.outputTokens;
|
||||
|
||||
if (result.delta) {
|
||||
content += result.delta;
|
||||
onChunk(content.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentFilterTriggered) {
|
||||
throw new Error(
|
||||
'content filter triggered mid-stream. The generated content was blocked by content policy. Adjust your prompt and retry.',
|
||||
);
|
||||
}
|
||||
if (refusalReason) throw new Error(refusalReason);
|
||||
|
||||
if (!content) {
|
||||
throw new Error('LLM returned empty streaming response');
|
||||
}
|
||||
|
||||
return { content };
|
||||
return { content, promptTokens: inputTokens, completionTokens: outputTokens };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { realpathSync } from 'fs';
|
|||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
||||
import type { LLMProvider } from '../core/wiki/llm-client.js';
|
||||
|
||||
/**
|
||||
* Normalise a repo path for registry comparison across platforms
|
||||
|
|
@ -849,10 +850,12 @@ export interface CLIConfig {
|
|||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
provider?: LLMProvider;
|
||||
cursorModel?: string;
|
||||
/** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */
|
||||
apiVersion?: string;
|
||||
/** Anthropic API version (e.g. '2023-06-01'). Only used when provider is 'anthropic'. */
|
||||
anthropicVersion?: string;
|
||||
/** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */
|
||||
isReasoningModel?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,27 @@ describe('buildRequestUrl', () => {
|
|||
'https://myres.openai.azure.com/openai/v1/chat/completions',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-prepends /v1 for Anthropic when base URL has no version segment', () => {
|
||||
expect(buildRequestUrl('https://api.anthropic.com', undefined, 'anthropic')).toBe(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash and auto-prepends /v1 for Anthropic', () => {
|
||||
expect(buildRequestUrl('https://api.anthropic.com/', undefined, 'anthropic')).toBe(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps existing /v1 segment for Anthropic without doubling', () => {
|
||||
expect(buildRequestUrl('https://api.anthropic.com/v1', undefined, 'anthropic')).toBe(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
);
|
||||
expect(buildRequestUrl('https://api.anthropic.com/v1/', undefined, 'anthropic')).toBe(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLLM — auth header', () => {
|
||||
|
|
@ -330,3 +351,213 @@ describe('readSSEStream — content_filter handling', () => {
|
|||
).rejects.toThrow('content filter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLLM — Anthropic provider', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('uses x-api-key + anthropic-version headers and hits /v1/messages', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
content: [{ type: 'text', text: 'hi from claude' }],
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
const res = await callLLM(
|
||||
'hello',
|
||||
{
|
||||
apiKey: 'sk-ant-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxTokens: 256,
|
||||
temperature: 0,
|
||||
provider: 'anthropic',
|
||||
},
|
||||
'be helpful',
|
||||
);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [
|
||||
string,
|
||||
RequestInit & { headers: Record<string, string> },
|
||||
];
|
||||
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect((init.headers as any)['x-api-key']).toBe('sk-ant-test');
|
||||
expect((init.headers as any)['anthropic-version']).toBe('2023-06-01');
|
||||
expect(init.headers['Authorization']).toBeUndefined();
|
||||
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.model).toBe('claude-sonnet-4-5');
|
||||
expect(body.max_tokens).toBe(256);
|
||||
expect(body.max_completion_tokens).toBeUndefined();
|
||||
expect(body.system).toBe('be helpful');
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
|
||||
expect(res.content).toBe('hi from claude');
|
||||
expect(res.promptTokens).toBe(10);
|
||||
expect(res.completionTokens).toBe(5);
|
||||
});
|
||||
|
||||
it('honours configured anthropicVersion override', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ content: [{ type: 'text', text: 'ok' }], usage: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await callLLM('test', {
|
||||
apiKey: 'sk-ant-test',
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxTokens: 100,
|
||||
temperature: 0,
|
||||
provider: 'anthropic',
|
||||
anthropicVersion: '2024-10-22',
|
||||
});
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [
|
||||
string,
|
||||
RequestInit & { headers: Record<string, string> },
|
||||
];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect((init.headers as any)['anthropic-version']).toBe('2024-10-22');
|
||||
});
|
||||
|
||||
it('streams Anthropic typed events and accumulates text deltas', async () => {
|
||||
const streamContent = [
|
||||
'data: {"type":"message_start","message":{"usage":{"input_tokens":7}}}\n\n',
|
||||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}\n\n',
|
||||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":" world"}}\n\n',
|
||||
'data: {"type":"message_delta","usage":{"output_tokens":3},"delta":{"stop_reason":"end_turn"}}\n\n',
|
||||
].join('');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(streamContent));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const chunks: number[] = [];
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
const res = await callLLM(
|
||||
'hi',
|
||||
{
|
||||
apiKey: 'sk-ant-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxTokens: 100,
|
||||
temperature: 0,
|
||||
provider: 'anthropic',
|
||||
},
|
||||
undefined,
|
||||
{ onChunk: (n) => chunks.push(n) },
|
||||
);
|
||||
|
||||
expect(res.content).toBe('Hello world');
|
||||
expect(res.promptTokens).toBe(7);
|
||||
expect(res.completionTokens).toBe(3);
|
||||
expect(chunks).toEqual([5, 11]);
|
||||
});
|
||||
|
||||
it('throws when Anthropic stream stop_reason is refusal', async () => {
|
||||
const streamContent = [
|
||||
'data: {"type":"message_start","message":{"usage":{"input_tokens":4}}}\n\n',
|
||||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}\n\n',
|
||||
'data: {"type":"message_delta","usage":{"output_tokens":1},"delta":{"stop_reason":"refusal"}}\n\n',
|
||||
].join('');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(streamContent));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM(
|
||||
'test',
|
||||
{
|
||||
apiKey: 'sk-ant-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxTokens: 100,
|
||||
temperature: 0,
|
||||
provider: 'anthropic',
|
||||
},
|
||||
undefined,
|
||||
{ onChunk: () => {} },
|
||||
),
|
||||
).rejects.toThrow('Anthropic refused');
|
||||
});
|
||||
|
||||
it('throws immediately on Anthropic error event', async () => {
|
||||
const streamContent = [
|
||||
'data: {"type":"message_start","message":{"usage":{"input_tokens":4}}}\n\n',
|
||||
'data: {"type":"error","error":{"type":"overloaded_error","message":"Service busy"}}\n\n',
|
||||
].join('');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(streamContent));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM(
|
||||
'test',
|
||||
{
|
||||
apiKey: 'sk-ant-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxTokens: 100,
|
||||
temperature: 0,
|
||||
provider: 'anthropic',
|
||||
},
|
||||
undefined,
|
||||
{ onChunk: () => {} },
|
||||
),
|
||||
).rejects.toThrow('Anthropic streaming error: Service busy');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue