fancive_obsidian-parallel-r.../src/streaming.ts
wujunchen 7986f1c308 fix: address codex review — drop false-positive fallback keywords, tighten stream-error detection
Two valid findings from an opposite-model (Codex) review of this branch:

- provider-request: drop bare `unknown`/`unrecognized` from the structured-output
  rejection keywords. They false-positive on model-name errors ("unknown model",
  "unrecognized model ID" from Ollama/Bedrock), triggering a wasted fallback
  retry. The specific feature tokens + bare `schema` still cover real structured-
  output rejections, so true-positive detection is unaffected.
- streaming: streamErrorMessage's OpenAI-style `{error:{...}}` branch now only
  treats the payload as an error when it carries a message/type, so a stray empty
  `error: {}` (or code-only object) in a normal chunk no longer aborts the stream.
  The Anthropic `{type:'error'}` branch still always signals an error.

Declined: Codex's suggestion to convert streaming's HTTP>=400 to ProviderApiError
+ add fallback — the streaming path always sends `structured:false`, so there is
no structured request to fall back from.

Tests updated/added: model-name 400s do not retry; empty/code-only error objects
do not throw.

Change-Id: I413d633c8ce7036365f97e2d8e1287c111e0902f
2026-06-16 10:05:04 +08:00

218 lines
7.2 KiB
TypeScript

