一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
286 行
9.2 KiB
TypeScript
286 行
9.2 KiB
TypeScript
import shim from '../../../shim';
|
|
import JoplinError from '../../../JoplinError';
|
|
import Logger from '@joplin/utils/Logger';
|
|
import { rtrimSlashes } from '@joplin/utils/path';
|
|
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification } from '../types';
|
|
import ChatProviderBase from './ChatProviderBase';
|
|
import { ToolSpec } from '../tools/types';
|
|
import extractReasoning from '../utils/extractReasoning';
|
|
|
|
const logger = Logger.create('OpenAiCompatibleProvider');
|
|
|
|
interface OpenAiUsage {
|
|
prompt_tokens?: number;
|
|
completion_tokens?: number;
|
|
}
|
|
|
|
interface OpenAiToolCall {
|
|
id: string;
|
|
index: number;
|
|
type: 'function';
|
|
function: {
|
|
arguments: string;
|
|
name: string;
|
|
};
|
|
}
|
|
|
|
interface OpenAiMessage {
|
|
content?: string;
|
|
// Non-standard: `reasoning_content` (DeepSeek, vLLM), `reasoning` (OpenRouter).
|
|
reasoning_content?: string;
|
|
reasoning?: string;
|
|
tool_calls?: OpenAiToolCall[];
|
|
}
|
|
|
|
interface OpenAiChoice {
|
|
message?: OpenAiMessage;
|
|
finish_reason?: string;
|
|
}
|
|
|
|
interface OpenAiResponse {
|
|
choices?: OpenAiChoice[];
|
|
usage?: OpenAiUsage;
|
|
error?: { message?: string };
|
|
}
|
|
|
|
interface Options {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
model: string;
|
|
classification: ProviderClassification;
|
|
}
|
|
|
|
const toChatFinishReason = (reason: string|undefined): ChatFinishReason|undefined => {
|
|
if (!reason) return undefined;
|
|
// Some providers use "max_tokens" or "MAX_TOKENS" instead of "length".
|
|
if (['length', 'max_tokens'].includes(reason.toLowerCase())) return 'length';
|
|
if (reason === 'stop') return 'stop';
|
|
if (reason === 'tool_calls') return 'tool_calls';
|
|
return 'other';
|
|
};
|
|
|
|
const convertTool = (tool: ToolSpec) => {
|
|
return {
|
|
type: 'function',
|
|
function: {
|
|
name: tool.id,
|
|
description: tool.description,
|
|
parameters: tool.inputSchema,
|
|
},
|
|
};
|
|
};
|
|
|
|
const convertMessage = (message: ChatMessage) => {
|
|
if (message.role === 'tool') {
|
|
const content = message.content;
|
|
if (typeof content === 'string') {
|
|
return [{
|
|
role: 'tool',
|
|
name: message.toolName,
|
|
content,
|
|
tool_call_id: message.toolCallId,
|
|
}];
|
|
} else {
|
|
// Joplin currently uses the older OpenAI chat responses API, which does not support
|
|
// images in tool results. Attach the image in a user message instead:
|
|
return [{
|
|
role: 'tool',
|
|
name: message.toolName,
|
|
content: 'success: will be attached in user message',
|
|
tool_call_id: message.toolCallId,
|
|
}, {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'image_url',
|
|
image_url: { url: content.dataUrl },
|
|
},
|
|
],
|
|
}];
|
|
}
|
|
} else {
|
|
return [{
|
|
role: message.role,
|
|
content: message.content,
|
|
...(message.toolCalls?.length ? {
|
|
tool_calls: message.toolCalls.map(call => {
|
|
return {
|
|
id: call.callId,
|
|
type: 'function',
|
|
function: {
|
|
name: call.toolName,
|
|
arguments: JSON.stringify(call.arguments),
|
|
},
|
|
};
|
|
}),
|
|
} : {}),
|
|
}];
|
|
}
|
|
};
|
|
|
|
const describeJsonParseFailure = (rawContent: string) => {
|
|
const parseError = ['Failed to parse JSON.'];
|
|
|
|
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
|
|
// the model knows to retry with a shorter message:
|
|
const suggestedRetryLengthLimit = 1000;
|
|
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
|
|
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
|
|
}
|
|
|
|
return parseError.join(' ');
|
|
};
|
|
|
|
export interface ChatRequestOptions {
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface OpenAiChatResponse {
|
|
response: { status: number };
|
|
json: OpenAiResponse;
|
|
}
|
|
|
|
export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
|
|
|
public id = 'openai-compatible';
|
|
public classification: ProviderClassification;
|
|
private baseUrl_: string;
|
|
private apiKey_: string;
|
|
private model_: string;
|
|
|
|
public constructor(options: Options) {
|
|
super();
|
|
this.baseUrl_ = rtrimSlashes(options.baseUrl);
|
|
this.apiKey_ = options.apiKey;
|
|
this.model_ = options.model;
|
|
this.classification = options.classification;
|
|
}
|
|
|
|
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
|
if (!this.model_) throw new JoplinError('OpenAI-compatible provider has no model configured', 'aiProviderNotConfigured');
|
|
|
|
const body: Record<string, unknown> = {
|
|
model: this.model_,
|
|
messages: messages.flatMap((message): unknown[] => convertMessage(message)),
|
|
stream: false,
|
|
};
|
|
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
|
if (options?.maxTokens !== undefined) body.max_tokens = options.maxTokens;
|
|
if (options?.responseFormat !== undefined) body.response_format = options.responseFormat;
|
|
if (options?.tools !== undefined) body.tools = options.tools.map(convertTool);
|
|
|
|
|
|
const doFetch = () => this.sendChatRequest(body, { signal: options?.signal });
|
|
|
|
let { response, json } = await doFetch();
|
|
|
|
// Newer OpenAI models (o1/o3/gpt-5/...) reject `max_tokens` and require
|
|
// `max_completion_tokens`. Older models and many OpenAI-compatible
|
|
// servers only know `max_tokens`. Retry once with the new name when the
|
|
// server tells us so.
|
|
const errorMessage = () => json?.error?.message ?? '';
|
|
if (response.status === 400 && 'max_tokens' in body && /max_completion_tokens/i.test(errorMessage())) {
|
|
body.max_completion_tokens = body.max_tokens;
|
|
delete body.max_tokens;
|
|
({ response, json } = await doFetch());
|
|
}
|
|
|
|
// Reasoning models apply a reasoning_effort default server-side, which OpenAI then rejects
|
|
// alongside tools on /chat/completions. Opt out of the default to keep tools working.
|
|
if (response.status === 400 && 'tools' in body && /reasoning_effort/i.test(errorMessage())) {
|
|
logger.warn(`Model ${this.model_} rejected function tools with reasoning; retrying with reasoning disabled.`);
|
|
body.reasoning_effort = 'none';
|
|
({ response, json } = await doFetch());
|
|
}
|
|
|
|
// Older OpenAI models might reject `response_format` json_schema (see https://stackoverflow.com/q/79039544).
|
|
// For compatibility, retry without response_format on failure:
|
|
if (response.status === 400 && 'response_format' in body && /json_schema|response_format/i.test(errorMessage())) {
|
|
logger.warn(`Model ${this.model_} rejected response_format; retrying without structured output schema.`);
|
|
delete body.response_format;
|
|
({ response, json } = await doFetch());
|
|
}
|
|
|
|
if (response.status >= 400) {
|
|
const detail = json?.error?.message ? `: ${json.error.message}` : '';
|
|
throw new JoplinError(`AI provider returned ${response.status}${detail}`, response.status);
|
|
}
|
|
|
|
// A 2xx response with no `choices` array usually means the endpoint URL
|
|
// is wrong (e.g. user forgot the `/v1` suffix on a local server) — many
|
|
// such servers reply 200 with an empty body rather than 404. Surface
|
|
// this rather than returning an empty string the caller can't diagnose.
|
|
if (!Array.isArray(json.choices)) {
|
|
throw new JoplinError(
|
|
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
|
|
'aiProviderBadResponse',
|
|
);
|
|
}
|
|
|
|
const choice = json.choices?.[0];
|
|
const responseMessage = choice?.message;
|
|
const { text: content, reasoning: inlineReasoning } = extractReasoning(responseMessage?.content ?? '');
|
|
const reasoningText = responseMessage?.reasoning_content ?? responseMessage?.reasoning ?? (inlineReasoning || undefined);
|
|
// Some "OpenAI-compatible" providers (notably older Ollama versions)
|
|
// omit `usage` entirely. Default to zeros rather than throw.
|
|
const inputTokens = json.usage?.prompt_tokens ?? 0;
|
|
const outputTokens = json.usage?.completion_tokens ?? 0;
|
|
|
|
const toolCalls: ChatToolCall[] = (responseMessage?.tool_calls ?? []).map(call => {
|
|
if (!call.function) return null;
|
|
|
|
let args;
|
|
let parseError: string|null = null;
|
|
const argumentString = call.function.arguments;
|
|
try {
|
|
args = JSON.parse(argumentString);
|
|
} catch (error) {
|
|
args = {};
|
|
parseError = describeJsonParseFailure(argumentString);
|
|
logger.error('JSON parse failed', error, parseError);
|
|
}
|
|
|
|
return {
|
|
toolName: call.function.name,
|
|
callId: call.id,
|
|
arguments: args,
|
|
parseError,
|
|
};
|
|
}).filter(toolCall => !!toolCall);
|
|
|
|
return {
|
|
text: content,
|
|
toolCalls,
|
|
usage: { inputTokens, outputTokens },
|
|
finishReason: toChatFinishReason(choice?.finish_reason),
|
|
reasoningText,
|
|
};
|
|
}
|
|
|
|
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
|
|
if (!this.baseUrl_) throw new JoplinError('OpenAI-compatible provider has no base URL configured', 'aiProviderNotConfigured');
|
|
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (this.apiKey_) headers['Authorization'] = `Bearer ${this.apiKey_}`;
|
|
|
|
const response = await shim.fetch(`${this.baseUrl_}/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: options.signal,
|
|
});
|
|
|
|
const text = await response.text();
|
|
let json: OpenAiResponse;
|
|
try {
|
|
json = JSON.parse(text) as OpenAiResponse;
|
|
} catch {
|
|
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, response.status);
|
|
}
|
|
return {
|
|
response: { status: response.status },
|
|
json,
|
|
};
|
|
}
|
|
}
|