Extract OpenAPI provider routing and payload seams
This commit is contained in:
parent
8ff7ae1625
commit
9c6677564c
7 changed files with 369 additions and 343 deletions
|
|
@ -145,6 +145,10 @@ const controlCenterIntegrationsMcpToolkitSectionSource = readFileSync(join(appDi
|
|||
const openApiChatSource = readFileSync(join(appDir, "src", "openapi-chat.ts"), "utf8");
|
||||
const openApiChatPromptFlowsSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-flows.ts"), "utf8");
|
||||
const openApiChatPromptPlainFlowSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-plain-flow.ts"), "utf8");
|
||||
const openApiChatProviderSource = readFileSync(join(appDir, "src", "openapi-chat-provider.ts"), "utf8");
|
||||
const openApiChatProviderSupportSource = readFileSync(join(appDir, "src", "openapi-chat-provider-support.ts"), "utf8");
|
||||
const openApiChatProviderRoutingSource = readFileSync(join(appDir, "src", "openapi-chat-provider-routing.ts"), "utf8");
|
||||
const openApiChatProviderPayloadsSource = readFileSync(join(appDir, "src", "openapi-chat-provider-payloads.ts"), "utf8");
|
||||
const openApiChatSettingsSource = readFileSync(join(appDir, "src", "openapi-chat-settings.ts"), "utf8");
|
||||
const openApiChatModelSource = readFileSync(join(appDir, "src", "openapi-chat-model.ts"), "utf8");
|
||||
const openApiChatPresentationSource = readFileSync(join(appDir, "src", "openapi-chat-presentation.ts"), "utf8");
|
||||
|
|
@ -715,6 +719,14 @@ assert.match(openApiChatPromptFlowsSource, /export \{[\s\S]*?buildOpenApiInstruc
|
|||
assert.match(openApiChatPromptFlowsSource, /from "\.\/openapi-chat-presentation(?:\.js)?"/, "OpenAPI chat prompt flows must import the extracted presentation seam.");
|
||||
assert.match(openApiChatPromptFlowsSource, /from "\.\/openapi-chat-provider(?:\.js)?"/, "OpenAPI chat prompt flows must import the extracted provider seam.");
|
||||
assert.match(openApiChatPromptFlowsSource, /from "\.\/openapi-chat-tool-loop(?:\.js)?"/, "OpenAPI chat prompt flows must import the extracted tool loop seam.");
|
||||
assert.match(openApiChatProviderSource, /export \* from "\.\/openapi-chat-provider-support(?:\.js)?"/, "OpenAPI chat provider barrel must re-export the extracted provider support seam.");
|
||||
assert.match(openApiChatProviderSupportSource, /export \* from "\.\/openapi-chat-provider-routing(?:\.js)?"/, "OpenAPI chat provider support seam must re-export routing helpers.");
|
||||
assert.match(openApiChatProviderSupportSource, /export \* from "\.\/openapi-chat-provider-payloads(?:\.js)?"/, "OpenAPI chat provider support seam must re-export payload helpers.");
|
||||
assert.match(openApiChatProviderRoutingSource, /export function buildRequestAttempts/, "OpenAPI chat provider routing seam must export request-attempt routing.");
|
||||
assert.match(openApiChatProviderRoutingSource, /export async function readProviderError/, "OpenAPI chat provider routing seam must export provider error normalization.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function parseResponsesPayload/, "OpenAPI chat provider payload seam must export responses payload parsing.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function parseChatCompletionsPayload/, "OpenAPI chat provider payload seam must export chat-completions payload parsing.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function extractChatCompletionsChoice/, "OpenAPI chat provider payload seam must export tool-call extraction.");
|
||||
assert.match(openApiChatPromptPlainFlowSource, /export async function sendOpenApiChatPromptPlainFlow/, "OpenAPI chat plain-flow seam must export the plain prompt runner.");
|
||||
assert.match(openApiChatPromptPlainFlowSource, /export function buildOpenApiInstructions/, "OpenAPI chat plain-flow seam must export instruction building.");
|
||||
assert.match(openApiChatPromptPlainFlowSource, /from "\.\/openapi-chat-presentation(?:\.js)?"/, "OpenAPI chat plain-flow seam must import the extracted presentation seam.");
|
||||
|
|
|
|||
|
|
@ -242,7 +242,10 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `openapi-chat-request-helpers.ts`: Extracted pure request-body and chat-completions history shaping for prompt-window OpenAPI chat
|
||||
- `openapi-chat-conversation-store.ts`: Extracted current-conversation state ownership and disk-backed persistence for prompt-window chat
|
||||
- `openapi-chat-conversation-store-core.ts`: Pure transcript normalization, per-conversation capping, and title helpers for the extracted conversation store seam
|
||||
- `openapi-chat-provider.ts`: Extracted provider transport helpers for endpoint routing, auth headers, provider error normalization, and response/tool-call payload parsing
|
||||
- `openapi-chat-provider.ts`: Stable provider barrel for prompt-window OpenAPI chat transport helpers.
|
||||
- `openapi-chat-provider-support.ts`: Public provider support barrel that keeps prompt-window imports stable while forwarding to focused routing and payload seams.
|
||||
- `openapi-chat-provider-routing.ts`: Extracted endpoint routing, auth header shaping, provider labeling, and normalized provider-error fallback decisions.
|
||||
- `openapi-chat-provider-payloads.ts`: Extracted responses/chat-completions payload parsing and tool-call envelope extraction for the MCP loop.
|
||||
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Familiars, Integrations, Plugins, and Settings, with extracted integrations/settings/helper route seams under `renderer/src/control-center/`, including the `settings-view-state.ts` hook seam, the `settings-view-state-actions.ts` route action shell, and the extracted state plugin/knowledge/type seams that keep route mutations bounded.
|
||||
|
||||
**Familiars**:
|
||||
|
|
|
|||
98
apps/desktop/src/openapi-chat-provider-payloads.ts
Normal file
98
apps/desktop/src/openapi-chat-provider-payloads.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
type ChatCompletionToolCall = {
|
||||
readonly id: string;
|
||||
readonly type: "function";
|
||||
readonly function: {
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ChatCompletionChoice {
|
||||
readonly message: {
|
||||
readonly content?: string | null;
|
||||
readonly tool_calls?: readonly ChatCompletionToolCall[];
|
||||
};
|
||||
}
|
||||
|
||||
export function parseResponsesPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId?: string } {
|
||||
const text = extractResponsesOutputText(payload).trim();
|
||||
const responseId = typeof payload.id === "string" && payload.id ? payload.id : undefined;
|
||||
return { text, responseId };
|
||||
}
|
||||
|
||||
export function parseChatCompletionsPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId: string } {
|
||||
const text = extractChatCompletionsText(payload).trim();
|
||||
const responseId = typeof payload.id === "string" && payload.id
|
||||
? payload.id
|
||||
: `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return { text, responseId };
|
||||
}
|
||||
|
||||
export function extractChatCompletionsChoice(payload: Record<string, unknown>): ChatCompletionChoice | undefined {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const first = choices[0];
|
||||
if (!first || typeof first !== "object") return undefined;
|
||||
const message = (first as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
const msg = message as Record<string, unknown>;
|
||||
const content = typeof msg.content === "string" ? msg.content : null;
|
||||
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
||||
const tool_calls = rawToolCalls.map((tc): ChatCompletionToolCall | null => {
|
||||
if (!tc || typeof tc !== "object") return null;
|
||||
const t = tc as Record<string, unknown>;
|
||||
const id = typeof t.id === "string" ? t.id : "";
|
||||
const type: "function" = t.type === "function" ? "function" : "function";
|
||||
const fn = t.function;
|
||||
if (!fn || typeof fn !== "object") return null;
|
||||
const f = fn as Record<string, unknown>;
|
||||
const name = typeof f.name === "string" ? f.name : "";
|
||||
const args = typeof f.arguments === "string" ? f.arguments : "";
|
||||
if (!id || !name) return null;
|
||||
return { id, type, function: { name, arguments: args } };
|
||||
}).filter((tc): tc is ChatCompletionToolCall => tc !== null);
|
||||
|
||||
return { message: { content, ...(tool_calls.length > 0 ? { tool_calls } : {}) } };
|
||||
}
|
||||
|
||||
function extractResponsesOutputText(payload: Record<string, unknown>): string {
|
||||
if (typeof payload.output_text === "string") return payload.output_text;
|
||||
const output = Array.isArray(payload.output) ? payload.output : [];
|
||||
const parts: string[] = [];
|
||||
for (const item of output) {
|
||||
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const contentPart of item.content) {
|
||||
if (!isRecord(contentPart) || contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue;
|
||||
parts.push(contentPart.text);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function extractChatCompletionsText(payload: Record<string, unknown>): string {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const choice of choices) {
|
||||
if (!isRecord(choice) || !isRecord(choice.message)) continue;
|
||||
const content = choice.message.content;
|
||||
if (typeof content === "string") {
|
||||
parts.push(content);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (typeof part.text === "string") {
|
||||
parts.push(part.text);
|
||||
} else if (isRecord(part.text) && typeof part.text.value === "string") {
|
||||
parts.push(part.text.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
241
apps/desktop/src/openapi-chat-provider-routing.ts
Normal file
241
apps/desktop/src/openapi-chat-provider-routing.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
export type OpenApiChatTransport = "responses" | "chat-completions";
|
||||
|
||||
export interface OpenApiRequestAttempt {
|
||||
readonly transport: OpenApiChatTransport;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
interface ProviderErrorPayload {
|
||||
readonly error?: {
|
||||
readonly message?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly type?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderErrorInfo {
|
||||
readonly endpoint: string;
|
||||
readonly status: number;
|
||||
readonly providerMessage: string;
|
||||
readonly providerCode: string;
|
||||
readonly providerType: string;
|
||||
readonly normalizedMessage: string;
|
||||
}
|
||||
|
||||
export function buildRequestAttempts(endpoint: string): readonly OpenApiRequestAttempt[] {
|
||||
const url = safeParseUrl(endpoint);
|
||||
if (!url) {
|
||||
return [{ transport: "responses", endpoint }];
|
||||
}
|
||||
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
if (normalizedPath.endsWith("/chat/completions")) {
|
||||
return [{ transport: "chat-completions", endpoint: url.toString() }];
|
||||
}
|
||||
if (normalizedPath.endsWith("/responses")) {
|
||||
if (isOfficialOpenAiEndpoint(endpoint) || isAzureOpenAiEndpoint(endpoint)) {
|
||||
return [{ transport: "responses", endpoint: url.toString() }];
|
||||
}
|
||||
return dedupeAttempts([
|
||||
{ transport: "responses", endpoint: url.toString() },
|
||||
{ transport: "chat-completions", endpoint: withPathname(url, replaceTransportPath(normalizedPath, "chat-completions")) },
|
||||
]);
|
||||
}
|
||||
if (normalizedPath.endsWith("/v1")) {
|
||||
return dedupeAttempts([
|
||||
{ transport: "responses", endpoint: withPathname(url, `${normalizedPath}/responses`) },
|
||||
{ transport: "chat-completions", endpoint: withPathname(url, `${normalizedPath}/chat/completions`) },
|
||||
]);
|
||||
}
|
||||
|
||||
return [{ transport: "responses", endpoint: url.toString() }];
|
||||
}
|
||||
|
||||
export function buildOpenApiRequestHeaders(endpoint: string, credential: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credential}`,
|
||||
};
|
||||
if (isAzureOpenAiEndpoint(endpoint)) {
|
||||
headers["api-key"] = credential;
|
||||
} else if (!isOfficialOpenAiEndpoint(endpoint)) {
|
||||
headers["x-api-key"] = credential;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function getEndpointHost(endpoint: string): string {
|
||||
return safeParseUrl(endpoint)?.host ?? "unknown";
|
||||
}
|
||||
|
||||
export async function readProviderError(response: Response, endpoint: string): Promise<ProviderErrorInfo> {
|
||||
let providerMessage = "";
|
||||
let providerCode = "";
|
||||
let providerType = "";
|
||||
try {
|
||||
const payload = await response.json() as ProviderErrorPayload;
|
||||
providerMessage = typeof payload?.error?.message === "string" ? payload.error.message : "";
|
||||
providerCode = typeof payload?.error?.code === "string" ? payload.error.code : "";
|
||||
providerType = typeof payload?.error?.type === "string" ? payload.error.type : "";
|
||||
} catch {
|
||||
// Fall through to a generic message.
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
status: response.status,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
providerType,
|
||||
normalizedMessage: normalizeProviderErrorMessage({
|
||||
endpoint,
|
||||
status: response.status,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
providerType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldTryChatCompletionsFallback(attempt: OpenApiRequestAttempt, errorInfo: ProviderErrorInfo): boolean {
|
||||
if (attempt.transport !== "responses") return false;
|
||||
if (isOfficialOpenAiEndpoint(attempt.endpoint) || isAzureOpenAiEndpoint(attempt.endpoint)) return false;
|
||||
|
||||
const normalizedProviderMessage = sanitizeProviderMessage(errorInfo.providerMessage);
|
||||
const haystack = `${errorInfo.providerCode} ${errorInfo.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
||||
if (errorInfo.status === 401 || errorInfo.status === 403 || errorInfo.status === 429) return false;
|
||||
if (haystack.includes("authentication header") || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) return false;
|
||||
if (haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) return false;
|
||||
|
||||
if (errorInfo.status === 404 || errorInfo.status === 405) return true;
|
||||
if (errorInfo.status === 400 || errorInfo.status === 415 || errorInfo.status === 422 || errorInfo.status === 501) {
|
||||
return [
|
||||
"unknown parameter",
|
||||
"unknown url",
|
||||
"unsupported",
|
||||
"unsupported route",
|
||||
"not found",
|
||||
"no route",
|
||||
"responses",
|
||||
"input",
|
||||
"instructions",
|
||||
"previous_response_id",
|
||||
"max_output_tokens",
|
||||
"invalid_request_error",
|
||||
"chat/completions",
|
||||
].some((needle) => haystack.includes(needle));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasNextAttempt(attempts: readonly OpenApiRequestAttempt[], index: number): boolean {
|
||||
return index < attempts.length - 1;
|
||||
}
|
||||
|
||||
export function payloadHasChatCompletionsShape(payload: Record<string, unknown>): boolean {
|
||||
return Array.isArray(payload.choices);
|
||||
}
|
||||
|
||||
export function forceChatCompletionsEndpoint(endpoint: string): string {
|
||||
if (endpoint.endsWith("/chat/completions")) return endpoint;
|
||||
if (endpoint.endsWith("/responses")) return endpoint.replace(/\/responses$/, "/chat/completions");
|
||||
if (endpoint.endsWith("/v1")) return `${endpoint}/chat/completions`;
|
||||
return `${endpoint.replace(/\/+$/, "")}/chat/completions`;
|
||||
}
|
||||
|
||||
function dedupeAttempts(attempts: readonly OpenApiRequestAttempt[]): readonly OpenApiRequestAttempt[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: OpenApiRequestAttempt[] = [];
|
||||
for (const attempt of attempts) {
|
||||
const key = `${attempt.transport}:${attempt.endpoint}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(attempt);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function withPathname(url: URL, pathname: string): string {
|
||||
const clone = new URL(url.toString());
|
||||
clone.pathname = pathname;
|
||||
return clone.toString();
|
||||
}
|
||||
|
||||
function replaceTransportPath(pathname: string, transport: OpenApiChatTransport): string {
|
||||
const suffix = transport === "responses" ? "/responses" : "/chat/completions";
|
||||
return pathname.replace(/\/(?:responses|chat\/completions)$/, suffix);
|
||||
}
|
||||
|
||||
function isAzureOpenAiEndpoint(endpoint: string): boolean {
|
||||
const url = safeParseUrl(endpoint);
|
||||
return Boolean(url && url.hostname.endsWith(".openai.azure.com"));
|
||||
}
|
||||
|
||||
function isOfficialOpenAiEndpoint(endpoint: string): boolean {
|
||||
const url = safeParseUrl(endpoint);
|
||||
return Boolean(url && url.hostname === "api.openai.com");
|
||||
}
|
||||
|
||||
function safeParseUrl(endpoint: string): URL | undefined {
|
||||
try {
|
||||
return new URL(endpoint);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderErrorMessage(input: {
|
||||
readonly endpoint: string;
|
||||
readonly status: number;
|
||||
readonly providerMessage: string;
|
||||
readonly providerCode: string;
|
||||
readonly providerType: string;
|
||||
}): string {
|
||||
const providerName = getEndpointProviderLabel(input.endpoint);
|
||||
const normalizedProviderMessage = sanitizeProviderMessage(input.providerMessage);
|
||||
const haystack = `${input.providerCode} ${input.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
||||
|
||||
if (haystack.includes("missing authentication header") || haystack.includes("authentication header")) {
|
||||
return `${providerName} says the request arrived without usable authentication. Re-save the credential in Settings after choosing the matching preset or endpoint.`;
|
||||
}
|
||||
|
||||
if (input.status === 401 || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) {
|
||||
return `${providerName} rejected the saved credential. Re-save it in Settings and confirm the preset matches the endpoint.`;
|
||||
}
|
||||
|
||||
if (isOfficialOpenAiEndpoint(input.endpoint) && (haystack.includes("insufficient_quota") || haystack.includes("current quota") || haystack.includes("billing"))) {
|
||||
return "OpenAI is reporting an API billing or quota problem for this key. ChatGPT app subscriptions do not automatically cover API usage.";
|
||||
}
|
||||
|
||||
if (haystack.includes("tokens exhausted") || haystack.includes("insufficient_quota") || haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) {
|
||||
return `${providerName} is reporting an account quota, credit, or billing problem for this credential, not a prompt-length issue.`;
|
||||
}
|
||||
|
||||
if (input.status === 429 || haystack.includes("rate limit")) {
|
||||
return `${providerName} rate-limited this request. Wait a moment and try again.`;
|
||||
}
|
||||
|
||||
if (normalizedProviderMessage) {
|
||||
return normalizedProviderMessage;
|
||||
}
|
||||
|
||||
return `${providerName} request failed with HTTP ${input.status}.`;
|
||||
}
|
||||
|
||||
function getEndpointProviderLabel(endpoint: string): string {
|
||||
if (isAzureOpenAiEndpoint(endpoint)) return "Azure OpenAI";
|
||||
|
||||
const url = safeParseUrl(endpoint);
|
||||
if (!url) return "OpenAPI provider";
|
||||
if (url.hostname === "api.openai.com") return "OpenAI";
|
||||
if (url.hostname === "openrouter.ai") return "OpenRouter";
|
||||
if (url.hostname === "api.moonshot.cn") return "Moonshot";
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1") return "Local OpenAPI provider";
|
||||
return "OpenAPI provider";
|
||||
}
|
||||
|
||||
function sanitizeProviderMessage(message: string): string {
|
||||
return message
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*For more information on this error, read the docs:.*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
2
apps/desktop/src/openapi-chat-provider-support.ts
Normal file
2
apps/desktop/src/openapi-chat-provider-support.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./openapi-chat-provider-routing.js";
|
||||
export * from "./openapi-chat-provider-payloads.js";
|
||||
|
|
@ -1,340 +1 @@
|
|||
export type OpenApiChatTransport = "responses" | "chat-completions";
|
||||
|
||||
export interface OpenApiRequestAttempt {
|
||||
readonly transport: OpenApiChatTransport;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
interface ProviderErrorPayload {
|
||||
readonly error?: {
|
||||
readonly message?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly type?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderErrorInfo {
|
||||
readonly endpoint: string;
|
||||
readonly status: number;
|
||||
readonly providerMessage: string;
|
||||
readonly providerCode: string;
|
||||
readonly providerType: string;
|
||||
readonly normalizedMessage: string;
|
||||
}
|
||||
|
||||
type ChatCompletionToolCall = {
|
||||
readonly id: string;
|
||||
readonly type: "function";
|
||||
readonly function: {
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ChatCompletionChoice {
|
||||
readonly message: {
|
||||
readonly content?: string | null;
|
||||
readonly tool_calls?: readonly ChatCompletionToolCall[];
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestAttempts(endpoint: string): readonly OpenApiRequestAttempt[] {
|
||||
const url = safeParseUrl(endpoint);
|
||||
if (!url) {
|
||||
return [{ transport: "responses", endpoint }];
|
||||
}
|
||||
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
if (normalizedPath.endsWith("/chat/completions")) {
|
||||
return [{ transport: "chat-completions", endpoint: url.toString() }];
|
||||
}
|
||||
if (normalizedPath.endsWith("/responses")) {
|
||||
if (isOfficialOpenAiEndpoint(endpoint) || isAzureOpenAiEndpoint(endpoint)) {
|
||||
return [{ transport: "responses", endpoint: url.toString() }];
|
||||
}
|
||||
return dedupeAttempts([
|
||||
{ transport: "responses", endpoint: url.toString() },
|
||||
{ transport: "chat-completions", endpoint: withPathname(url, replaceTransportPath(normalizedPath, "chat-completions")) },
|
||||
]);
|
||||
}
|
||||
if (normalizedPath.endsWith("/v1")) {
|
||||
return dedupeAttempts([
|
||||
{ transport: "responses", endpoint: withPathname(url, `${normalizedPath}/responses`) },
|
||||
{ transport: "chat-completions", endpoint: withPathname(url, `${normalizedPath}/chat/completions`) },
|
||||
]);
|
||||
}
|
||||
|
||||
return [{ transport: "responses", endpoint: url.toString() }];
|
||||
}
|
||||
|
||||
export function parseResponsesPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId?: string } {
|
||||
const text = extractResponsesOutputText(payload).trim();
|
||||
const responseId = typeof payload.id === "string" && payload.id ? payload.id : undefined;
|
||||
return { text, responseId };
|
||||
}
|
||||
|
||||
export function parseChatCompletionsPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId: string } {
|
||||
const text = extractChatCompletionsText(payload).trim();
|
||||
const responseId = typeof payload.id === "string" && payload.id
|
||||
? payload.id
|
||||
: `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return { text, responseId };
|
||||
}
|
||||
|
||||
export function buildOpenApiRequestHeaders(endpoint: string, credential: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credential}`,
|
||||
};
|
||||
if (isAzureOpenAiEndpoint(endpoint)) {
|
||||
headers["api-key"] = credential;
|
||||
} else if (!isOfficialOpenAiEndpoint(endpoint)) {
|
||||
headers["x-api-key"] = credential;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function getEndpointHost(endpoint: string): string {
|
||||
return safeParseUrl(endpoint)?.host ?? "unknown";
|
||||
}
|
||||
|
||||
export async function readProviderError(response: Response, endpoint: string): Promise<ProviderErrorInfo> {
|
||||
let providerMessage = "";
|
||||
let providerCode = "";
|
||||
let providerType = "";
|
||||
try {
|
||||
const payload = await response.json() as ProviderErrorPayload;
|
||||
providerMessage = typeof payload?.error?.message === "string" ? payload.error.message : "";
|
||||
providerCode = typeof payload?.error?.code === "string" ? payload.error.code : "";
|
||||
providerType = typeof payload?.error?.type === "string" ? payload.error.type : "";
|
||||
} catch {
|
||||
// Fall through to a generic message.
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
status: response.status,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
providerType,
|
||||
normalizedMessage: normalizeProviderErrorMessage({
|
||||
endpoint,
|
||||
status: response.status,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
providerType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldTryChatCompletionsFallback(attempt: OpenApiRequestAttempt, errorInfo: ProviderErrorInfo): boolean {
|
||||
if (attempt.transport !== "responses") return false;
|
||||
if (isOfficialOpenAiEndpoint(attempt.endpoint) || isAzureOpenAiEndpoint(attempt.endpoint)) return false;
|
||||
|
||||
const normalizedProviderMessage = sanitizeProviderMessage(errorInfo.providerMessage);
|
||||
const haystack = `${errorInfo.providerCode} ${errorInfo.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
||||
if (errorInfo.status === 401 || errorInfo.status === 403 || errorInfo.status === 429) return false;
|
||||
if (haystack.includes("authentication header") || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) return false;
|
||||
if (haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) return false;
|
||||
|
||||
if (errorInfo.status === 404 || errorInfo.status === 405) return true;
|
||||
if (errorInfo.status === 400 || errorInfo.status === 415 || errorInfo.status === 422 || errorInfo.status === 501) {
|
||||
return [
|
||||
"unknown parameter",
|
||||
"unknown url",
|
||||
"unsupported",
|
||||
"unsupported route",
|
||||
"not found",
|
||||
"no route",
|
||||
"responses",
|
||||
"input",
|
||||
"instructions",
|
||||
"previous_response_id",
|
||||
"max_output_tokens",
|
||||
"invalid_request_error",
|
||||
"chat/completions",
|
||||
].some((needle) => haystack.includes(needle));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasNextAttempt(attempts: readonly OpenApiRequestAttempt[], index: number): boolean {
|
||||
return index < attempts.length - 1;
|
||||
}
|
||||
|
||||
export function payloadHasChatCompletionsShape(payload: Record<string, unknown>): boolean {
|
||||
return Array.isArray(payload.choices);
|
||||
}
|
||||
|
||||
export function forceChatCompletionsEndpoint(endpoint: string): string {
|
||||
if (endpoint.endsWith("/chat/completions")) return endpoint;
|
||||
if (endpoint.endsWith("/responses")) return endpoint.replace(/\/responses$/, "/chat/completions");
|
||||
if (endpoint.endsWith("/v1")) return `${endpoint}/chat/completions`;
|
||||
return `${endpoint.replace(/\/+$/, "")}/chat/completions`;
|
||||
}
|
||||
|
||||
export function extractChatCompletionsChoice(payload: Record<string, unknown>): ChatCompletionChoice | undefined {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const first = choices[0];
|
||||
if (!first || typeof first !== "object") return undefined;
|
||||
const message = (first as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
const msg = message as Record<string, unknown>;
|
||||
const content = typeof msg.content === "string" ? msg.content : null;
|
||||
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
||||
const tool_calls = rawToolCalls.map((tc): ChatCompletionToolCall | null => {
|
||||
if (!tc || typeof tc !== "object") return null;
|
||||
const t = tc as Record<string, unknown>;
|
||||
const id = typeof t.id === "string" ? t.id : "";
|
||||
const type: "function" = t.type === "function" ? "function" : "function";
|
||||
const fn = t.function;
|
||||
if (!fn || typeof fn !== "object") return null;
|
||||
const f = fn as Record<string, unknown>;
|
||||
const name = typeof f.name === "string" ? f.name : "";
|
||||
const args = typeof f.arguments === "string" ? f.arguments : "";
|
||||
if (!id || !name) return null;
|
||||
return { id, type, function: { name, arguments: args } };
|
||||
}).filter((tc): tc is ChatCompletionToolCall => tc !== null);
|
||||
|
||||
return { message: { content, ...(tool_calls.length > 0 ? { tool_calls } : {}) } };
|
||||
}
|
||||
|
||||
function dedupeAttempts(attempts: readonly OpenApiRequestAttempt[]): readonly OpenApiRequestAttempt[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: OpenApiRequestAttempt[] = [];
|
||||
for (const attempt of attempts) {
|
||||
const key = `${attempt.transport}:${attempt.endpoint}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(attempt);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function withPathname(url: URL, pathname: string): string {
|
||||
const clone = new URL(url.toString());
|
||||
clone.pathname = pathname;
|
||||
return clone.toString();
|
||||
}
|
||||
|
||||
function replaceTransportPath(pathname: string, transport: OpenApiChatTransport): string {
|
||||
const suffix = transport === "responses" ? "/responses" : "/chat/completions";
|
||||
return pathname.replace(/\/(?:responses|chat\/completions)$/, suffix);
|
||||
}
|
||||
|
||||
function isAzureOpenAiEndpoint(endpoint: string): boolean {
|
||||
const url = safeParseUrl(endpoint);
|
||||
return Boolean(url && url.hostname.endsWith(".openai.azure.com"));
|
||||
}
|
||||
|
||||
function isOfficialOpenAiEndpoint(endpoint: string): boolean {
|
||||
const url = safeParseUrl(endpoint);
|
||||
return Boolean(url && url.hostname === "api.openai.com");
|
||||
}
|
||||
|
||||
function safeParseUrl(endpoint: string): URL | undefined {
|
||||
try {
|
||||
return new URL(endpoint);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderErrorMessage(input: {
|
||||
readonly endpoint: string;
|
||||
readonly status: number;
|
||||
readonly providerMessage: string;
|
||||
readonly providerCode: string;
|
||||
readonly providerType: string;
|
||||
}): string {
|
||||
const providerName = getEndpointProviderLabel(input.endpoint);
|
||||
const normalizedProviderMessage = sanitizeProviderMessage(input.providerMessage);
|
||||
const haystack = `${input.providerCode} ${input.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
||||
|
||||
if (haystack.includes("missing authentication header") || haystack.includes("authentication header")) {
|
||||
return `${providerName} says the request arrived without usable authentication. Re-save the credential in Settings after choosing the matching preset or endpoint.`;
|
||||
}
|
||||
|
||||
if (input.status === 401 || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) {
|
||||
return `${providerName} rejected the saved credential. Re-save it in Settings and confirm the preset matches the endpoint.`;
|
||||
}
|
||||
|
||||
if (isOfficialOpenAiEndpoint(input.endpoint) && (haystack.includes("insufficient_quota") || haystack.includes("current quota") || haystack.includes("billing"))) {
|
||||
return "OpenAI is reporting an API billing or quota problem for this key. ChatGPT app subscriptions do not automatically cover API usage.";
|
||||
}
|
||||
|
||||
if (haystack.includes("tokens exhausted") || haystack.includes("insufficient_quota") || haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) {
|
||||
return `${providerName} is reporting an account quota, credit, or billing problem for this credential, not a prompt-length issue.`;
|
||||
}
|
||||
|
||||
if (input.status === 429 || haystack.includes("rate limit")) {
|
||||
return `${providerName} rate-limited this request. Wait a moment and try again.`;
|
||||
}
|
||||
|
||||
if (normalizedProviderMessage) {
|
||||
return normalizedProviderMessage;
|
||||
}
|
||||
|
||||
return `${providerName} request failed with HTTP ${input.status}.`;
|
||||
}
|
||||
|
||||
function getEndpointProviderLabel(endpoint: string): string {
|
||||
if (isAzureOpenAiEndpoint(endpoint)) return "Azure OpenAI";
|
||||
|
||||
const url = safeParseUrl(endpoint);
|
||||
if (!url) return "OpenAPI provider";
|
||||
if (url.hostname === "api.openai.com") return "OpenAI";
|
||||
if (url.hostname === "openrouter.ai") return "OpenRouter";
|
||||
if (url.hostname === "api.moonshot.cn") return "Moonshot";
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1") return "Local OpenAPI provider";
|
||||
return "OpenAPI provider";
|
||||
}
|
||||
|
||||
function sanitizeProviderMessage(message: string): string {
|
||||
return message
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*For more information on this error, read the docs:.*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractResponsesOutputText(payload: Record<string, unknown>): string {
|
||||
if (typeof payload.output_text === "string") return payload.output_text;
|
||||
const output = Array.isArray(payload.output) ? payload.output : [];
|
||||
const parts: string[] = [];
|
||||
for (const item of output) {
|
||||
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const contentPart of item.content) {
|
||||
if (!isRecord(contentPart) || contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue;
|
||||
parts.push(contentPart.text);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function extractChatCompletionsText(payload: Record<string, unknown>): string {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const choice of choices) {
|
||||
if (!isRecord(choice) || !isRecord(choice.message)) continue;
|
||||
const content = choice.message.content;
|
||||
if (typeof content === "string") {
|
||||
parts.push(content);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (typeof part.text === "string") {
|
||||
parts.push(part.text);
|
||||
} else if (isRecord(part.text) && typeof part.text.value === "string") {
|
||||
parts.push(part.text.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
export * from "./openapi-chat-provider-support.js";
|
||||
|
|
|
|||
|
|
@ -16,11 +16,20 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU
|
|||
const openApiChatSource = readFileSync(resolve(desktopRoot, "src/openapi-chat.ts"), "utf8");
|
||||
const openApiChatPromptFlowsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-prompt-flows.ts"), "utf8");
|
||||
const openApiChatProviderSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-provider.ts"), "utf8");
|
||||
const openApiChatProviderSupportSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-provider-support.ts"), "utf8");
|
||||
const openApiChatProviderRoutingSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-provider-routing.ts"), "utf8");
|
||||
const openApiChatProviderPayloadsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-provider-payloads.ts"), "utf8");
|
||||
|
||||
assert.match(openApiChatSource, /from "\.\/openapi-chat-prompt-flows(?:\.js)?"/, "openapi-chat must import the extracted prompt-flow seam.");
|
||||
assert.match(openApiChatPromptFlowsSource, /from "\.\/openapi-chat-provider(?:\.js)?"/, "openapi-chat prompt flows must import the extracted provider helper module.");
|
||||
assert.match(openApiChatProviderSource, /export function buildRequestAttempts/, "openapi-chat-provider must export request-attempt routing.");
|
||||
assert.match(openApiChatProviderSource, /export async function readProviderError/, "openapi-chat-provider must export provider error normalization.");
|
||||
assert.match(openApiChatProviderSource, /export \* from "\.\/openapi-chat-provider-support(?:\.js)?"/, "openapi-chat-provider must re-export the extracted provider support seam.");
|
||||
assert.match(openApiChatProviderSupportSource, /export \* from "\.\/openapi-chat-provider-routing(?:\.js)?"/, "openapi-chat provider support seam must re-export routing helpers.");
|
||||
assert.match(openApiChatProviderSupportSource, /export \* from "\.\/openapi-chat-provider-payloads(?:\.js)?"/, "openapi-chat provider support seam must re-export payload helpers.");
|
||||
assert.match(openApiChatProviderRoutingSource, /export function buildRequestAttempts/, "openapi-chat provider routing seam must export request-attempt routing.");
|
||||
assert.match(openApiChatProviderRoutingSource, /export async function readProviderError/, "openapi-chat provider routing seam must export provider error normalization.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function parseResponsesPayload/, "openapi-chat provider payload seam must export responses payload parsing.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function parseChatCompletionsPayload/, "openapi-chat provider payload seam must export chat-completions payload parsing.");
|
||||
assert.match(openApiChatProviderPayloadsSource, /export function extractChatCompletionsChoice/, "openapi-chat provider payload seam must export tool-call extraction.");
|
||||
|
||||
assert.deepEqual(
|
||||
buildRequestAttempts("https://openrouter.ai/api/v1/responses"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue