refactor: extract provider response parsers

Change-Id: I6884999f34594faec349378deb59e68972e07355
This commit is contained in:
wujunchen 2026-04-26 18:20:49 +08:00
parent 6dd7ef15a4
commit 70f9a39cd7
6 changed files with 168 additions and 98 deletions

44
main.js

File diff suppressed because one or more lines are too long

14
main.ts
View file

@ -31,6 +31,14 @@ import { translate } from './src/i18n';
import { cardsToMarkdown } from './src/markdown';
import { activeSectionLine, nextCardIndex } from './src/navigation';
import { buildPrompts } from './src/prompt';
import {
cardsFromAnthropicToolUse,
textFromAnthropicMessagesResponse,
textFromGoogleGenerativeAiResponse,
textFromOpenAiChatResponse,
textFromOpenAiResponsesResponse,
textFromProviderContent,
} from './src/provider-parsers';
import {
buildAnthropicMessagesBody,
buildGeminiBody,
@ -808,6 +816,12 @@ export const __test = {
touchCacheEntry,
translate,
tokenLimitFieldForOpenAiChat,
cardsFromAnthropicToolUse,
textFromAnthropicMessagesResponse,
textFromGoogleGenerativeAiResponse,
textFromOpenAiChatResponse,
textFromOpenAiResponsesResponse,
textFromProviderContent,
updateCardAt,
validateBatchFolderInput,
visibleTopProbeY,

87
src/provider-parsers.ts Normal file
View file

@ -0,0 +1,87 @@
'use strict';
import { ANTHROPIC_CARD_TOOL_NAME, normalizeCardsPayload, parseCardsJson } from './schema';
import type { PluginSettings, RawCard } from './types';
export function textFromProviderContent(content: unknown): 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') {
const p = part as Record<string, unknown>;
return typeof p.text === 'string' ? p.text : typeof p.output_text === 'string' ? p.output_text : '';
}
return '';
})
.join('');
}
if (content && typeof content === 'object') {
const c = content as Record<string, unknown>;
return typeof c.text === 'string' ? c.text : typeof c.output_text === 'string' ? c.output_text : '';
}
return '';
}
export function textFromOpenAiResponsesResponse(json: Record<string, unknown>): string {
if (typeof json.output_text === 'string') return json.output_text;
const parts: string[] = [];
const walk = (value: unknown) => {
if (!value) return;
if (typeof value === 'string') return;
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (typeof value === 'object') {
const v = value as Record<string, unknown>;
if (typeof v.text === 'string') parts.push(v.text);
if (typeof v.output_text === 'string') parts.push(v.output_text);
if (v.type === 'output_text' && typeof v.content === 'string') parts.push(v.content);
if (v.content) walk(v.content);
if (v.output) walk(v.output);
}
};
walk(json.output);
return parts.join('');
}
export function cardsFromAnthropicToolUse(
json: Record<string, unknown>,
settings?: PluginSettings | null,
): RawCard[] | null {
const content = Array.isArray(json?.content) ? (json.content as Array<Record<string, unknown>>) : [];
const block = content.find((c) => 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 as Record<string, unknown>);
return [];
}
export function textFromAnthropicMessagesResponse(json: Record<string, unknown>): string {
const contentBlocks = Array.isArray(json.content) ? (json.content as Array<unknown>) : [];
return contentBlocks
.map((c) => textFromProviderContent(c))
.join('')
.trim();
}
export function textFromOpenAiChatResponse(json: Record<string, unknown>): string {
const choices = Array.isArray(json.choices) ? (json.choices as Array<Record<string, unknown>>) : [];
const choice = choices[0] || {};
const message = choice.message as Record<string, unknown> | undefined;
return textFromProviderContent(message?.content ?? choice.text ?? '').trim();
}
export function textFromGoogleGenerativeAiResponse(json: Record<string, unknown>): string {
const candidates = Array.isArray(json.candidates) ? (json.candidates as Array<Record<string, unknown>>) : [];
const candidate = candidates[0] || {};
const contentObj = candidate.content as Record<string, unknown> | undefined;
const parts = Array.isArray(contentObj?.parts) ? (contentObj.parts as Array<unknown>) : [];
return parts
.map((p) => textFromProviderContent(p))
.join('')
.trim();
}

View file

