From 9c6677564c5c59dc546ddb0f7c122a132a365df3 Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 21:15:17 +0000 Subject: [PATCH] Extract OpenAPI provider routing and payload seams --- apps/desktop/src/check-packaging-contract.ts | 12 + apps/desktop/src/codemap.md | 5 +- .../src/openapi-chat-provider-payloads.ts | 98 +++++ .../src/openapi-chat-provider-routing.ts | 241 +++++++++++++ .../src/openapi-chat-provider-support.ts | 2 + apps/desktop/src/openapi-chat-provider.ts | 341 +----------------- .../tests/openapi-chat-provider.test.ts | 13 +- 7 files changed, 369 insertions(+), 343 deletions(-) create mode 100644 apps/desktop/src/openapi-chat-provider-payloads.ts create mode 100644 apps/desktop/src/openapi-chat-provider-routing.ts create mode 100644 apps/desktop/src/openapi-chat-provider-support.ts diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 366b18b8..6c99aa4f 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -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."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 28bdb8bd..fd158c27 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -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**: diff --git a/apps/desktop/src/openapi-chat-provider-payloads.ts b/apps/desktop/src/openapi-chat-provider-payloads.ts new file mode 100644 index 00000000..4f0b87c2 --- /dev/null +++ b/apps/desktop/src/openapi-chat-provider-payloads.ts @@ -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): { 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): { 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): 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).message; + if (!message || typeof message !== "object") return undefined; + const msg = message as Record; + 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; + 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; + 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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/openapi-chat-provider-routing.ts b/apps/desktop/src/openapi-chat-provider-routing.ts new file mode 100644 index 00000000..62a40546 --- /dev/null +++ b/apps/desktop/src/openapi-chat-provider-routing.ts @@ -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 { + const headers: Record = { + "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 { + 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): 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(); + 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(); +} diff --git a/apps/desktop/src/openapi-chat-provider-support.ts b/apps/desktop/src/openapi-chat-provider-support.ts new file mode 100644 index 00000000..47a56ae4 --- /dev/null +++ b/apps/desktop/src/openapi-chat-provider-support.ts @@ -0,0 +1,2 @@ +export * from "./openapi-chat-provider-routing.js"; +export * from "./openapi-chat-provider-payloads.js"; diff --git a/apps/desktop/src/openapi-chat-provider.ts b/apps/desktop/src/openapi-chat-provider.ts index ac1e25f3..73aaa443 100644 --- a/apps/desktop/src/openapi-chat-provider.ts +++ b/apps/desktop/src/openapi-chat-provider.ts @@ -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): { 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): { 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 { - const headers: Record = { - "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 { - 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): 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): 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).message; - if (!message || typeof message !== "object") return undefined; - const msg = message as Record; - 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; - 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; - 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(); - 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 { - 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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export * from "./openapi-chat-provider-support.js"; diff --git a/apps/desktop/tests/openapi-chat-provider.test.ts b/apps/desktop/tests/openapi-chat-provider.test.ts index ffb099eb..b5ae6167 100644 --- a/apps/desktop/tests/openapi-chat-provider.test.ts +++ b/apps/desktop/tests/openapi-chat-provider.test.ts @@ -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"),