fancive_obsidian-parallel-r.../src/providers.ts
wujunchen 447ad1414b feat: add streaming API responses for OpenAI Chat and Anthropic
Implement SSE streaming for API backends that support it (OpenAI Chat
Completions and Anthropic Messages). During generation the view shows
a live streaming preview with accumulated text. Adds a streaming toggle
in settings (enabled by default). Non-streaming-capable formats (Gemini,
OpenAI Responses) and CLI backends continue using the existing path.

Change-Id: I3361a5ca5aec3e25a7d16e4fb585185f6fc0695e
2026-04-26 09:30:51 +08:00

481 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
import { translate } from './i18n';
import {
ANTHROPIC_CARD_TOOL_NAME,
anthropicCardTool,
cardOutputSchema,
normalizeCardsPayload,
openAiJsonSchemaResponseFormat,
openAiResponsesTextFormat,
parseCardsJson,
} from './schema';
import {
API_FORMATS,
getApiAuthType,
getApiBaseUrl,
getApiFormat,
getApiKey,
getApiPreset,
modelForApi,
} from './settings';
import { deltaExtractorForFormat, type StreamProgress, streamingFetch } from './streaming';
import type { PluginSettings, RawCard } from './types';
function endpointUrl(baseUrl: string, suffixes: string[]) {
const base = baseUrl.replace(/\/+$/, '');
for (const suffix of suffixes) {
if (base.endsWith(suffix)) return base;
}
return base + suffixes[0];
}
function parseApiHeaders(raw: string, settings?: PluginSettings | null): Record<string, string> {
const text = (raw || '').trim();
if (!text) return {};
if (text.startsWith('{')) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(text);
} catch (e: any) {
throw new Error(translate(settings || null, 'errorCustomHeadersJsonParse', { error: e.message }));
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(translate(settings || null, 'errorCustomHeadersJsonObject'));
}
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'string' && k.trim()) headers[k.trim()] = v;
}
return headers;
}
const headers: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf(':');
if (idx <= 0) {
throw new Error(translate(settings || null, 'errorCustomHeadersLineFormat'));
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key) headers[key] = value;
}
return headers;
}
function authHeaders(settings: PluginSettings): Record<string, string> {
const authType = getApiAuthType(settings);
if (authType === 'none') return {};
const key = getApiKey(settings);
if (!key) {
const envVar = (settings.apiKeyEnvVar || getApiPreset(settings).envVar || '').trim();
const hint = envVar ? translate(settings, 'errorApiKeyEnvHint', { envVar }) : '';
throw new Error(translate(settings, 'errorApiKeyMissing', { hint }));
}
if (authType === 'bearer') return { authorization: `Bearer ${key}` };
if (authType === 'x-api-key') return { 'x-api-key': key };
if (authType === 'x-goog-api-key') return { 'x-goog-api-key': key };
if (authType === 'api-key') return { 'api-key': key };
return { authorization: `Bearer ${key}` };
}
function buildApiHeaders(settings: PluginSettings, extra?: Record<string, string>): Record<string, string> {
return {
'content-type': 'application/json',
...authHeaders(settings),
...(extra || {}),
...parseApiHeaders(settings.apiHeaders, settings),
};
}
function responseJson(resp: any, label: string, settings?: PluginSettings | null) {
if (resp.json && typeof resp.json === 'object') return resp.json;
try {
return JSON.parse(resp.text || '{}');
} catch (_) {
throw new Error(
translate(settings || null, 'errorProviderNonJson', {
label,
excerpt: (resp.text || '').slice(0, 500),
}),
);
}
}
async function requestJsonBody(
requestUrlImpl: any,
label: string,
url: string,
headers: Record<string, string>,
body: any,
settings?: PluginSettings | null,
) {
let resp: any;
try {
resp = await requestUrlImpl({
url,
method: 'POST',
headers,
body: JSON.stringify(body),
throw: false,
});
} catch (e: any) {
throw new Error(
translate(settings || null, 'errorProviderRequestFailed', {
label,
error: e.message || e,
}),
);
}
if (resp.status >= 400) {
throw new Error(
translate(settings || null, 'errorProviderApiStatus', {
label,
status: resp.status,
excerpt: (resp.text || '').slice(0, 500),
}),
);
}
return responseJson(resp, label, settings);
}
function shouldRetryWithoutStructuredOutput(error: any) {
const message = String(error?.message ? error.message : error);
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message))
return false;
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(
message,
);
}
async function requestJsonBodyWithStructuredFallback(
requestUrlImpl: any,
label: string,
url: string,
headers: Record<string, string>,
structuredBody: any,
fallbackBody: any,
settings?: PluginSettings | null,
) {
try {
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
} catch (e) {
if (!fallbackBody || !shouldRetryWithoutStructuredOutput(e)) throw e;
console.warn(`[parallel-reader] ${label} structured output rejected; retrying without structured output`, e);
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody, settings);
}
}
function textFromContent(content: any): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') return part.text || part.output_text || '';
return '';
})
.join('');
}
if (content && typeof content === 'object') {
return content.text || content.output_text || '';
}
return '';
}
function textFromOpenAiResponses(json: any): string {
if (typeof json.output_text === 'string') return json.output_text;
const parts: string[] = [];
const walk = (value: any) => {
if (!value) return;
if (typeof value === 'string') return;
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (typeof value === 'object') {
if (typeof value.text === 'string') parts.push(value.text);
if (typeof value.output_text === 'string') parts.push(value.output_text);
if (value.type === 'output_text' && typeof value.content === 'string') parts.push(value.content);
if (value.content) walk(value.content);
if (value.output) walk(value.output);
}
};
walk(json.output);
return parts.join('');
}
export function tokenLimitFieldForOpenAiChat(settings: PluginSettings): string {
const preset = getApiPreset(settings);
const format = API_FORMATS[getApiFormat(settings)];
return preset.tokenLimitField || format?.tokenLimitField || 'max_tokens';
}
export function buildAnthropicMessagesBody(
system: string,
user: string,
settings: PluginSettings,
options?: { structured?: boolean },
) {
const structured = !options || options.structured !== false;
const body: any = {
model: modelForApi(settings),
max_tokens: Number(settings.apiMaxTokens) || 4096,
system,
messages: [{ role: 'user', content: user }],
};
if (structured) {
body.tools = [anthropicCardTool()];
body.tool_choice = { type: 'tool', name: ANTHROPIC_CARD_TOOL_NAME };
}
return body;
}
export function buildOpenAiChatBody(
system: string,
user: string,
settings: PluginSettings,
options?: { structured?: boolean },
) {
const structured = !options || options.structured !== false;
const body: any = {
model: modelForApi(settings),
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
};
body[tokenLimitFieldForOpenAiChat(settings)] = Number(settings.apiMaxTokens) || 4096;
if (structured) {
body.response_format = openAiJsonSchemaResponseFormat();
}
return body;
}
export function buildOpenAiResponsesBody(
system: string,
user: string,
settings: PluginSettings,
options?: { structured?: boolean },
) {
const structured = !options || options.structured !== false;
const body: any = {
model: modelForApi(settings),
instructions: system,
input: user,
max_output_tokens: Number(settings.apiMaxTokens) || 4096,
};
if (structured) {
body.text = openAiResponsesTextFormat();
}
return body;
}
export function buildGeminiBody(
system: string,
user: string,
settings: PluginSettings,
options?: { structured?: boolean },
) {
const structured = !options || options.structured !== false;
const generationConfig: any = {
temperature: 0,
maxOutputTokens: Number(settings.apiMaxTokens) || 4096,
};
if (structured) {
generationConfig.responseMimeType = 'application/json';
generationConfig.responseJsonSchema = cardOutputSchema(false);
}
return {
systemInstruction: { parts: [{ text: system }] },
contents: [{ role: 'user', parts: [{ text: user }] }],
generationConfig,
};
}
function cardsFromAnthropicToolUse(json: any, settings?: PluginSettings | null) {
const content = Array.isArray(json?.content) ? json.content : [];
const block = content.find((c: any) => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
if (!block) return null;
if (typeof block.input === 'string') return parseCardsJson(block.input, settings);
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input);
return [];
}
async function summarizeViaAnthropicMessages(
requestUrlImpl: any,
system: string,
user: string,
settings: PluginSettings,
) {
const url = endpointUrl(getApiBaseUrl(settings), ['/messages']);
const json = await requestJsonBodyWithStructuredFallback(
requestUrlImpl,
'Anthropic-compatible',
url,
buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' }),
buildAnthropicMessagesBody(system, user, settings),
buildAnthropicMessagesBody(system, user, settings, { structured: false }),
settings,
);
const toolCards = cardsFromAnthropicToolUse(json, settings);
if (toolCards) return toolCards;
const text = (json.content || [])
.map((c: any) => textFromContent(c))
.join('')
.trim();
return parseCardsJson(text, settings);
}
async function summarizeViaOpenAiChat(requestUrlImpl: any, system: string, user: string, settings: PluginSettings) {
const url = endpointUrl(getApiBaseUrl(settings), ['/chat/completions']);
const json = await requestJsonBodyWithStructuredFallback(
requestUrlImpl,
'OpenAI-compatible Chat',
url,
buildApiHeaders(settings),
buildOpenAiChatBody(system, user, settings),
buildOpenAiChatBody(system, user, settings, { structured: false }),
settings,
);
const choice = (json.choices || [])[0] || {};
const text = textFromContent(choice.message?.content || choice.text || '').trim();
return parseCardsJson(text, settings);
}
async function summarizeViaOpenAiResponses(
requestUrlImpl: any,
system: string,
user: string,
settings: PluginSettings,
) {
const url = endpointUrl(getApiBaseUrl(settings), ['/responses']);
const json = await requestJsonBodyWithStructuredFallback(
requestUrlImpl,
'OpenAI Responses',
url,
buildApiHeaders(settings),
buildOpenAiResponsesBody(system, user, settings),
buildOpenAiResponsesBody(system, user, settings, { structured: false }),
settings,
);
return parseCardsJson(textFromOpenAiResponses(json).trim(), settings);
}
async function summarizeViaGoogleGenerativeAi(
requestUrlImpl: any,
system: string,
user: string,
settings: PluginSettings,
) {
const model = encodeURIComponent(modelForApi(settings));
let url = getApiBaseUrl(settings);
if (!/:generateContent(?:\?|$)/.test(url)) {
url = `${url.replace(/\/+$/, '')}/models/${model}:generateContent`;
}
const headers = buildApiHeaders(settings);
const json = await requestJsonBodyWithStructuredFallback(
requestUrlImpl,
'Google Gemini',
url,
headers,
buildGeminiBody(system, user, settings),
buildGeminiBody(system, user, settings, { structured: false }),
settings,
);
const candidate = (json.candidates || [])[0] || {};
const parts = candidate.content?.parts || [];
const text = parts
.map((p: any) => textFromContent(p))
.join('')
.trim();
return parseCardsJson(text, settings);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
export async function summarizeViaApi(
requestUrlImpl: any,
system: string,
user: string,
settings: PluginSettings,
): Promise<RawCard[]> {
const format = getApiFormat(settings);
switch (format) {
case 'openai-chat':
return summarizeViaOpenAiChat(requestUrlImpl, system, user, settings);
case 'openai-responses':
return summarizeViaOpenAiResponses(requestUrlImpl, system, user, settings);
case 'google-generative-ai':
return summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, settings);
default:
return summarizeViaAnthropicMessages(requestUrlImpl, system, user, settings);
}
}
export function supportsStreaming(settings: PluginSettings): boolean {
if (!settings.streaming) return false;
const format = getApiFormat(settings);
return !!deltaExtractorForFormat(format);
}
async function streamSummarizeViaOpenAiChat(
system: string,
user: string,
settings: PluginSettings,
onProgress?: (progress: StreamProgress) => void,
signal?: AbortSignal,
) {
const url = endpointUrl(getApiBaseUrl(settings), ['/chat/completions']);
const headers = buildApiHeaders(settings);
const body = buildOpenAiChatBody(system, user, settings, { structured: false });
body.stream = true;
const extractor = deltaExtractorForFormat('openai-chat')!;
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings);
return parseCardsJson(text.trim(), settings);
}
async function streamSummarizeViaAnthropicMessages(
system: string,
user: string,
settings: PluginSettings,
onProgress?: (progress: StreamProgress) => void,
signal?: AbortSignal,
) {
const url = endpointUrl(getApiBaseUrl(settings), ['/messages']);
const headers = buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' });
const body = buildAnthropicMessagesBody(system, user, settings, { structured: false });
body.stream = true;
const extractor = deltaExtractorForFormat('anthropic-messages')!;
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings);
return parseCardsJson(text.trim(), settings);
}
export async function summarizeViaApiStreaming(
system: string,
user: string,
settings: PluginSettings,
onProgress?: (progress: StreamProgress) => void,
signal?: AbortSignal,
): Promise<RawCard[]> {
const format = getApiFormat(settings);
switch (format) {
case 'openai-chat':
return streamSummarizeViaOpenAiChat(system, user, settings, onProgress, signal);
case 'anthropic-messages':
return streamSummarizeViaAnthropicMessages(system, user, settings, onProgress, signal);
default:
throw new Error(`Streaming not supported for format: ${format}`);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
export async function testApiBackend(requestUrlImpl: any, settings: PluginSettings): Promise<string> {
await summarizeViaApi(requestUrlImpl, '只输出 JSON{"cards":[]}', '连通性测试:请原样输出 {"cards":[]}', settings);
const format = getApiFormat(settings);
return `${getApiPreset(settings).label} / ${API_FORMATS[format].label}`;
}