@ -1,11 +1,17 @@
'use strict';
import { translate } from './i18n';
import {
cardsFromAnthropicToolUse,
textFromAnthropicMessagesResponse,
textFromGoogleGenerativeAiResponse,
textFromOpenAiChatResponse,
textFromOpenAiResponsesResponse,
} from './provider-parsers';
import {
ANTHROPIC_CARD_TOOL_NAME,
anthropicCardTool,
cardOutputSchema,
normalizeCardsPayload,
openAiJsonSchemaResponseFormat,
openAiResponsesTextFormat,
parseCardsJson,
@ -231,50 +237,6 @@ async function requestJsonBodyWithStructuredFallback(
}
}
function textFromContent(content: unknown): 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') {
const p = part as Record<string, unknown>;
return typeof p.text === 'string' ? p.text : typeof p.output_text === 'string' ? p.output_text : '';
}
return '';
})
.join('');
}
if (content && typeof content === 'object') {
const c = content as Record<string, unknown>;
return typeof c.text === 'string' ? c.text : typeof c.output_text === 'string' ? c.output_text : '';
}
return '';
}
function textFromOpenAiResponses(json: Record<string, unknown>): string {
if (typeof json.output_text === 'string') return json.output_text;
const parts: string[] = [];
const walk = (value: unknown) => {
if (!value) return;
if (typeof value === 'string') return;
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (typeof value === 'object') {
const v = value as Record<string, unknown>;
if (typeof v.text === 'string') parts.push(v.text);
if (typeof v.output_text === 'string') parts.push(v.output_text);
if (v.type === 'output_text' && typeof v.content === 'string') parts.push(v.content);
if (v.content) walk(v.content);
if (v.output) walk(v.output);
}
};
walk(json.output);
return parts.join('');
}
export function tokenLimitFieldForOpenAiChat(settings: PluginSettings): string {
const preset = getApiPreset(settings);
const format = API_FORMATS[getApiFormat(settings)];
@ -363,16 +325,6 @@ export function buildGeminiBody(
};
}
function cardsFromAnthropicToolUse(json: Record<string, unknown>, settings?: PluginSettings | null) {
const content = Array.isArray(json?.content) ? (json.content as Array<Record<string, unknown>>) : [];
const block = content.find((c) => 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 as Record<string, unknown>);
return [];
}
async function summarizeViaAnthropicMessages(
requestUrlImpl: RequestUrlFunction,
system: string,
@ -393,12 +345,7 @@ async function summarizeViaAnthropicMessages(
const toolCards = cardsFromAnthropicToolUse(json, settings);
if (toolCards) return toolCards;
const contentBlocks = Array.isArray(json.content) ? (json.content as Array<unknown>) : [];
const text = contentBlocks
.map((c) => textFromContent(c))
.join('')
.trim();
return parseCardsJson(text, settings);
return parseCardsJson(textFromAnthropicMessagesResponse(json), settings);
}
async function summarizeViaOpenAiChat(
@ -417,11 +364,7 @@ async function summarizeViaOpenAiChat(
buildOpenAiChatBody(system, user, settings, { structured: false }),
settings,
);
const choices = Array.isArray(json.choices) ? (json.choices as Array<Record<string, unknown>>) : [];
const choice = choices[0] || {};
const message = choice.message as Record<string, unknown> | undefined;
const text = textFromContent(message?.content ?? choice.text ?? '').trim();
return parseCardsJson(text, settings);
return parseCardsJson(textFromOpenAiChatResponse(json), settings);
}
async function summarizeViaOpenAiResponses(
@ -440,7 +383,7 @@ async function summarizeViaOpenAiResponses(
buildOpenAiResponsesBody(system, user, settings, { structured: false }),
settings,
);
return parseCardsJson(textFromOpenAiResponses(json).trim(), settings);
return parseCardsJson(textFromOpenAiResponsesResponse(json).trim(), settings);
}
async function summarizeViaGoogleGenerativeAi(
@ -464,15 +407,7 @@ async function summarizeViaGoogleGenerativeAi(
buildGeminiBody(system, user, settings, { structured: false }),
settings,
);
const candidates = Array.isArray(json.candidates) ? (json.candidates as Array<Record<string, unknown>>) : [];
const candidate = candidates[0] || {};
const contentObj = candidate.content as Record<string, unknown> | undefined;
const parts = Array.isArray(contentObj?.parts) ? (contentObj.parts as Array<unknown>) : [];
const text = parts
.map((p) => textFromContent(p))
.join('')
.trim();
return parseCardsJson(text, settings);
return parseCardsJson(textFromGoogleGenerativeAiResponse(json), settings);
}
// Obsidian's requestUrl is not directly compatible with fetch — we accept it as a typed callback

View file

@ -70,6 +70,11 @@ assert.strictEqual(typeof t.resolveCliPath, 'function');
assert.strictEqual(typeof t.runCli, 'function');
assert.strictEqual(typeof t.buildPrompts, 'function');
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
assert.strictEqual(typeof t.textFromOpenAiChatResponse, 'function');
assert.strictEqual(typeof t.textFromAnthropicMessagesResponse, 'function');
assert.strictEqual(typeof t.textFromOpenAiResponsesResponse, 'function');
assert.strictEqual(typeof t.textFromGoogleGenerativeAiResponse, 'function');
assert.strictEqual(typeof t.cardsFromAnthropicToolUse, 'function');
assert.strictEqual(typeof t.extractJson, 'function');
assert.strictEqual(typeof t.findLineForAnchor, 'function');
assert.strictEqual(typeof t.folderPathsForTarget, 'function');

View file

@ -523,6 +523,35 @@ assert.strictEqual(gemBody.systemInstruction.parts[0].text, 'sys');
assert.strictEqual(gemBody.contents[0].parts[0].text, 'usr');
assert.strictEqual(gemBody.generationConfig.responseMimeType, 'application/json');
const providerCardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] });
assert.strictEqual(
t.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: providerCardsJson }] } }] }),
providerCardsJson,
'OpenAI Chat parser extracts message content text',
);
assert.strictEqual(
t.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: providerCardsJson }] }),
providerCardsJson,
'Anthropic parser extracts text blocks',
);
assert.strictEqual(
t.textFromOpenAiResponsesResponse({ output: [{ content: [{ type: 'output_text', text: providerCardsJson }] }] }),
providerCardsJson,
'OpenAI Responses parser extracts nested output text',
);
assert.strictEqual(
t.textFromGoogleGenerativeAiResponse({ candidates: [{ content: { parts: [{ text: providerCardsJson }] } }] }),
providerCardsJson,
'Gemini parser extracts candidate part text',
);
assert.deepStrictEqual(
t.cardsFromAnthropicToolUse({
content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(providerCardsJson) }],
}),
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
'Anthropic tool-use parser extracts structured card payloads',
);
// tokenLimitFieldForOpenAiChat
assert.strictEqual(
t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openai' }),