From cfd0ac697279275364d1f0e443c6031b67a08876 Mon Sep 17 00:00:00 2001 From: jim139129 Date: Fri, 26 Jun 2026 11:42:29 +0800 Subject: [PATCH 1/3] fix(stage-ui): proxy openai-compatible requests in electron --- apps/stage-tamagotchi/src/main/index.ts | 2 + .../services/airi/openai-compatible-fetch.ts | 55 ++++++++ packages/stage-shared/src/index.ts | 1 + .../stage-shared/src/openai-compatible.ts | 24 ++++ .../libs/providers/openaiCompatibleFetch.ts | 126 ++++++++++++++++++ .../providers/openai-compatible/index.ts | 15 ++- .../providers/validators/openai-compatible.ts | 18 ++- packages/stage-ui/src/stores/llm.ts | 3 + .../src/stores/providers/converters.ts | 3 + 9 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts create mode 100644 packages/stage-shared/src/openai-compatible.ts create mode 100644 packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts 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..ead3c91d7 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts @@ -0,0 +1,55 @@ +import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main' + +import { defineStreamInvokeHandler } from '@moeru/eventa' +import { openAICompatibleFetch } from '@proj-airi/stage-shared' + +export function setupOpenAICompatibleFetchBridge(params: { + context: ReturnType['context'] +}) { + defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) { + const url = new URL(payload.url) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('OpenAI-compatible fetch only supports http and https URLs.') + } + + const response = await fetch(url, { + method: payload.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..2507d84ea --- /dev/null +++ b/packages/stage-shared/src/openai-compatible.ts @@ -0,0 +1,24 @@ +import { defineInvokeEventa } from '@moeru/eventa' + +export interface OpenAICompatibleFetchRequest { + url: 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..a7936730d --- /dev/null +++ b/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts @@ -0,0 +1,126 @@ +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] + +let sharedFetch: Fetch | undefined + +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): 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, + 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(): Fetch | undefined { + const ipcRenderer = resolveElectronIpcRenderer() + if (!ipcRenderer) + return undefined + + sharedFetch ??= createElectronFetch(ipcRenderer) + return sharedFetch +} 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..340a448a6 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() + 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..bf41883bd 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() + 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 a61ce91c8..10a723e6a 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -9,6 +9,7 @@ import { uniqBy } from 'es-toolkit' import { defineStore } from 'pinia' import { ref } from 'vue' +import { resolveOpenAICompatibleFetch } from '../libs/providers/openaiCompatibleFetch' import { createSparkCommandTool, debug, mcp } from '../tools' import { useLlmToolsStore } from './llm-tools' import { useModsServerChannelStore } from './mods/api/channel-server' @@ -107,9 +108,11 @@ export const useLLM = defineStore('llm', () => { return [] try { + const fetch = resolveOpenAICompatibleFetch() 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..f40c09790 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' @@ -143,6 +144,7 @@ export function convertProviderDefinitionToMetadata( : async (config) => { const provider = await definition.createProvider(config as any) try { + const fetch = resolveOpenAICompatibleFetch() if (isModelProvider(provider)) { const models = await listModels(provider.model()) return mapModelsToMetadataModels(definition.id, models as any[]) @@ -156,6 +158,7 @@ export function convertProviderDefinitionToMetadata( const models = await listModels({ baseURL: baseUrl, ...(apiKey ? { apiKey } : {}), + ...(fetch ? { fetch } : {}), }) return mapModelsToMetadataModels(definition.id, models as any[]) } From bcb4331e5b3c06e3998ccd44c929eec43a5d859d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:57:18 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- packages/stage-ui/src/stores/llm.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index 738c4ec7e..e7e0a01f8 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -8,9 +8,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { resolveOpenAICompatibleFetch } from '../libs/providers/openaiCompatibleFetch' -import { createSparkCommandTool, debug, mcp } from '../tools' -import { useLlmToolsStore } from './llm-tools' -import { useModsServerChannelStore } from './mods/api/channel-server' import { resolveLlmTools } from './llm-tool-resolver' export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent' From 8b475abfe53038e6514f674712b7fe233efbfce3 Mon Sep 17 00:00:00 2001 From: jim139129 Date: Fri, 26 Jun 2026 16:08:33 +0800 Subject: [PATCH 3/3] fix(stage-ui): scope openai-compatible fetch bridge --- .../services/airi/openai-compatible-fetch.ts | 47 +++++++++++++++++-- .../stage-shared/src/openai-compatible.ts | 1 + .../libs/providers/openaiCompatibleFetch.ts | 19 ++++++-- .../providers/openai-compatible/index.ts | 2 +- .../providers/validators/openai-compatible.ts | 6 +-- packages/stage-ui/src/stores/llm.ts | 2 +- .../src/stores/providers/converters.ts | 2 +- 7 files changed, 64 insertions(+), 15 deletions(-) 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 index ead3c91d7..0cd0ee103 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts @@ -3,17 +3,56 @@ import type { createContext as createMainEventaContext } from '@moeru/eventa/ada 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) - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new Error('OpenAI-compatible fetch only supports http and https URLs.') - } + const baseUrl = new URL(payload.baseUrl) + const method = normalizeRequestMethod(payload.method) + assertAllowedBridgeTarget(url, baseUrl, method) const response = await fetch(url, { - method: payload.method, + method, headers: payload.headers, body: payload.body, signal: options?.abortController?.signal, diff --git a/packages/stage-shared/src/openai-compatible.ts b/packages/stage-shared/src/openai-compatible.ts index 2507d84ea..048c60775 100644 --- a/packages/stage-shared/src/openai-compatible.ts +++ b/packages/stage-shared/src/openai-compatible.ts @@ -2,6 +2,7 @@ import { defineInvokeEventa } from '@moeru/eventa' export interface OpenAICompatibleFetchRequest { url: string + baseUrl: string method?: string headers?: Record body?: string diff --git a/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts b/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts index a7936730d..f9c8b4a3d 100644 --- a/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts +++ b/packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts @@ -6,7 +6,7 @@ import { openAICompatibleFetch } from '@proj-airi/stage-shared' type IpcRendererLike = Parameters[0] -let sharedFetch: Fetch | undefined +const sharedFetches = new Map() function resolveElectronIpcRenderer(): IpcRendererLike | undefined { if (typeof window === 'undefined') @@ -50,7 +50,7 @@ function canReplayRequest(method: string): boolean { return method === 'GET' || method === 'HEAD' || method === 'OPTIONS' || method === 'TRACE' } -function createElectronFetch(ipcRenderer: IpcRendererLike): Fetch { +function createElectronFetch(ipcRenderer: IpcRendererLike, baseUrl: string): Fetch { const { context } = createContext(ipcRenderer) const invokeFetch = defineStreamInvoke(context, openAICompatibleFetch) @@ -61,6 +61,7 @@ function createElectronFetch(ipcRenderer: IpcRendererLike): Fetch { const fetchThroughElectron = async () => { const responseEvents = invokeFetch({ url: requestUrl, + baseUrl, method: init.method, headers: headersToRecord(init.headers), body: await readRequestBody(init), @@ -116,11 +117,19 @@ function createElectronFetch(ipcRenderer: IpcRendererLike): Fetch { } } -export function resolveOpenAICompatibleFetch(): Fetch | undefined { +export function resolveOpenAICompatibleFetch(baseUrl: string | URL | null | undefined): Fetch | undefined { + if (!baseUrl) + return undefined + const ipcRenderer = resolveElectronIpcRenderer() if (!ipcRenderer) return undefined - sharedFetch ??= createElectronFetch(ipcRenderer) - return sharedFetch + 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 340a448a6..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 @@ -42,7 +42,7 @@ export const providerOpenAICompatible = defineProvider({ }), }), createProvider(config) { - const fetch = resolveOpenAICompatibleFetch() + const fetch = resolveOpenAICompatibleFetch(config.baseUrl) const providerOptions = () => ({ apiKey: config.apiKey, baseURL: config.baseUrl!, 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 bf41883bd..574a5146e 100644 --- a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts +++ b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts @@ -79,7 +79,7 @@ async function resolveModels controller.abort(), 10_000) try { - const electronFetch = resolveOpenAICompatibleFetch() + const electronFetch = resolveOpenAICompatibleFetch(config.baseUrl) const requestInit: RequestInit = { method: 'GET', headers: { diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index e7e0a01f8..cb32b764a 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -63,7 +63,7 @@ export const useLLM = defineStore('llm', () => { return [] try { - const fetch = resolveOpenAICompatibleFetch() + const fetch = resolveOpenAICompatibleFetch(apiUrl) return await listModels({ baseURL: (apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`) as `${string}/`, apiKey, diff --git a/packages/stage-ui/src/stores/providers/converters.ts b/packages/stage-ui/src/stores/providers/converters.ts index f40c09790..1e1ef2875 100644 --- a/packages/stage-ui/src/stores/providers/converters.ts +++ b/packages/stage-ui/src/stores/providers/converters.ts @@ -144,7 +144,6 @@ export function convertProviderDefinitionToMetadata( : async (config) => { const provider = await definition.createProvider(config as any) try { - const fetch = resolveOpenAICompatibleFetch() if (isModelProvider(provider)) { const models = await listModels(provider.model()) return mapModelsToMetadataModels(definition.id, models as any[]) @@ -155,6 +154,7 @@ export function convertProviderDefinitionToMetadata( if (!baseUrl) return [] + const fetch = resolveOpenAICompatibleFetch(baseUrl) const models = await listModels({ baseURL: baseUrl, ...(apiKey ? { apiKey } : {}),