'use strict';
import { translate } from './i18n';
import type { RequestUrlFunction } from './provider-request';
import type { PluginSettings } from './types';
/**
* Extract delta text from an OpenAI Chat SSE chunk.
* Shape: { choices: [{ delta: { content: "..." } }] }
*/
function openAiChatDelta(json: Record<string, unknown>): string {
const choices = json.choices as Array<{ delta?: { content?: string } }> | undefined;
return choices?.[0]?.delta?.content || '';
}
/**
* Extract delta text from an Anthropic Messages SSE chunk.
* Event types:
* content_block_delta → { delta: { text: "..." } }
*/
function anthropicDelta(json: Record<string, unknown>): string {
if (json.type === 'content_block_delta') {
const delta = json.delta as { text?: string } | undefined;
return delta?.text || '';
}
return '';
}
/**
* Detect a provider error delivered as an SSE payload inside an HTTP 200 stream.
* These bypass the `response.status >= 400` guard, so without this they would be
* extracted as empty deltas and later misreported as "non-JSON LLM output".
* Anthropic: { type: 'error', error: { type, message } }
* OpenAI-compatible: { error: { message, type, code } }
* Returns a human-readable error message for an error payload, or null otherwise.
*/
export function streamErrorMessage(json: Record<string, unknown>): string | null {
const messageFromError = (value: unknown): string | null => {
if (!value || typeof value !== 'object') return null;
const err = value as { message?: unknown; type?: unknown };
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.type === 'string' && err.type) return err.type;
return null;
};
// Anthropic: an explicit error event is an error even if its details are sparse.
if (json.type === 'error') {
return messageFromError(json.error) ?? 'Provider returned a streaming error';
}
// OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error
// when it actually carries a message/type, so a stray empty `error: {}` (or a
// code-only object) in an otherwise-normal chunk does not abort the stream.
return messageFromError(json.error);
}
export type DeltaExtractor = (json: Record<string, unknown>) => string;
export function deltaExtractorForFormat(format: string): DeltaExtractor | null {
switch (format) {
case 'openai-chat':
return openAiChatDelta;
case 'anthropic-messages':
return anthropicDelta;
default:
return null;
}
}
/**
* Parse a buffer of SSE text into individual data payloads.
* Returns { events: parsed JSON objects, rest: unconsumed buffer }.
*/
export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): { deltas: string[]; rest: string } {
const deltas: string[] = [];
const normalized = buffer.replace(/\r\n/g, '\n');
const chunks = normalized.split('\n\n');
const rest = normalized.endsWith('\n\n') ? '' : (chunks.pop() ?? '');
const eventChunks = normalized.endsWith('\n\n') ? chunks.slice(0, -1) : chunks;
for (const eventChunk of eventChunks) {
const dataLines: string[] = [];
for (const line of eventChunk.split('\n')) {
if (!line.startsWith('data:')) continue;
const data = line.slice(line.startsWith('data: ') ? 6 : 5);
dataLines.push(data);
}
if (dataLines.length === 0) continue;
const data = dataLines.join('\n');
if (data.trim() === '[DONE]') continue;
let json: Record<string, unknown>;
try {
json = JSON.parse(data) as Record<string, unknown>;
} catch {
continue; // skip non-JSON SSE lines (keep-alives, partial frames)
}
// Provider errors arrive as a 200-status SSE payload — surface them instead of
// swallowing them, so a transient overload/quota error is not misreported downstream.
const errorMessage = streamErrorMessage(json);
if (errorMessage) throw new Error(errorMessage);
const delta = extractDelta(json);
if (delta) deltas.push(delta);
}
return { deltas, rest };
}
export interface StreamProgress {
accumulated: string;
done: boolean;
}
async function doStreamingRequestUrl(
requestUrlImpl: RequestUrlFunction,
url: string,
headers: Record<string, string>,
body: unknown,
extractDelta: DeltaExtractor,
onProgress: ((progress: StreamProgress) => void) | undefined,
settings: PluginSettings | null | undefined,
): Promise<string> {
let response: Awaited<ReturnType<RequestUrlFunction>>;
try {
response = await requestUrlImpl({
url,
method: 'POST',
headers,
body: JSON.stringify(body),
throw: false,
});
} catch (e: unknown) {
throw new Error(
translate(settings || null, 'errorProviderRequestFailed', {
label: 'Streaming',
error: e instanceof Error ? e.message : String(e),
}),
);
}
if (response.status >= 400) {
throw new Error(
translate(settings || null, 'errorProviderApiStatus', {
label: 'Streaming',
status: response.status,
excerpt: (response.text || '').slice(0, 500),
}),
);
}
let accumulated = '';
const text = response.text || '';
const buffer = text.endsWith('\n\n') ? text : `${text}\n\n`;
const result = parseSseBuffer(buffer, extractDelta);
for (const delta of result.deltas) {
accumulated += delta;
}
if (result.deltas.length > 0) {
onProgress?.({ accumulated, done: false });
}
onProgress?.({ accumulated, done: true });
return accumulated;
}
/**
* Perform an Obsidian requestUrl call that asks providers for SSE output and parses
* the complete response text. requestUrl is one-shot, so progress arrives after
* the HTTP request completes rather than per network chunk.
*/
export async function streamingRequestUrl(
requestUrlImpl: RequestUrlFunction,
url: string,
headers: Record<string, string>,
body: unknown,
extractDelta: DeltaExtractor,
onProgress?: (progress: StreamProgress) => void,
signal?: AbortSignal,
settings?: PluginSettings | null,
): Promise<string> {
const timeoutMs = settings?.streamingTimeoutMs ?? 120000;
let abortListener: (() => void) | null = null;
let abortPromise: Promise<never> | null = null;
if (signal) {
if (signal.aborted) throw new Error('Streaming request aborted');
abortPromise = (async () => {
await new Promise<void>((resolve) => {
abortListener = resolve;
signal.addEventListener('abort', abortListener, { once: true });
});
throw new Error('Streaming request aborted');
})();
}
let timeoutId: number | null = null;
const timeoutPromise = (async () => {
await new Promise<void>((resolve) => {
timeoutId = activeWindow.setTimeout(resolve, timeoutMs);
});
throw new Error(`Streaming timed out after ${timeoutMs}ms`);
})();
try {
const requestPromise = doStreamingRequestUrl(
requestUrlImpl,
url,
headers,
body,
extractDelta,
onProgress,
settings,
);
return await Promise.race(
abortPromise ? [requestPromise, timeoutPromise, abortPromise] : [requestPromise, timeoutPromise],
);
} finally {
if (timeoutId !== null) activeWindow.clearTimeout(timeoutId);
if (signal && abortListener) signal.removeEventListener('abort', abortListener);
}
}