diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index eafd643ce..702d8239e 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -33,6 +33,7 @@ import { setupServerChannel } from './services/airi/channel-server' import { setupGodotStageManager } from './services/airi/godot-stage' import { setupBuiltInServer } from './services/airi/http-server' import { setupMcpStdioManager } from './services/airi/mcp-servers' +import { setupOpenAICompatibleFetchBridge } from './services/airi/openai-compatible-fetch' import { setupExtensionHost } from './services/airi/plugins' import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge' import { setupAutoUpdater } from './services/electron/auto-updater' @@ -259,6 +260,7 @@ app.whenReady().then(async () => { context, artistryConfig: deps.artistryConfig, }) + setupOpenAICompatibleFetchBridge({ context }) }, }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts b/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts new file mode 100644 index 000000000..0cd0ee103 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts @@ -0,0 +1,94 @@ +import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main' + +import { defineStreamInvokeHandler } from '@moeru/eventa' +import { openAICompatibleFetch } from '@proj-airi/stage-shared' + +function normalizeRequestMethod(method: string | undefined): string { + return (method ?? 'GET').toUpperCase() +} + +function normalizePathname(pathname: string): string { + return pathname.replace(/\/+$/, '') +} + +function isAllowedOpenAICompatiblePath(url: URL, method: string): boolean { + const pathname = normalizePathname(url.pathname) + if ((method === 'GET' || method === 'HEAD') && pathname.endsWith('/models')) + return true + + return method === 'POST' && pathname.endsWith('/chat/completions') +} + +function isWithinBaseUrl(url: URL, baseUrl: URL): boolean { + if (url.origin !== baseUrl.origin) + return false + + const basePathname = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/` + return url.pathname.startsWith(basePathname) +} + +function assertAllowedBridgeTarget(url: URL, baseUrl: URL, method: string) { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('OpenAI-compatible fetch only supports http and https URLs.') + } + if (baseUrl.protocol !== 'http:' && baseUrl.protocol !== 'https:') { + throw new Error('OpenAI-compatible fetch only supports http and https base URLs.') + } + if (!isWithinBaseUrl(url, baseUrl)) { + throw new Error('OpenAI-compatible fetch bridge only supports requests within the configured provider base URL.') + } + if (!isAllowedOpenAICompatiblePath(url, method)) { + throw new Error('OpenAI-compatible fetch bridge only supports model listing and chat completions requests.') + } +} + +export function setupOpenAICompatibleFetchBridge(params: { + context: ReturnType['context'] +}) { + defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) { + const url = new URL(payload.url) + const baseUrl = new URL(payload.baseUrl) + const method = normalizeRequestMethod(payload.method) + assertAllowedBridgeTarget(url, baseUrl, method) + + const response = await fetch(url, { + method, + headers: payload.headers, + body: payload.body, + signal: options?.abortController?.signal, + }) + + yield { + type: 'head' as const, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + } + + if (!response.body) + return + + const reader = response.body.getReader() + let completed = false + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + completed = true + return + } + + yield { type: 'chunk' as const, chunk: value } + } + } + finally { + if (!completed) { + try { + await reader.cancel() + } + catch {} + } + reader.releaseLock() + } + }) +} diff --git a/packages/stage-shared/src/index.ts b/packages/stage-shared/src/index.ts index 42ad7f6ba..1cda6278b 100644 --- a/packages/stage-shared/src/index.ts +++ b/packages/stage-shared/src/index.ts @@ -3,6 +3,7 @@ export * from './env-vars' export * from './environment' export * from './error-message' export * from './export-csv' +export * from './openai-compatible' export * from './perf/io-trace' export * from './perf/tracer' export * from './url' diff --git a/packages/stage-shared/src/openai-compatible.ts b/packages/stage-shared/src/openai-compatible.ts new file mode 100644 index 000000000..048c60775 --- /dev/null +++ b/packages/stage-shared/src/openai-compatible.ts @@ -0,0 +1,25 @@ +import { defineInvokeEventa } from '@moeru/eventa' + +export interface OpenAICompatibleFetchRequest { + url: string + baseUrl: string + method?: string + headers?: Record + body?: string +} + +export interface OpenAICompatibleFetchResponse { + type: 'head' + status: number + statusText: string + headers: Record +} + +export interface OpenAICompatibleFetchChunk { + type: 'chunk' + chunk: Uint8Array +} + +export type OpenAICompatibleFetchStreamEvent = OpenAICompatibleFetchResponse | OpenAICompatibleFetchChunk + +export const openAICompatibleFetch = defineInvokeEventa('eventa:invoke:electron:openai-compatible:fetch') diff --git a/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts b/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts new file mode 100644 index 000000000..f9c8b4a3d --- /dev/null +++ b/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts @@ -0,0 +1,135 @@ +import type { Fetch } from '@xsai/shared' + +import { defineStreamInvoke } from '@moeru/eventa' +import { createContext } from '@moeru/eventa/adapters/electron/renderer' +import { openAICompatibleFetch } from '@proj-airi/stage-shared' + +type IpcRendererLike = Parameters[0] + +const sharedFetches = new Map() + +function resolveElectronIpcRenderer(): IpcRendererLike | undefined { + if (typeof window === 'undefined') + return undefined + + return (window as { electron?: { ipcRenderer?: IpcRendererLike } }).electron?.ipcRenderer +} + +async function readRequestBody(init: RequestInit): Promise { + if (typeof init.body === 'string') + return init.body + if (init.body == null) + return undefined + + return await new Response(init.body).text() +} + +function headersToRecord(headers: HeadersInit | undefined): Record | undefined { + if (!headers) + return undefined + + const result: Record = {} + new Headers(headers).forEach((value, key) => { + result[key] = value + }) + return result +} + +function shouldUseElectronFetchFallback(error: unknown) { + if (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError') + return false + + return error instanceof TypeError +} + +function normalizeRequestMethod(init: RequestInit): string { + return (init.method ?? 'GET').toUpperCase() +} + +function canReplayRequest(method: string): boolean { + return method === 'GET' || method === 'HEAD' || method === 'OPTIONS' || method === 'TRACE' +} + +function createElectronFetch(ipcRenderer: IpcRendererLike, baseUrl: string): Fetch { + const { context } = createContext(ipcRenderer) + const invokeFetch = defineStreamInvoke(context, openAICompatibleFetch) + + return async (input, init) => { + const requestUrl = input.toString() + const requestMethod = normalizeRequestMethod(init) + + const fetchThroughElectron = async () => { + const responseEvents = invokeFetch({ + url: requestUrl, + baseUrl, + method: init.method, + headers: headersToRecord(init.headers), + body: await readRequestBody(init), + }, init.signal ? { signal: init.signal } : undefined) + const reader = responseEvents.getReader() + const head = await reader.read() + + if (head.done || head.value.type !== 'head') { + await reader.cancel() + throw new Error('OpenAI-compatible fetch bridge closed before response headers.') + } + + const body = new ReadableStream({ + async pull(controller) { + const next = await reader.read() + if (next.done) { + controller.close() + return + } + + if (next.value.type !== 'chunk') { + controller.error(new Error('OpenAI-compatible fetch bridge received an unexpected response event.')) + await reader.cancel() + return + } + + controller.enqueue(next.value.chunk) + }, + async cancel(reason) { + await reader.cancel(reason) + }, + }) + + return new Response(body, { + status: head.value.status, + statusText: head.value.statusText, + headers: head.value.headers, + }) + } + + if (!canReplayRequest(requestMethod)) + return await fetchThroughElectron() + + try { + return await globalThis.fetch(input, init) + } + catch (error) { + if (!shouldUseElectronFetchFallback(error)) + throw error + + return await fetchThroughElectron() + } + } +} + +export function resolveOpenAICompatibleFetch(baseUrl: string | URL | null | undefined): Fetch | undefined { + if (!baseUrl) + return undefined + + const ipcRenderer = resolveElectronIpcRenderer() + if (!ipcRenderer) + return undefined + + const cacheKey = baseUrl.toString() + let fetch = sharedFetches.get(cacheKey) + if (!fetch) { + fetch = createElectronFetch(ipcRenderer, cacheKey) + sharedFetches.set(cacheKey, fetch) + } + return fetch +} diff --git a/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts b/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts index a5b68493c..42bdea2a2 100644 --- a/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts @@ -1,6 +1,7 @@ -import { createOpenAI } from '@xsai-ext/providers/create' +import { createChatProvider, createModelProvider, merge } from '@xsai-ext/providers/utils' import { z } from 'zod' +import { resolveOpenAICompatibleFetch } from '../../openaiCompatibleFetch' import { ProviderValidationCheck } from '../../types' import { createOpenAICompatibleValidators } from '../../validators' import { defineProvider } from '../registry' @@ -41,7 +42,17 @@ export const providerOpenAICompatible = defineProvider({ }), }), createProvider(config) { - return createOpenAI(config.apiKey as string, config.baseUrl) + const fetch = resolveOpenAICompatibleFetch(config.baseUrl) + const providerOptions = () => ({ + apiKey: config.apiKey, + baseURL: config.baseUrl!, + ...(fetch ? { fetch } : {}), + }) + + return merge( + createChatProvider(providerOptions()), + createModelProvider(providerOptions()), + ) }, validationRequiredWhen(config) { diff --git a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts index f458a221f..574a5146e 100644 --- a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts +++ b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts @@ -8,6 +8,7 @@ import { listModels } from '@xsai/model' import { message } from '@xsai/utils-chat' import { Mutex } from 'es-toolkit' +import { resolveOpenAICompatibleFetch } from '../openaiCompatibleFetch' import { isModelProvider, ProviderValidationCheck } from '../types' interface OpenAICompatibleValidationOptions { @@ -78,7 +79,12 @@ async function resolveModels controller.abort(), 10_000) try { - const response = await fetch(modelsUrl, { + const electronFetch = resolveOpenAICompatibleFetch(config.baseUrl) + const requestInit: RequestInit = { method: 'GET', headers: { ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), ...additionalHeaders, }, signal: controller.signal, - }) + } + const response = electronFetch + ? await electronFetch(new URL(modelsUrl), requestInit) + : await globalThis.fetch(modelsUrl, requestInit) if (response.status >= 500) { const errorMessage = `Server error: HTTP ${response.status}` diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index 159e81bab..cb32b764a 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -7,6 +7,7 @@ import { listModels } from '@xsai/model' import { defineStore } from 'pinia' import { ref } from 'vue' +import { resolveOpenAICompatibleFetch } from '../libs/providers/openaiCompatibleFetch' import { resolveLlmTools } from './llm-tool-resolver' export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent' @@ -62,9 +63,11 @@ export const useLLM = defineStore('llm', () => { return [] try { + const fetch = resolveOpenAICompatibleFetch(apiUrl) return await listModels({ baseURL: (apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`) as `${string}/`, apiKey, + ...(fetch ? { fetch } : {}), }) } catch (err) { diff --git a/packages/stage-ui/src/stores/providers/converters.ts b/packages/stage-ui/src/stores/providers/converters.ts index f3fbb08d0..1e1ef2875 100644 --- a/packages/stage-ui/src/stores/providers/converters.ts +++ b/packages/stage-ui/src/stores/providers/converters.ts @@ -5,6 +5,7 @@ import type { ProviderMetadata } from '../providers' import { listModels } from '@xsai/model' +import { resolveOpenAICompatibleFetch } from '../../libs/providers/openaiCompatibleFetch' import { resolveProviderSourceMetadata } from '../../libs/providers/source-metadata' import { CHAT_COMPLETIONS_VALIDATOR_ID, isModelProvider } from '../../libs/providers/types' import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run' @@ -153,9 +154,11 @@ export function convertProviderDefinitionToMetadata( if (!baseUrl) return [] + const fetch = resolveOpenAICompatibleFetch(baseUrl) const models = await listModels({ baseURL: baseUrl, ...(apiKey ? { apiKey } : {}), + ...(fetch ? { fetch } : {}), }) return mapModelsToMetadataModels(definition.id, models as any[]) }