From d742937b86c9036067438bd8425de24d3df49d3f Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:40:05 +0800 Subject: [PATCH 1/8] unified gateway --- apps/server/src/http.ts | 190 ++++++++++ apps/server/src/provider/Gateway/Errors.ts | 100 ++++++ .../Gateway/Layers/GatewayServiceLive.ts | 334 ++++++++++++++++++ .../Gateway/Services/GatewayService.ts | 110 ++++++ apps/server/src/serverLayers.ts | 6 + apps/server/src/wsRpc.ts | 28 ++ apps/web/src/lib/serverReactQuery.ts | 1 + apps/web/src/routes/_chat.settings.tsx | 190 +++++++++- apps/web/src/wsNativeApi.ts | 12 + packages/contracts/src/gateway.ts | 189 ++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 30 ++ packages/contracts/src/rpc.ts | 86 +++++ packages/contracts/src/settings.ts | 34 ++ packages/contracts/src/ws.ts | 33 ++ 15 files changed, 1326 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/provider/Gateway/Errors.ts create mode 100644 apps/server/src/provider/Gateway/Layers/GatewayServiceLive.ts create mode 100644 apps/server/src/provider/Gateway/Services/GatewayService.ts create mode 100644 packages/contracts/src/gateway.ts diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index d7014dd..a63d51a 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -6,6 +6,9 @@ import { AuthCreatePairingCredentialInput, AuthRevokeClientSessionInput, AuthRevokePairingLinkInput, + type GatewayConfig, + type GatewayUpstreamConfig, + type GatewayUpstreamProvider, } from "@peakcode/contracts"; import { DateTime, Effect, Exit, FileSystem, Layer, Path, Schema, Stream } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -18,6 +21,7 @@ import { import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { authErrorResponse, makeEffectAuthRequest, serveAuthHttpRoute } from "./auth/http"; import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import type { ServerAuthShape } from "./auth/Services/ServerAuth"; import type { SessionCredentialServiceShape } from "./auth/Services/SessionCredentialService"; import { SessionCredentialService } from "./auth/Services/SessionCredentialService"; @@ -27,6 +31,7 @@ import { LOCAL_IMAGE_ROUTE_PATH, resolveAllowedLocalImageFile } from "./localIma import type { ProjectFaviconResolverShape } from "./project/Services/ProjectFaviconResolver"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; import type { ServerReadiness } from "./server/readiness"; +import { ServerSettingsService } from "./serverSettings"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const decodeBootstrapInput = Schema.decodeUnknownEffect(AuthBootstrapInput); @@ -58,6 +63,7 @@ export function makeEffectHttpRouteLayer(readiness: ServerReadiness) { ), ), authEffectRouteLayer, + gatewayEffectRouteLayer, projectFaviconEffectRouteLayer, localImageEffectRouteLayer, attachmentsEffectRouteLayer, @@ -71,6 +77,17 @@ const requireAuthenticatedRequest = Effect.gen(function* () { yield* serverAuth.authenticateHttpRequest(makeEffectAuthRequest(request)); }); +const requireGatewayRequestAccess = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const config = yield* ServerConfig; + const url = HttpServerRequest.toURL(request); + if (url && isLegacyTokenAuthorized({ config, url })) { + return; + } + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(makeEffectAuthRequest(request)); +}); + export function isLegacyTokenAuthorized(input: { readonly config: ServerConfigShape; readonly url: URL; @@ -264,6 +281,179 @@ const authEffectRouteLayer = HttpRouter.add( ), ); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function upstreamSecretName(provider: GatewayUpstreamProvider): string { + return `gateway.upstream.${provider}.apiKey`; +} + +function joinGatewayUrl(baseUrl: string, suffix: string): string { + return `${baseUrl.replace(/\/+$/u, "")}/${suffix.replace(/^\/+/u, "")}`; +} + +function resolveGatewayUpstream(input: { + readonly config: GatewayConfig; + readonly model: string | null; +}): { + readonly upstream: GatewayUpstreamConfig | null; + readonly requestedModel: string | null; + readonly resolvedModel: string | null; +} { + const enabledProviders = new Set(input.config.upstreamProviders.map((upstream) => upstream.provider)); + const slashIndex = input.model?.indexOf("/") ?? -1; + const prefixedProvider = + input.model && slashIndex > 0 + ? (input.model.slice(0, slashIndex) as GatewayUpstreamProvider) + : null; + const provider = + prefixedProvider && enabledProviders.has(prefixedProvider) + ? prefixedProvider + : input.config.defaultUpstreamProvider; + const upstream = + input.config.upstreamProviders.find((candidate) => candidate.provider === provider) ?? null; + const requestedModel = + input.model && prefixedProvider === provider + ? input.model.slice(slashIndex + 1) + : input.model || upstream?.defaultModel || null; + const resolvedModel = requestedModel + ? (upstream?.modelAliases[requestedModel] ?? requestedModel) + : null; + return { upstream, requestedModel, resolvedModel }; +} + +function gatewayErrorResponse(message: string, status: number) { + return HttpServerResponse.jsonUnsafe( + { + error: { + message, + type: "peakcode_gateway_error", + }, + }, + { status }, + ); +} + +const gatewayEffectRouteLayer = HttpRouter.add( + "*", + "/gateway/openai/v1/*", + Effect.gen(function* () { + yield* requireGatewayRequestAccess; + + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (!url) return gatewayErrorResponse("Bad Request", 400); + + const settings = yield* ServerSettingsService; + const secretStore = yield* ServerSecretStore; + const gatewayConfig = (yield* settings.getSettings).gateway; + if (!gatewayConfig.enabled) { + return gatewayErrorResponse("PeakCode gateway is disabled.", 503); + } + + if (request.method === "GET" && url.pathname === "/gateway/openai/v1/models") { + const data = gatewayConfig.upstreamProviders.flatMap((upstream) => { + if (!upstream.enabled) return []; + const modelIds = new Set(upstream.customModels); + if (upstream.defaultModel) modelIds.add(upstream.defaultModel); + return Array.from(modelIds).map((model) => ({ + id: `${upstream.provider}/${model}`, + object: "model", + created: 0, + owned_by: upstream.provider, + })); + }); + return HttpServerResponse.jsonUnsafe({ object: "list", data }); + } + + if (request.method !== "POST" || url.pathname !== "/gateway/openai/v1/chat/completions") { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const payload = yield* readEffectJson(request, "Invalid gateway chat completions payload.").pipe( + Effect.mapError(() => ({ message: "Invalid JSON request body.", status: 400 as const })), + ); + if (!isRecord(payload)) { + return gatewayErrorResponse("Request body must be a JSON object.", 400); + } + + const requestedModel = typeof payload.model === "string" ? payload.model.trim() : null; + const { upstream, resolvedModel } = resolveGatewayUpstream({ + config: gatewayConfig, + model: requestedModel, + }); + if (!upstream) { + return gatewayErrorResponse("No gateway upstream provider is configured.", 400); + } + if (!upstream.enabled) { + return gatewayErrorResponse(`Gateway upstream provider '${upstream.provider}' is disabled.`, 400); + } + if (upstream.protocol !== "openai-compatible") { + return gatewayErrorResponse( + `Gateway upstream provider '${upstream.provider}' is not OpenAI-compatible.`, + 400, + ); + } + if (!resolvedModel) { + return gatewayErrorResponse( + `Gateway upstream provider '${upstream.provider}' has no model configured.`, + 400, + ); + } + if (upstream.baseUrl.trim().length === 0) { + return gatewayErrorResponse( + `Gateway upstream provider '${upstream.provider}' has no base URL configured.`, + 400, + ); + } + + const apiKeyBytes = yield* secretStore.get(upstreamSecretName(upstream.provider)); + const apiKey = apiKeyBytes ? new TextDecoder().decode(apiKeyBytes).trim() : ""; + if (apiKey.length === 0) { + return gatewayErrorResponse( + `Gateway upstream provider '${upstream.provider}' has no API key configured.`, + 401, + ); + } + + const upstreamResponse = yield* Effect.tryPromise({ + try: () => + fetch(joinGatewayUrl(upstream.baseUrl, "/chat/completions"), { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + "Accept": request.headers.accept ?? "application/json", + }, + body: JSON.stringify({ + ...payload, + model: resolvedModel, + }), + }), + catch: (cause) => ({ + message: cause instanceof Error ? cause.message : "Gateway upstream request failed.", + status: 502 as const, + }), + }); + + return HttpServerResponse.fromWeb(upstreamResponse); + }).pipe( + Effect.catch((error) => + Effect.succeed( + gatewayErrorResponse( + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : "Gateway request failed.", + typeof (error as { status?: unknown }).status === "number" + ? (error as { status: number }).status + : 500, + ), + ), + ), + ), +); + const projectFaviconEffectRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", diff --git a/apps/server/src/provider/Gateway/Errors.ts b/apps/server/src/provider/Gateway/Errors.ts new file mode 100644 index 0000000..41a4468 --- /dev/null +++ b/apps/server/src/provider/Gateway/Errors.ts @@ -0,0 +1,100 @@ +/** + * Gateway Errors - Typed errors for gateway service operations. + * + * Gateway-specific errors complement ProviderServiceError for operations + * like model discovery, routing, health monitoring, and configuration. + */ + +import { Schema } from "effect"; + +/** + * GatewayDiscoveryError - Failed to discover models from a provider. + */ +export class GatewayDiscoveryError extends Schema.TaggedErrorClass()( + "GatewayDiscoveryError", + { + provider: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Gateway discovery failed for '${this.provider}' in ${this.operation}: ${this.detail}`; + } +} + +/** + * GatewayRoutingError - Model selection or routing failed. + */ +export class GatewayRoutingError extends Schema.TaggedErrorClass()( + "GatewayRoutingError", + { + provider: Schema.String, + model: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Gateway routing failed for '${this.provider}/${this.model}' in ${this.operation}: ${this.detail}`; + } +} + +/** + * GatewayHealthError - Provider health check failed. + */ +export class GatewayHealthError extends Schema.TaggedErrorClass()( + "GatewayHealthError", + { + provider: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Gateway health check failed for '${this.provider}': ${this.detail}`; + } +} + +/** + * GatewayConfigurationError - Gateway configuration read/write failed. + */ +export class GatewayConfigurationError extends Schema.TaggedErrorClass()( + "GatewayConfigurationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Gateway configuration error in ${this.operation}: ${this.detail}`; + } +} + +/** + * GatewayCapabilitiesError - Failed to retrieve provider capabilities. + */ +export class GatewayCapabilitiesError extends Schema.TaggedErrorClass()( + "GatewayCapabilitiesError", + { + provider: Schema.String, + model: Schema.optional(Schema.String), + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + const modelPart = this.model ? `/${this.model}` : ""; + return `Gateway capabilities error for '${this.provider}${modelPart}': ${this.detail}`; + } +} + +export type GatewayError = + | GatewayDiscoveryError + | GatewayRoutingError + | GatewayHealthError + | GatewayConfigurationError + | GatewayCapabilitiesError; diff --git a/apps/server/src/provider/Gateway/Layers/GatewayServiceLive.ts b/apps/server/src/provider/Gateway/Layers/GatewayServiceLive.ts new file mode 100644 index 0000000..beda77c --- /dev/null +++ b/apps/server/src/provider/Gateway/Layers/GatewayServiceLive.ts @@ -0,0 +1,334 @@ +import type { + GatewayConfig, + GatewayDiscoverModelsResult, + GatewayModelDescriptor, + GatewayProviderHealth, + GatewaySecretStatus, + GatewayUpstreamConfig, + GatewayUpstreamProvider, +} from "@peakcode/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerSecretStore } from "../../../auth/Services/ServerSecretStore"; +import { ServerSettingsService } from "../../../serverSettings"; +import { GatewayConfigurationError, GatewayRoutingError } from "../Errors"; +import { GatewayService, type GatewayServiceShape } from "../Services/GatewayService"; + +const textEncoder = new TextEncoder(); + +function secretName(provider: GatewayUpstreamProvider): string { + return `gateway.upstream.${provider}.apiKey`; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function modelIdsFor(upstream: GatewayUpstreamConfig): string[] { + const ids = new Set(); + if (upstream.defaultModel) { + ids.add(upstream.defaultModel); + } + for (const model of upstream.customModels) { + ids.add(model); + } + return Array.from(ids); +} + +function modelsFor(upstream: GatewayUpstreamConfig): GatewayModelDescriptor[] { + return modelIdsFor(upstream).map((id) => ({ + id, + name: id, + upstreamProvider: upstream.provider, + })); +} + +function findUpstream( + config: GatewayConfig, + provider: GatewayUpstreamProvider, +): GatewayUpstreamConfig | null { + return config.upstreamProviders.find((upstream) => upstream.provider === provider) ?? null; +} + +function applyGatewayPatch( + current: GatewayConfig, + patch: Parameters[0], +): GatewayConfig { + const upstreamPatchByProvider = new Map( + (patch.upstreamProviders ?? []).map((upstreamPatch) => [upstreamPatch.provider, upstreamPatch]), + ); + const patchedUpstreams = + patch.upstreamProviders === undefined + ? current.upstreamProviders + : current.upstreamProviders.map((upstream) => { + const upstreamPatch = upstreamPatchByProvider.get(upstream.provider); + if (!upstreamPatch) { + return upstream; + } + return { + ...upstream, + ...upstreamPatch, + provider: upstream.provider, + customModels: upstreamPatch.customModels ?? upstream.customModels, + modelAliases: upstreamPatch.modelAliases ?? upstream.modelAliases, + }; + }); + + return { + ...current, + ...patch, + upstreamProviders: patchedUpstreams, + }; +} + +const secretStatusFor = (secretStore: ServerSecretStore, provider: GatewayUpstreamProvider) => + secretStore.get(secretName(provider)).pipe( + Effect.map( + (value): GatewaySecretStatus => ({ + provider, + hasApiKey: value !== null && value.byteLength > 0, + }), + ), + Effect.mapError( + (cause) => + new GatewayConfigurationError({ + operation: "getSecretStatus", + detail: `Failed to read API key status for '${provider}'.`, + cause, + }), + ), + ); + +export const makeGatewayServiceLive = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const secretStore = yield* ServerSecretStore; + + const getConfig = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.gateway), + Effect.mapError( + (cause) => + new GatewayConfigurationError({ + operation: "getGatewayConfig", + detail: "Failed to read gateway settings.", + cause, + }), + ), + ); + + const getConfiguredSecretStatuses = Effect.gen(function* () { + const config = yield* getConfig; + const secrets = yield* Effect.all( + config.upstreamProviders.map((upstream) => secretStatusFor(secretStore, upstream.provider)), + ); + return { secrets }; + }); + + const getUpstreams = (provider?: GatewayUpstreamProvider) => + getConfig.pipe( + Effect.map((config) => + provider + ? config.upstreamProviders.filter((upstream) => upstream.provider === provider) + : config.upstreamProviders, + ), + ); + + const discoverModels: GatewayServiceShape["discoverModels"] = (input) => + Effect.gen(function* () { + const discoveredAt = nowIso(); + const upstreams = yield* getUpstreams(input.provider); + const results: GatewayDiscoverModelsResult["results"] = upstreams.map((upstream) => ({ + provider: upstream.provider, + models: modelsFor(upstream), + source: "static", + discoveredAt, + cacheHit: false, + })); + return { + results, + totalProviders: results.length, + discoveredAt, + }; + }); + + const selectModel: GatewayServiceShape["selectModel"] = (input) => + Effect.gen(function* () { + const config = yield* getConfig; + const provider = input.upstreamProvider ?? config.defaultUpstreamProvider; + const upstream = findUpstream(config, provider); + if (!upstream) { + return yield* new GatewayRoutingError({ + provider, + model: input.model ?? "", + operation: "selectModel", + detail: `Gateway upstream provider '${provider}' is not configured.`, + }); + } + if (!upstream.enabled) { + return yield* new GatewayRoutingError({ + provider, + model: input.model ?? "", + operation: "selectModel", + detail: `Gateway upstream provider '${provider}' is disabled.`, + }); + } + + const requestedModel = input.model ?? upstream.defaultModel; + if (!requestedModel) { + return yield* new GatewayRoutingError({ + provider, + model: "", + operation: "selectModel", + detail: `Gateway upstream provider '${provider}' has no default model.`, + }); + } + + const resolvedModel = upstream.modelAliases[requestedModel] ?? requestedModel; + return { + upstreamProvider: provider, + model: requestedModel, + resolvedModel, + baseUrl: upstream.baseUrl, + protocol: upstream.protocol, + }; + }); + + const getAvailableProviders: GatewayServiceShape["getAvailableProviders"] = () => + Effect.gen(function* () { + const config = yield* getConfig; + const secretStatuses = yield* Effect.all( + config.upstreamProviders.map((upstream) => secretStatusFor(secretStore, upstream.provider)), + ); + const hasApiKeyByProvider = new Map( + secretStatuses.map((status) => [status.provider, status.hasApiKey]), + ); + return { + providers: config.upstreamProviders.map((upstream) => { + const hasApiKey = hasApiKeyByProvider.get(upstream.provider) ?? false; + return { + provider: upstream.provider, + displayName: upstream.displayName, + protocol: upstream.protocol, + baseUrl: upstream.baseUrl, + enabled: upstream.enabled, + configured: upstream.enabled && upstream.baseUrl.trim().length > 0 && hasApiKey, + hasApiKey, + availableModelCount: modelIdsFor(upstream).length, + }; + }), + }; + }); + + const getProviderHealth: GatewayServiceShape["getProviderHealth"] = (input) => + Effect.gen(function* () { + const checkedAt = nowIso(); + const upstreams = yield* getUpstreams(input.provider); + const secretStatuses = yield* Effect.all( + upstreams.map((upstream) => secretStatusFor(secretStore, upstream.provider)), + ); + const hasApiKeyByProvider = new Map( + secretStatuses.map((status) => [status.provider, status.hasApiKey]), + ); + const providers: GatewayProviderHealth[] = upstreams.map((upstream) => { + const hasApiKey = hasApiKeyByProvider.get(upstream.provider) ?? false; + const hasBaseUrl = upstream.baseUrl.trim().length > 0; + const status = + upstream.enabled && hasBaseUrl && hasApiKey + ? "healthy" + : upstream.enabled + ? "degraded" + : "unavailable"; + return { + provider: upstream.provider, + status, + lastChecked: checkedAt, + ...(status === "healthy" + ? {} + : { + errorMessage: !upstream.enabled + ? "Provider is disabled." + : !hasBaseUrl + ? "Provider base URL is not configured." + : "Provider API key is not configured.", + }), + availableModels: modelIdsFor(upstream), + consecutiveFailures: status === "healthy" ? 0 : 1, + }; + }); + return { providers, checkedAt }; + }); + + const updateGatewayConfig: GatewayServiceShape["updateGatewayConfig"] = (patch) => + Effect.gen(function* () { + const current = yield* getConfig; + const next = applyGatewayPatch(current, patch); + const updated = yield* serverSettings.updateSettings({ gateway: next }).pipe( + Effect.mapError( + (cause) => + new GatewayConfigurationError({ + operation: "updateGatewayConfig", + detail: "Failed to update gateway settings.", + cause, + }), + ), + ); + return updated.gateway; + }); + + const getCapabilities: GatewayServiceShape["getCapabilities"] = (input) => + Effect.gen(function* () { + const config = yield* getConfig; + const upstream = findUpstream(config, input.provider); + if (!upstream) { + return yield* new GatewayConfigurationError({ + operation: "getCapabilities", + detail: `Gateway upstream provider '${input.provider}' is not configured.`, + }); + } + return { + provider: upstream.provider, + protocol: upstream.protocol, + supportsRuntimeDiscovery: false, + }; + }); + + const setApiKey: GatewayServiceShape["setApiKey"] = (input) => + secretStore.set(secretName(input.provider), textEncoder.encode(input.apiKey)).pipe( + Effect.mapError( + (cause) => + new GatewayConfigurationError({ + operation: "setApiKey", + detail: `Failed to store API key for '${input.provider}'.`, + cause, + }), + ), + Effect.flatMap(() => getConfiguredSecretStatuses), + ); + + const removeApiKey: GatewayServiceShape["removeApiKey"] = (input) => + secretStore.remove(secretName(input.provider)).pipe( + Effect.mapError( + (cause) => + new GatewayConfigurationError({ + operation: "removeApiKey", + detail: `Failed to remove API key for '${input.provider}'.`, + cause, + }), + ), + Effect.flatMap(() => getConfiguredSecretStatuses), + ); + + return { + discoverModels, + selectModel, + getAvailableProviders, + getProviderHealth, + getGatewayConfig: () => getConfig, + updateGatewayConfig, + getCapabilities, + setApiKey, + removeApiKey, + getSecretStatus: () => getConfiguredSecretStatuses, + } satisfies GatewayServiceShape; +}); + +export const GatewayServiceLive = Layer.effect(GatewayService, makeGatewayServiceLive); diff --git a/apps/server/src/provider/Gateway/Services/GatewayService.ts b/apps/server/src/provider/Gateway/Services/GatewayService.ts new file mode 100644 index 0000000..c5b8e87 --- /dev/null +++ b/apps/server/src/provider/Gateway/Services/GatewayService.ts @@ -0,0 +1,110 @@ +/** + * GatewayService - Unified gateway service for model routing and provider abstraction. + * + * Gateway provides a unified interface over ProviderService for: + * - Model discovery (runtime + static fallback) + * - Model selection and routing + * - Provider health monitoring + * - Gateway configuration + * + * @module GatewayService + */ +import type { + GatewayAvailableProvidersResult, + GatewayCapabilities, + GatewayConfig, + GatewayConfigPatch, + GatewayDiscoverModelsInput, + GatewayDiscoverModelsResult, + GatewayGetCapabilitiesInput, + GatewayGetProviderHealthInput, + GatewayModelSelection, + GatewayProviderHealthResult, + GatewayRemoveApiKeyInput, + GatewaySecretStatusResult, + GatewaySelectModelInput, + GatewaySetApiKeyInput, +} from "@peakcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { GatewayError } from "../Errors"; + +/** + * GatewayServiceShape - Service API for unified gateway operations. + */ +export interface GatewayServiceShape { + /** + * Discover available models from providers. + */ + readonly discoverModels: ( + input: GatewayDiscoverModelsInput, + ) => Effect.Effect; + + /** + * Select a model with routing and alias resolution. + */ + readonly selectModel: ( + input: GatewaySelectModelInput, + ) => Effect.Effect; + + /** + * Get available providers with their status. + */ + readonly getAvailableProviders: () => Effect.Effect< + GatewayAvailableProvidersResult, + GatewayError + >; + + /** + * Get health status for one or all providers. + */ + readonly getProviderHealth: ( + input: GatewayGetProviderHealthInput, + ) => Effect.Effect; + + /** + * Get current gateway configuration. + */ + readonly getGatewayConfig: () => Effect.Effect; + + /** + * Update gateway configuration. + */ + readonly updateGatewayConfig: ( + patch: GatewayConfigPatch, + ) => Effect.Effect; + + /** + * Get capabilities for a provider + optional model. + */ + readonly getCapabilities: ( + input: GatewayGetCapabilitiesInput, + ) => Effect.Effect; + + /** + * Store a provider API key without exposing it through settings. + */ + readonly setApiKey: ( + input: GatewaySetApiKeyInput, + ) => Effect.Effect; + + /** + * Remove a stored provider API key. + */ + readonly removeApiKey: ( + input: GatewayRemoveApiKeyInput, + ) => Effect.Effect; + + /** + * Return secret presence only; never returns secret values. + */ + readonly getSecretStatus: () => Effect.Effect; +} + +/** + * GatewayService - Service tag for the unified gateway. + */ +export class GatewayService extends ServiceMap.Service()( + "t3/provider/Gateway/GatewayService", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index c86529d..70ac380 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -27,6 +27,7 @@ import { ServerSettingsLive } from "./serverSettings"; import { WorkspaceLayerLive } from "./workspace/runtimeLayer"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +import { GatewayServiceLive } from "./provider/Gateway/Layers/GatewayServiceLive"; export { makeServerProviderLayer } from "./provider/runtimeLayer"; @@ -86,6 +87,10 @@ export function makeServerRuntimeServicesLayer() { authControlPlaneLayer, serverAuthLayer, ); + const gatewayServiceLayer = GatewayServiceLive.pipe( + Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(ServerSecretStoreLive), + ); return Layer.mergeAll( orchestrationReactorLayer, @@ -96,6 +101,7 @@ export function makeServerRuntimeServicesLayer() { ServerSettingsLive, ServerEnvironmentLive, authServicesLayer, + gatewayServiceLayer, ServerLifecycleEventsLive, ServerRuntimeStartupLive, WorkspaceLayerLive, diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 074f3ff..3beea5e 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -37,6 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderDiscoveryService } from "./provider/Services/ProviderDiscoveryService"; import { ProviderAdapterRegistry } from "./provider/Services/ProviderAdapterRegistry"; +import { GatewayService } from "./provider/Gateway/Services/GatewayService"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { ProviderService } from "./provider/Services/ProviderService"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; @@ -188,6 +189,7 @@ export const makeWsRpcLayer = () => const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const providerAdapterRegistry = yield* ProviderAdapterRegistry; const providerDiscoveryService = yield* ProviderDiscoveryService; + const gatewayService = yield* GatewayService; const providerHealth = yield* ProviderHealth; const providerService = yield* ProviderService; const lifecycleEvents = yield* ServerLifecycleEvents; @@ -718,6 +720,32 @@ export const makeWsRpcLayer = () => rpcEffect(providerDiscoveryService.listModels(input), "Failed to list models"), [WS_METHODS.providerListAgents]: (input) => rpcEffect(providerDiscoveryService.listAgents(input), "Failed to list agents"), + [WS_METHODS.gatewayDiscoverModels]: (input) => + rpcEffect(gatewayService.discoverModels(input), "Failed to discover gateway models"), + [WS_METHODS.gatewaySelectModel]: (input) => + rpcEffect(gatewayService.selectModel(input), "Failed to select gateway model"), + [WS_METHODS.gatewayGetProviders]: () => + rpcEffect( + gatewayService.getAvailableProviders(), + "Failed to list gateway upstream providers", + ), + [WS_METHODS.gatewayGetHealth]: (input) => + rpcEffect(gatewayService.getProviderHealth(input), "Failed to get gateway health"), + [WS_METHODS.gatewayGetConfig]: () => + rpcEffect(gatewayService.getGatewayConfig(), "Failed to load gateway config"), + [WS_METHODS.gatewayUpdateConfig]: (input) => + rpcEffect(gatewayService.updateGatewayConfig(input), "Failed to update gateway config"), + [WS_METHODS.gatewayGetCapabilities]: (input) => + rpcEffect( + gatewayService.getCapabilities(input), + "Failed to get gateway capabilities", + ), + [WS_METHODS.gatewaySetApiKey]: (input) => + rpcEffect(gatewayService.setApiKey(input), "Failed to store gateway API key"), + [WS_METHODS.gatewayRemoveApiKey]: (input) => + rpcEffect(gatewayService.removeApiKey(input), "Failed to remove gateway API key"), + [WS_METHODS.gatewayGetSecretStatus]: () => + rpcEffect(gatewayService.getSecretStatus(), "Failed to load gateway secret status"), [WS_METHODS.skillsListLocal]: (_input) => rpcEffect( Effect.tryPromise(() => listLocalUserSkills()), diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 02648de..aa7bfdc 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -8,6 +8,7 @@ export const serverQueryKeys = { authSession: () => ["server", "auth", "session"] as const, environment: () => ["server", "environment"] as const, settings: () => ["server", "settings"] as const, + gatewaySecretStatus: () => ["server", "gateway", "secretStatus"] as const, worktrees: () => ["server", "worktrees"] as const, providerUsage: (provider: ProviderKind | null | undefined, homePath?: string | null) => ["server", "providerUsage", provider ?? null, homePath ?? null] as const, diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index c758594..52aba6c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -9,6 +9,7 @@ import { type ServerProviderStatus, type ThreadId, DEFAULT_GIT_TEXT_GENERATION_MODEL, + type GatewayUpstreamProvider, } from "@peakcode/contracts"; import { createFileRoute, useSearch } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -91,6 +92,7 @@ import { import { serverConfigQueryOptions, serverQueryKeys, + serverSettingsQueryOptions, serverWorktreesQueryOptions, } from "../lib/serverReactQuery"; import { cn, isMacPlatform } from "../lib/utils"; @@ -114,13 +116,7 @@ import { sameProviderOrder } from "../providerOrdering"; // ── Model Channels (Service Gateways) ────────────────────────────────────── -type ModelChannelId = - | "deepseek" - | "siliconflow" - | "volcano" - | "tongyi" - | "kimi" - | "minimax"; +type ModelChannelId = "deepseek" | "siliconflow" | "volcano" | "tongyi" | "kimi" | "minimax"; type ModelChannel = { readonly id: ModelChannelId; @@ -670,6 +666,12 @@ function SettingsRouteView() { const desktopTopBarTrafficLightGutterClassName = useDesktopTopBarTrafficLightGutterClassName(); const queryClient = useQueryClient(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const serverSettingsQuery = useQuery(serverSettingsQueryOptions()); + const gatewaySecretStatusQuery = useQuery({ + queryKey: serverQueryKeys.gatewaySecretStatus(), + queryFn: async () => ensureNativeApi().gateway.getSecretStatus(), + staleTime: 15_000, + }); const serverWorktreesQuery = useQuery(serverWorktreesQueryOptions()); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const syncServerReadModel = useStore((store) => store.syncServerReadModel); @@ -724,10 +726,75 @@ function SettingsRouteView() { Partial> >({}); const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const [enabledModelChannels, setEnabledModelChannels] = useState>( - readEnabledModelChannels, + const [enabledModelChannels, setEnabledModelChannels] = + useState>(readEnabledModelChannels); + const gatewayRunning = serverSettingsQuery.data?.gateway.enabled ?? false; + const gatewayUpstreams = serverSettingsQuery.data?.gateway.upstreamProviders ?? []; + const gatewaySecretStatusByProvider = useMemo( + () => + new Map( + (gatewaySecretStatusQuery.data?.secrets ?? []).map((secret) => [ + secret.provider, + secret.hasApiKey, + ]), + ), + [gatewaySecretStatusQuery.data], ); - const [gatewayRunning, setGatewayRunning] = useState(false); + const [gatewayApiKeyInputByProvider, setGatewayApiKeyInputByProvider] = useState< + Partial> + >({}); + const gatewayToggleMutation = useMutation({ + mutationFn: async (enabled: boolean) => { + const api = ensureNativeApi(); + return api.gateway.updateConfig({ enabled }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.settings() }); + }, + onError: (error) => { + toastManager.add({ + title: "Could not update gateway", + description: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); + const gatewaySetApiKeyMutation = useMutation({ + mutationFn: async (input: { provider: GatewayUpstreamProvider; apiKey: string }) => { + const api = ensureNativeApi(); + return api.gateway.setApiKey(input); + }, + onSuccess: (_result, variables) => { + setGatewayApiKeyInputByProvider((current) => ({ + ...current, + [variables.provider]: "", + })); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.gatewaySecretStatus() }); + }, + onError: (error) => { + toastManager.add({ + title: "Could not save gateway API key", + description: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); + const gatewayRemoveApiKeyMutation = useMutation({ + mutationFn: async (provider: GatewayUpstreamProvider) => { + const api = ensureNativeApi(); + return api.gateway.removeApiKey({ provider }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.gatewaySecretStatus() }); + }, + onError: (error) => { + toastManager.add({ + title: "Could not remove gateway API key", + description: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); const [browserNotificationPermission, setBrowserNotificationPermission] = useState( readBrowserNotificationPermissionState(), ); @@ -2555,7 +2622,8 @@ function SettingsRouteView() { control={ setGatewayRunning(checked)} + disabled={gatewayToggleMutation.isPending} + onCheckedChange={(checked) => gatewayToggleMutation.mutate(Boolean(checked))} /> } /> @@ -2565,15 +2633,13 @@ function SettingsRouteView() {

Local API

- Listening on http://127.0.0.1:9872/v1 + Listening on /gateway/openai/v1

{[ - { label: "Root", url: "http://127.0.0.1:9872/v1" }, - { label: "Chat", url: "http://127.0.0.1:9872/v1/chat/completions" }, - { label: "Messages", url: "http://127.0.0.1:9872/v1/messages" }, - { label: "Responses", url: "http://127.0.0.1:9872/v1/responses" }, - { label: "Models", url: "http://127.0.0.1:9872/v1/models" }, + { label: "Root", url: "/gateway/openai/v1" }, + { label: "Chat", url: "/gateway/openai/v1/chat/completions" }, + { label: "Models", url: "/gateway/openai/v1/models" }, ].map((ep) => (
- + + + +
))}
+
+

Upstream API Keys

+
+ {gatewayUpstreams.map((upstream) => { + const hasApiKey = gatewaySecretStatusByProvider.get(upstream.provider) ?? false; + const apiKeyInput = gatewayApiKeyInputByProvider[upstream.provider] ?? ""; + const isSaving = + gatewaySetApiKeyMutation.isPending && + gatewaySetApiKeyMutation.variables?.provider === upstream.provider; + const isRemoving = + gatewayRemoveApiKeyMutation.isPending && + gatewayRemoveApiKeyMutation.variables === upstream.provider; + return ( +
+
+
+
+ {upstream.displayName} +
+
+ {upstream.baseUrl || "No base URL configured"} +
+
+ + {hasApiKey ? "API key saved" : "API key required"} + +
+
+ + setGatewayApiKeyInputByProvider((current) => ({ + ...current, + [upstream.provider]: event.target.value, + })) + } + /> + + +
+
+ ); + })} +
+
+ {/* ── Agent Setup ── */}

Agent Setup

diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 1ab1a83..78956a6 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -596,6 +596,18 @@ export function createWsNativeApi(): NativeApi { listModels: (input) => transport.request(WS_METHODS.providerListModels, input), listAgents: (input) => transport.request(WS_METHODS.providerListAgents, input), }, + gateway: { + getConfig: () => transport.request(WS_METHODS.gatewayGetConfig), + updateConfig: (input) => transport.request(WS_METHODS.gatewayUpdateConfig, input), + discoverModels: (input) => transport.request(WS_METHODS.gatewayDiscoverModels, input), + getAvailableProviders: () => transport.request(WS_METHODS.gatewayGetProviders), + getProviderHealth: (input) => transport.request(WS_METHODS.gatewayGetHealth, input), + selectModel: (input) => transport.request(WS_METHODS.gatewaySelectModel, input), + getCapabilities: (input) => transport.request(WS_METHODS.gatewayGetCapabilities, input), + setApiKey: (input) => transport.request(WS_METHODS.gatewaySetApiKey, input), + removeApiKey: (input) => transport.request(WS_METHODS.gatewayRemoveApiKey, input), + getSecretStatus: () => transport.request(WS_METHODS.gatewayGetSecretStatus), + }, skills: { listLocal: () => transport.request(WS_METHODS.skillsListLocal, null), }, diff --git a/packages/contracts/src/gateway.ts b/packages/contracts/src/gateway.ts new file mode 100644 index 0000000..6b71828 --- /dev/null +++ b/packages/contracts/src/gateway.ts @@ -0,0 +1,189 @@ +import { Schema } from "effect"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; + +export const GatewayUpstreamProvider = Schema.Literals([ + "deepseek", + "glm", + "openrouter", + "siliconflow", + "custom", +]); +export type GatewayUpstreamProvider = typeof GatewayUpstreamProvider.Type; + +export const GatewayUpstreamProtocol = Schema.Literals([ + "openai-compatible", + "anthropic-compatible", +]); +export type GatewayUpstreamProtocol = typeof GatewayUpstreamProtocol.Type; + +export const GatewayFallbackStrategy = Schema.Literals(["retry", "skip", "fail"]); +export type GatewayFallbackStrategy = typeof GatewayFallbackStrategy.Type; + +export const GatewayModelDescriptor = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + upstreamProvider: GatewayUpstreamProvider, +}); +export type GatewayModelDescriptor = typeof GatewayModelDescriptor.Type; + +export const GatewayUpstreamConfig = Schema.Struct({ + provider: GatewayUpstreamProvider, + displayName: TrimmedNonEmptyString, + protocol: GatewayUpstreamProtocol, + baseUrl: TrimmedString, + enabled: Schema.Boolean, + defaultModel: Schema.optional(TrimmedNonEmptyString), + customModels: Schema.Array(TrimmedNonEmptyString), + modelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), +}); +export type GatewayUpstreamConfig = typeof GatewayUpstreamConfig.Type; + +export const GatewayConfig = Schema.Struct({ + enabled: Schema.Boolean, + defaultUpstreamProvider: GatewayUpstreamProvider, + upstreamProviders: Schema.Array(GatewayUpstreamConfig), + healthCheckEnabled: Schema.Boolean, + healthCheckIntervalMs: NonNegativeInt, + discoveryCacheTtlMs: NonNegativeInt, + fallbackStrategy: GatewayFallbackStrategy, +}); +export type GatewayConfig = typeof GatewayConfig.Type; + +export const GatewayUpstreamConfigPatch = Schema.Struct({ + provider: GatewayUpstreamProvider, + displayName: Schema.optional(TrimmedNonEmptyString), + protocol: Schema.optional(GatewayUpstreamProtocol), + baseUrl: Schema.optional(TrimmedString), + enabled: Schema.optional(Schema.Boolean), + defaultModel: Schema.optional(TrimmedNonEmptyString), + customModels: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + modelAliases: Schema.optional(Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString)), +}); +export type GatewayUpstreamConfigPatch = typeof GatewayUpstreamConfigPatch.Type; + +export const GatewayConfigPatch = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + defaultUpstreamProvider: Schema.optional(GatewayUpstreamProvider), + upstreamProviders: Schema.optional(Schema.Array(GatewayUpstreamConfigPatch)), + healthCheckEnabled: Schema.optional(Schema.Boolean), + healthCheckIntervalMs: Schema.optional(NonNegativeInt), + discoveryCacheTtlMs: Schema.optional(NonNegativeInt), + fallbackStrategy: Schema.optional(GatewayFallbackStrategy), +}); +export type GatewayConfigPatch = typeof GatewayConfigPatch.Type; + +export const GatewaySecretStatus = Schema.Struct({ + provider: GatewayUpstreamProvider, + hasApiKey: Schema.Boolean, +}); +export type GatewaySecretStatus = typeof GatewaySecretStatus.Type; + +export const GatewayUpstreamInfo = Schema.Struct({ + provider: GatewayUpstreamProvider, + displayName: TrimmedNonEmptyString, + protocol: GatewayUpstreamProtocol, + baseUrl: TrimmedString, + enabled: Schema.Boolean, + configured: Schema.Boolean, + hasApiKey: Schema.Boolean, + availableModelCount: NonNegativeInt, +}); +export type GatewayUpstreamInfo = typeof GatewayUpstreamInfo.Type; + +export const GatewayAvailableProvidersResult = Schema.Struct({ + providers: Schema.Array(GatewayUpstreamInfo), +}); +export type GatewayAvailableProvidersResult = typeof GatewayAvailableProvidersResult.Type; + +export const GatewayDiscoverySource = Schema.Literals(["runtime", "static", "fallback"]); +export type GatewayDiscoverySource = typeof GatewayDiscoverySource.Type; + +export const GatewayModelDiscoveryResult = Schema.Struct({ + provider: GatewayUpstreamProvider, + models: Schema.Array(GatewayModelDescriptor), + source: GatewayDiscoverySource, + discoveredAt: IsoDateTime, + cacheHit: Schema.optional(Schema.Boolean), +}); +export type GatewayModelDiscoveryResult = typeof GatewayModelDiscoveryResult.Type; + +export const GatewayDiscoverModelsInput = Schema.Struct({ + provider: Schema.optional(GatewayUpstreamProvider), + forceRefresh: Schema.optional(Schema.Boolean), +}); +export type GatewayDiscoverModelsInput = typeof GatewayDiscoverModelsInput.Type; + +export const GatewayDiscoverModelsResult = Schema.Struct({ + results: Schema.Array(GatewayModelDiscoveryResult), + totalProviders: NonNegativeInt, + discoveredAt: IsoDateTime, +}); +export type GatewayDiscoverModelsResult = typeof GatewayDiscoverModelsResult.Type; + +export const GatewayProviderStatus = Schema.Literals(["healthy", "degraded", "unavailable"]); +export type GatewayProviderStatus = typeof GatewayProviderStatus.Type; + +export const GatewayProviderHealth = Schema.Struct({ + provider: GatewayUpstreamProvider, + status: GatewayProviderStatus, + lastChecked: IsoDateTime, + latencyMs: Schema.optional(NonNegativeInt), + errorMessage: Schema.optional(TrimmedNonEmptyString), + availableModels: Schema.Array(TrimmedNonEmptyString), + consecutiveFailures: NonNegativeInt, +}); +export type GatewayProviderHealth = typeof GatewayProviderHealth.Type; + +export const GatewayProviderHealthResult = Schema.Struct({ + providers: Schema.Array(GatewayProviderHealth), + checkedAt: IsoDateTime, +}); +export type GatewayProviderHealthResult = typeof GatewayProviderHealthResult.Type; + +export const GatewayGetProviderHealthInput = Schema.Struct({ + provider: Schema.optional(GatewayUpstreamProvider), +}); +export type GatewayGetProviderHealthInput = typeof GatewayGetProviderHealthInput.Type; + +export const GatewayModelSelection = Schema.Struct({ + upstreamProvider: GatewayUpstreamProvider, + model: TrimmedNonEmptyString, + resolvedModel: TrimmedNonEmptyString, + baseUrl: TrimmedString, + protocol: GatewayUpstreamProtocol, +}); +export type GatewayModelSelection = typeof GatewayModelSelection.Type; + +export const GatewaySelectModelInput = Schema.Struct({ + upstreamProvider: Schema.optional(GatewayUpstreamProvider), + model: Schema.optional(TrimmedNonEmptyString), +}); +export type GatewaySelectModelInput = typeof GatewaySelectModelInput.Type; + +export const GatewayCapabilities = Schema.Struct({ + provider: GatewayUpstreamProvider, + protocol: GatewayUpstreamProtocol, + supportsRuntimeDiscovery: Schema.Boolean, +}); +export type GatewayCapabilities = typeof GatewayCapabilities.Type; + +export const GatewayGetCapabilitiesInput = Schema.Struct({ + provider: GatewayUpstreamProvider, +}); +export type GatewayGetCapabilitiesInput = typeof GatewayGetCapabilitiesInput.Type; + +export const GatewaySetApiKeyInput = Schema.Struct({ + provider: GatewayUpstreamProvider, + apiKey: TrimmedNonEmptyString, +}); +export type GatewaySetApiKeyInput = typeof GatewaySetApiKeyInput.Type; + +export const GatewayRemoveApiKeyInput = Schema.Struct({ + provider: GatewayUpstreamProvider, +}); +export type GatewayRemoveApiKeyInput = typeof GatewayRemoveApiKeyInput.Type; + +export const GatewaySecretStatusResult = Schema.Struct({ + secrets: Schema.Array(GatewaySecretStatus), +}); +export type GatewaySecretStatusResult = typeof GatewaySecretStatusResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bf0f5b2..55b4b56 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,4 +17,5 @@ export * from "./editor"; export * from "./environment"; export * from "./project"; export * from "./filesystem"; +export * from "./gateway"; export * from "./rpc"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 59c133d..3bef840 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -119,6 +119,22 @@ import type { ListLocalUserSkillsResult, } from "./providerDiscovery"; import type { ProviderCompactThreadInput } from "./provider"; +import type { + GatewayAvailableProvidersResult, + GatewayCapabilities, + GatewayConfig, + GatewayConfigPatch, + GatewayDiscoverModelsInput, + GatewayDiscoverModelsResult, + GatewayGetCapabilitiesInput, + GatewayGetProviderHealthInput, + GatewayModelSelection, + GatewayProviderHealthResult, + GatewayRemoveApiKeyInput, + GatewaySecretStatusResult, + GatewaySelectModelInput, + GatewaySetApiKeyInput, +} from "./gateway"; export interface ContextMenuItem { id: T; @@ -422,6 +438,20 @@ export interface NativeApi { listModels: (input: ProviderListModelsInput) => Promise; listAgents: (input: ProviderListAgentsInput) => Promise; }; + gateway: { + getConfig: () => Promise; + updateConfig: (input: GatewayConfigPatch) => Promise; + discoverModels: (input: GatewayDiscoverModelsInput) => Promise; + getAvailableProviders: () => Promise; + getProviderHealth: ( + input: GatewayGetProviderHealthInput, + ) => Promise; + selectModel: (input: GatewaySelectModelInput) => Promise; + getCapabilities: (input: GatewayGetCapabilitiesInput) => Promise; + setApiKey: (input: GatewaySetApiKeyInput) => Promise; + removeApiKey: (input: GatewayRemoveApiKeyInput) => Promise; + getSecretStatus: () => Promise; + }; skills: { listLocal: () => Promise; }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b7d4ba4..8801cb3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,22 @@ import { GitSummarizeDiffResult, } from "./git"; import { KeybindingRule } from "./keybindings"; +import { + GatewayAvailableProvidersResult, + GatewayCapabilities, + GatewayConfig, + GatewayConfigPatch, + GatewayDiscoverModelsInput, + GatewayDiscoverModelsResult, + GatewayGetCapabilitiesInput, + GatewayGetProviderHealthInput, + GatewayModelSelection, + GatewayProviderHealthResult, + GatewayRemoveApiKeyInput, + GatewaySecretStatusResult, + GatewaySelectModelInput, + GatewaySetApiKeyInput, +} from "./gateway"; import { ClientOrchestrationCommand, ORCHESTRATION_WS_METHODS, @@ -567,6 +583,66 @@ export const WsProviderListAgentsRpc = Rpc.make(WS_METHODS.providerListAgents, { error: WsRpcError, }); +export const WsGatewayDiscoverModelsRpc = Rpc.make(WS_METHODS.gatewayDiscoverModels, { + payload: GatewayDiscoverModelsInput, + success: GatewayDiscoverModelsResult, + error: WsRpcError, +}); + +export const WsGatewaySelectModelRpc = Rpc.make(WS_METHODS.gatewaySelectModel, { + payload: GatewaySelectModelInput, + success: GatewayModelSelection, + error: WsRpcError, +}); + +export const WsGatewayGetProvidersRpc = Rpc.make(WS_METHODS.gatewayGetProviders, { + payload: Schema.Struct({}), + success: GatewayAvailableProvidersResult, + error: WsRpcError, +}); + +export const WsGatewayGetHealthRpc = Rpc.make(WS_METHODS.gatewayGetHealth, { + payload: GatewayGetProviderHealthInput, + success: GatewayProviderHealthResult, + error: WsRpcError, +}); + +export const WsGatewayGetConfigRpc = Rpc.make(WS_METHODS.gatewayGetConfig, { + payload: Schema.Struct({}), + success: GatewayConfig, + error: WsRpcError, +}); + +export const WsGatewayUpdateConfigRpc = Rpc.make(WS_METHODS.gatewayUpdateConfig, { + payload: GatewayConfigPatch, + success: GatewayConfig, + error: WsRpcError, +}); + +export const WsGatewayGetCapabilitiesRpc = Rpc.make(WS_METHODS.gatewayGetCapabilities, { + payload: GatewayGetCapabilitiesInput, + success: GatewayCapabilities, + error: WsRpcError, +}); + +export const WsGatewaySetApiKeyRpc = Rpc.make(WS_METHODS.gatewaySetApiKey, { + payload: GatewaySetApiKeyInput, + success: GatewaySecretStatusResult, + error: WsRpcError, +}); + +export const WsGatewayRemoveApiKeyRpc = Rpc.make(WS_METHODS.gatewayRemoveApiKey, { + payload: GatewayRemoveApiKeyInput, + success: GatewaySecretStatusResult, + error: WsRpcError, +}); + +export const WsGatewayGetSecretStatusRpc = Rpc.make(WS_METHODS.gatewayGetSecretStatus, { + payload: Schema.Struct({}), + success: GatewaySecretStatusResult, + error: WsRpcError, +}); + export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationImportThreadRpc, @@ -637,4 +713,14 @@ export const WsRpcGroup = RpcGroup.make( WsProviderListModelsRpc, WsProviderListAgentsRpc, WsSkillsListLocalRpc, + WsGatewayDiscoverModelsRpc, + WsGatewaySelectModelRpc, + WsGatewayGetProvidersRpc, + WsGatewayGetHealthRpc, + WsGatewayGetConfigRpc, + WsGatewayUpdateConfigRpc, + WsGatewayGetCapabilitiesRpc, + WsGatewaySetApiKeyRpc, + WsGatewayRemoveApiKeyRpc, + WsGatewayGetSecretStatusRpc, ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f6037f3..eb4ee15 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { TrimmedString } from "./baseSchemas"; +import { GatewayConfig, GatewayConfigPatch } from "./gateway"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model"; import { ModelSelection, ProviderKind, ThreadEnvironmentMode } from "./orchestration"; @@ -76,6 +77,38 @@ export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvironmentMode.pipe(Schema.withDecodingDefault(() => "local")), addProjectBaseDirectory: StringSetting.pipe(Schema.withDecodingDefault(() => "")), + gateway: GatewayConfig.pipe( + Schema.withDecodingDefault(() => ({ + enabled: false, + defaultUpstreamProvider: "deepseek" as const, + upstreamProviders: [ + { + provider: "deepseek" as const, + displayName: "DeepSeek", + protocol: "openai-compatible" as const, + baseUrl: "https://api.deepseek.com", + enabled: true, + defaultModel: "deepseek-chat", + customModels: ["deepseek-chat", "deepseek-reasoner"], + modelAliases: {}, + }, + { + provider: "glm" as const, + displayName: "GLM", + protocol: "openai-compatible" as const, + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + enabled: false, + defaultModel: "glm-4.5", + customModels: ["glm-4.5", "glm-4.5-air"], + modelAliases: {}, + }, + ], + healthCheckEnabled: true, + healthCheckIntervalMs: 30_000, + discoveryCacheTtlMs: 300_000, + fallbackStrategy: "fail" as const, + })), + ), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault(() => ({ provider: "codex" as const, @@ -113,6 +146,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvironmentMode), addProjectBaseDirectory: Schema.optionalKey(StringSetting), + gateway: Schema.optionalKey(GatewayConfigPatch), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), providers: Schema.optionalKey( Schema.Struct({ diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index d641ba7..dfcb397 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -81,6 +81,15 @@ import { ListLocalUserSkillsInput, } from "./providerDiscovery"; import { ProviderCompactThreadInput } from "./provider"; +import { + GatewayConfigPatch, + GatewayDiscoverModelsInput, + GatewayGetCapabilitiesInput, + GatewayGetProviderHealthInput, + GatewayRemoveApiKeyInput, + GatewaySelectModelInput, + GatewaySetApiKeyInput, +} from "./gateway"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -163,6 +172,18 @@ export const WS_METHODS = { // Local user skills (home-dir scan, independent of provider) skillsListLocal: "skills.listLocal", + + // Gateway methods + gatewayDiscoverModels: "gateway.discoverModels", + gatewaySelectModel: "gateway.selectModel", + gatewayGetProviders: "gateway.getProviders", + gatewayGetHealth: "gateway.getHealth", + gatewayGetConfig: "gateway.getConfig", + gatewayUpdateConfig: "gateway.updateConfig", + gatewayGetCapabilities: "gateway.getCapabilities", + gatewaySetApiKey: "gateway.setApiKey", + gatewayRemoveApiKey: "gateway.removeApiKey", + gatewayGetSecretStatus: "gateway.getSecretStatus", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -271,6 +292,18 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.providerListModels, ProviderListModelsInput), tagRequestBody(WS_METHODS.providerListAgents, ProviderListAgentsInput), tagRequestBody(WS_METHODS.skillsListLocal, ListLocalUserSkillsInput), + + // Gateway methods + tagRequestBody(WS_METHODS.gatewayDiscoverModels, GatewayDiscoverModelsInput), + tagRequestBody(WS_METHODS.gatewaySelectModel, GatewaySelectModelInput), + tagRequestBody(WS_METHODS.gatewayGetProviders, Schema.Struct({})), + tagRequestBody(WS_METHODS.gatewayGetHealth, GatewayGetProviderHealthInput), + tagRequestBody(WS_METHODS.gatewayGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.gatewayUpdateConfig, GatewayConfigPatch), + tagRequestBody(WS_METHODS.gatewayGetCapabilities, GatewayGetCapabilitiesInput), + tagRequestBody(WS_METHODS.gatewaySetApiKey, GatewaySetApiKeyInput), + tagRequestBody(WS_METHODS.gatewayRemoveApiKey, GatewayRemoveApiKeyInput), + tagRequestBody(WS_METHODS.gatewayGetSecretStatus, Schema.Struct({})), ]); export const WebSocketRequest = Schema.Struct({ From d4d843f5bd1ccd87de45dc0235bae936b5c094fa Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:22:06 +0800 Subject: [PATCH 2/8] complete gateway openai proxy --- apps/server/src/http.test.ts | 196 +++++++++++++++++- apps/server/src/http.ts | 389 +++++++++++++++++++++++++++++++---- 2 files changed, 539 insertions(+), 46 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 114f813..f4d49d8 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -4,14 +4,21 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@peakcode/contracts"; import { DateTime, Effect, FileSystem, Path } from "effect"; import { afterEach, describe, expect, it } from "vitest"; -import { createHttpRequestHandler, isLegacyTokenAuthorized } from "./http"; +import { + createHttpRequestHandler, + isLegacyBearerAuthorized, + isLegacyTokenAuthorized, +} from "./http"; import type { ServerAuthShape } from "./auth/Services/ServerAuth"; +import type { ServerSecretStoreShape } from "./auth/Services/ServerSecretStore"; import { deriveServerPaths, type ServerConfigShape } from "./config"; import type { ProjectFaviconResolverShape } from "./project/Services/ProjectFaviconResolver"; import type { ServerReadiness } from "./server/readiness"; +import type { ServerSettingsShape } from "./serverSettings"; const tempDirs: string[] = []; @@ -73,6 +80,10 @@ async function makeHandler( readonly serverAuth: ServerAuthShape; readonly cookieName: string; }, + gateway?: { + readonly serverSettings: Pick; + readonly secretStore: Pick; + }, ): Promise { const services = await Effect.runPromise( Effect.gen(function* () { @@ -94,9 +105,40 @@ async function makeHandler( sessionCredentials: { cookieName: auth.cookieName }, } : {}), + ...(gateway ?? {}), }); } +function makeGatewaySettings(gateway: ServerSettings["gateway"]): ServerSettings { + return { + ...DEFAULT_SERVER_SETTINGS, + gateway, + }; +} + +function makeGatewayDependencies(input: { + readonly settings: ServerSettings; + readonly apiKey?: string | null; +}): { + readonly serverSettings: Pick; + readonly secretStore: Pick; +} { + const encoder = new TextEncoder(); + return { + serverSettings: { + getSettings: Effect.succeed(input.settings), + }, + secretStore: { + get: (name) => + Effect.succeed( + name === "gateway.upstream.deepseek.apiKey" && input.apiKey + ? encoder.encode(input.apiKey) + : null, + ), + }, + }; +} + function makeAuthDescriptor() { return { policy: "loopback-browser" as const, @@ -200,6 +242,158 @@ describe("createHttpRequestHandler", () => { ).toBe(false); }); + it("recognizes the desktop startup token from OpenAI-compatible bearer requests", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + + expect( + isLegacyBearerAuthorized({ + config, + headers: { authorization: "Bearer desktop-secret" }, + }), + ).toBe(true); + expect( + isLegacyBearerAuthorized({ + config, + headers: { authorization: "Bearer wrong" }, + }), + ).toBe(false); + }); + + it("serves gateway OpenAI-compatible model metadata", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: "upstream-key" }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/models`, { + headers: { Authorization: "Bearer desktop-secret" }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + object: "list", + data: expect.arrayContaining([ + expect.objectContaining({ id: "deepseek/deepseek-chat", object: "model" }), + expect.objectContaining({ id: "deepseek/deepseek-reasoner", object: "model" }), + ]), + }); + }); + }); + + it("proxies gateway OpenAI-compatible chat completions to the selected upstream", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + const observed: { + path?: string; + authorization?: string; + body?: Record; + } = {}; + await withServer( + async (req, res) => { + observed.path = req.url; + observed.authorization = req.headers.authorization; + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + observed.body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id: "chatcmpl-test", object: "chat.completion", choices: [] })); + }, + async (upstreamOrigin) => { + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + upstreamProviders: [ + { + provider: "deepseek", + displayName: "DeepSeek", + protocol: "openai-compatible", + baseUrl: `${upstreamOrigin}/v1`, + enabled: true, + defaultModel: "deepseek-chat", + customModels: ["deepseek-chat", "deepseek-reasoner"], + modelAliases: {}, + }, + ], + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: "upstream-key" }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: "Bearer desktop-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "deepseek/deepseek-reasoner", + messages: [{ role: "user", content: "hello" }], + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + id: "chatcmpl-test", + object: "chat.completion", + }); + }); + }, + ); + + expect(observed.path).toBe("/v1/chat/completions"); + expect(observed.authorization).toBe("Bearer upstream-key"); + expect(observed.body).toMatchObject({ + model: "deepseek-reasoner", + messages: [{ role: "user", content: "hello" }], + }); + }); + + it("rejects gateway chat completions when the selected upstream API key is missing", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: null }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: "Bearer desktop-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "deepseek/deepseek-chat", + messages: [{ role: "user", content: "hello" }], + }), + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { + message: "Gateway upstream provider 'deepseek' has no API key configured.", + type: "peakcode_gateway_error", + }, + }); + }); + }); + it("serves health readiness JSON", async () => { const config = await makeConfig(); const handler = await makeHandler(config); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index a63d51a..fd28c3e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -19,10 +19,16 @@ import { resolveAttachmentRelativePath, } from "./attachmentPaths"; import { resolveAttachmentPathById } from "./attachmentStore.ts"; -import { authErrorResponse, makeEffectAuthRequest, serveAuthHttpRoute } from "./auth/http"; +import { + authErrorResponse, + makeEffectAuthRequest, + makeNodeAuthRequest, + serveAuthHttpRoute, +} from "./auth/http"; import { ServerAuth } from "./auth/Services/ServerAuth"; import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import type { ServerAuthShape } from "./auth/Services/ServerAuth"; +import type { ServerSecretStoreShape } from "./auth/Services/ServerSecretStore"; import type { SessionCredentialServiceShape } from "./auth/Services/SessionCredentialService"; import { SessionCredentialService } from "./auth/Services/SessionCredentialService"; import { deriveAuthClientMetadata } from "./auth/utils"; @@ -32,6 +38,7 @@ import type { ProjectFaviconResolverShape } from "./project/Services/ProjectFavi import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; import type { ServerReadiness } from "./server/readiness"; import { ServerSettingsService } from "./serverSettings"; +import type { ServerSettingsShape } from "./serverSettings"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const decodeBootstrapInput = Schema.decodeUnknownEffect(AuthBootstrapInput); @@ -81,7 +88,10 @@ const requireGatewayRequestAccess = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const url = HttpServerRequest.toURL(request); - if (url && isLegacyTokenAuthorized({ config, url })) { + if ( + (url && isLegacyTokenAuthorized({ config, url })) || + isLegacyBearerAuthorized({ config, headers: request.headers }) + ) { return; } const serverAuth = yield* ServerAuth; @@ -96,6 +106,17 @@ export function isLegacyTokenAuthorized(input: { return !input.config.authToken || legacyToken === input.config.authToken; } +export function isLegacyBearerAuthorized(input: { + readonly config: ServerConfigShape; + readonly headers: Record; +}): boolean { + if (!input.config.authToken) return true; + const authorization = input.headers.authorization; + const header = Array.isArray(authorization) ? authorization.join(", ") : authorization; + if (typeof header !== "string" || !header.startsWith("Bearer ")) return false; + return header.slice("Bearer ".length).trim() === input.config.authToken; +} + function encodeCookie(input: { readonly name: string; readonly value: string; @@ -335,6 +356,99 @@ function gatewayErrorResponse(message: string, status: number) { ); } +function makeGatewayModelsPayload(gatewayConfig: GatewayConfig) { + return { + object: "list", + data: gatewayConfig.upstreamProviders.flatMap((upstream) => { + if (!upstream.enabled) return []; + const modelIds = new Set(upstream.customModels); + if (upstream.defaultModel) modelIds.add(upstream.defaultModel); + return Array.from(modelIds).map((model) => ({ + id: `${upstream.provider}/${model}`, + object: "model", + created: 0, + owned_by: upstream.provider, + })); + }), + }; +} + +async function proxyGatewayChatCompletion(input: { + readonly gatewayConfig: GatewayConfig; + readonly payload: Record; + readonly apiKey: string; + readonly accept?: string | undefined; +}): Promise { + const requestedModel = typeof input.payload.model === "string" ? input.payload.model.trim() : null; + const { upstream, resolvedModel } = resolveGatewayUpstream({ + config: input.gatewayConfig, + model: requestedModel, + }); + if (!upstream) { + return Response.json( + { error: { message: "No gateway upstream provider is configured.", type: "peakcode_gateway_error" } }, + { status: 400 }, + ); + } + if (!upstream.enabled) { + return Response.json( + { + error: { + message: `Gateway upstream provider '${upstream.provider}' is disabled.`, + type: "peakcode_gateway_error", + }, + }, + { status: 400 }, + ); + } + if (upstream.protocol !== "openai-compatible") { + return Response.json( + { + error: { + message: `Gateway upstream provider '${upstream.provider}' is not OpenAI-compatible.`, + type: "peakcode_gateway_error", + }, + }, + { status: 400 }, + ); + } + if (!resolvedModel) { + return Response.json( + { + error: { + message: `Gateway upstream provider '${upstream.provider}' has no model configured.`, + type: "peakcode_gateway_error", + }, + }, + { status: 400 }, + ); + } + if (upstream.baseUrl.trim().length === 0) { + return Response.json( + { + error: { + message: `Gateway upstream provider '${upstream.provider}' has no base URL configured.`, + type: "peakcode_gateway_error", + }, + }, + { status: 400 }, + ); + } + + return fetch(joinGatewayUrl(upstream.baseUrl, "/chat/completions"), { + method: "POST", + headers: { + Authorization: `Bearer ${input.apiKey}`, + "Content-Type": "application/json", + Accept: input.accept ?? "application/json", + }, + body: JSON.stringify({ + ...input.payload, + model: resolvedModel, + }), + }); +} + const gatewayEffectRouteLayer = HttpRouter.add( "*", "/gateway/openai/v1/*", @@ -353,18 +467,7 @@ const gatewayEffectRouteLayer = HttpRouter.add( } if (request.method === "GET" && url.pathname === "/gateway/openai/v1/models") { - const data = gatewayConfig.upstreamProviders.flatMap((upstream) => { - if (!upstream.enabled) return []; - const modelIds = new Set(upstream.customModels); - if (upstream.defaultModel) modelIds.add(upstream.defaultModel); - return Array.from(modelIds).map((model) => ({ - id: `${upstream.provider}/${model}`, - object: "model", - created: 0, - owned_by: upstream.provider, - })); - }); - return HttpServerResponse.jsonUnsafe({ object: "list", data }); + return HttpServerResponse.jsonUnsafe(makeGatewayModelsPayload(gatewayConfig)); } if (request.method !== "POST" || url.pathname !== "/gateway/openai/v1/chat/completions") { @@ -379,7 +482,7 @@ const gatewayEffectRouteLayer = HttpRouter.add( } const requestedModel = typeof payload.model === "string" ? payload.model.trim() : null; - const { upstream, resolvedModel } = resolveGatewayUpstream({ + const { upstream } = resolveGatewayUpstream({ config: gatewayConfig, model: requestedModel, }); @@ -389,25 +492,6 @@ const gatewayEffectRouteLayer = HttpRouter.add( if (!upstream.enabled) { return gatewayErrorResponse(`Gateway upstream provider '${upstream.provider}' is disabled.`, 400); } - if (upstream.protocol !== "openai-compatible") { - return gatewayErrorResponse( - `Gateway upstream provider '${upstream.provider}' is not OpenAI-compatible.`, - 400, - ); - } - if (!resolvedModel) { - return gatewayErrorResponse( - `Gateway upstream provider '${upstream.provider}' has no model configured.`, - 400, - ); - } - if (upstream.baseUrl.trim().length === 0) { - return gatewayErrorResponse( - `Gateway upstream provider '${upstream.provider}' has no base URL configured.`, - 400, - ); - } - const apiKeyBytes = yield* secretStore.get(upstreamSecretName(upstream.provider)); const apiKey = apiKeyBytes ? new TextDecoder().decode(apiKeyBytes).trim() : ""; if (apiKey.length === 0) { @@ -417,19 +501,16 @@ const gatewayEffectRouteLayer = HttpRouter.add( ); } + const acceptHeader = Array.isArray(request.headers.accept) + ? request.headers.accept.join(", ") + : request.headers.accept; const upstreamResponse = yield* Effect.tryPromise({ try: () => - fetch(joinGatewayUrl(upstream.baseUrl, "/chat/completions"), { - method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "Content-Type": "application/json", - "Accept": request.headers.accept ?? "application/json", - }, - body: JSON.stringify({ - ...payload, - model: resolvedModel, - }), + proxyGatewayChatCompletion({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, }), catch: (cause) => ({ message: cause instanceof Error ? cause.message : "Gateway upstream request failed.", @@ -689,6 +770,8 @@ export interface HttpRequestHandlerOptions { readonly path: Path.Path; readonly serverAuth?: ServerAuthShape; readonly sessionCredentials?: Pick; + readonly serverSettings?: Pick; + readonly secretStore?: Pick; } function makeResponder(res: http.ServerResponse): Respond { @@ -706,6 +789,8 @@ export function createHttpRequestHandler({ path, serverAuth, sessionCredentials, + serverSettings, + secretStore, }: HttpRequestHandlerOptions): http.RequestListener { const { port, staticDir, devUrl } = serverConfig; @@ -759,6 +844,23 @@ export function createHttpRequestHandler({ if (handled) return; } + if (url.pathname.startsWith("/gateway/openai/v1/")) { + if (!serverSettings || !secretStore) { + respond(503, { "Content-Type": "text/plain" }, "Gateway service unavailable"); + return; + } + yield* serveGatewayOpenAi({ + url, + req, + respond, + serverConfig, + serverAuth, + serverSettings, + secretStore, + }); + return; + } + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { yield* serveAttachment({ url, @@ -912,6 +1014,203 @@ const serveAttachment = Effect.fn(function* (input: { } }); +async function readNodeRequestBody(req: http.IncomingMessage, limitBytes = 8 * 1024 * 1024) { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > limitBytes) { + throw new Error("Request body is too large."); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +const serveGatewayOpenAi = Effect.fn(function* (input: { + readonly url: URL; + readonly req: http.IncomingMessage; + readonly respond: Respond; + readonly serverConfig: ServerConfigShape; + readonly serverAuth?: ServerAuthShape; + readonly serverSettings: Pick; + readonly secretStore: Pick; +}) { + if ( + !isLegacyTokenAuthorized({ config: input.serverConfig, url: input.url }) && + !isLegacyBearerAuthorized({ config: input.serverConfig, headers: input.req.headers }) + ) { + if (!input.serverAuth) { + input.respond( + 401, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { message: "Authentication required.", type: "peakcode_gateway_error" }, + }), + ); + return; + } + const authExit = yield* input.serverAuth + .authenticateHttpRequest(makeNodeAuthRequest({ req: input.req, url: input.url })) + .pipe(Effect.exit); + if (Exit.isFailure(authExit)) { + input.respond( + 401, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { message: "Authentication required.", type: "peakcode_gateway_error" }, + }), + ); + return; + } + } + + const gatewayConfig = (yield* input.serverSettings.getSettings).gateway; + if (!gatewayConfig.enabled) { + input.respond( + 503, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { message: "PeakCode gateway is disabled.", type: "peakcode_gateway_error" }, + }), + ); + return; + } + + if (input.req.method === "GET" && input.url.pathname === "/gateway/openai/v1/models") { + input.respond( + 200, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify(makeGatewayModelsPayload(gatewayConfig)), + ); + return; + } + + if ( + input.req.method !== "POST" || + input.url.pathname !== "/gateway/openai/v1/chat/completions" + ) { + input.respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const rawBody = yield* Effect.tryPromise({ + try: () => readNodeRequestBody(input.req), + catch: () => new Error("Invalid JSON request body."), + }).pipe( + Effect.catch(() => { + input.respond( + 400, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { message: "Invalid JSON request body.", type: "peakcode_gateway_error" }, + }), + ); + return Effect.succeed(null); + }), + ); + if (rawBody === null) return; + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + input.respond( + 400, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { message: "Invalid JSON request body.", type: "peakcode_gateway_error" }, + }), + ); + return; + } + if (!isRecord(payload)) { + input.respond( + 400, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { + message: "Request body must be a JSON object.", + type: "peakcode_gateway_error", + }, + }), + ); + return; + } + + const requestedModel = typeof payload.model === "string" ? payload.model.trim() : null; + const { upstream } = resolveGatewayUpstream({ config: gatewayConfig, model: requestedModel }); + if (!upstream) { + input.respond( + 400, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { + message: "No gateway upstream provider is configured.", + type: "peakcode_gateway_error", + }, + }), + ); + return; + } + + const apiKeyBytes = yield* input.secretStore.get(upstreamSecretName(upstream.provider)); + const apiKey = apiKeyBytes ? new TextDecoder().decode(apiKeyBytes).trim() : ""; + if (apiKey.length === 0) { + input.respond( + 401, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { + message: `Gateway upstream provider '${upstream.provider}' has no API key configured.`, + type: "peakcode_gateway_error", + }, + }), + ); + return; + } + + const acceptHeader = Array.isArray(input.req.headers.accept) + ? input.req.headers.accept.join(", ") + : input.req.headers.accept; + const upstreamResponse = yield* Effect.tryPromise({ + try: () => + proxyGatewayChatCompletion({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, + }), + catch: (cause) => + cause instanceof Error ? cause : new Error("Gateway upstream request failed."), + }).pipe( + Effect.catch((cause) => { + input.respond( + 502, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ + error: { + message: cause.message || "Gateway upstream request failed.", + type: "peakcode_gateway_error", + }, + }), + ); + return Effect.succeed(null); + }), + ); + if (upstreamResponse === null) return; + + const responseBody = yield* Effect.promise(() => + upstreamResponse.arrayBuffer().then((buffer) => new Uint8Array(buffer)), + ); + const headers: Record = {}; + upstreamResponse.headers.forEach((value, key) => { + headers[key] = value; + }); + input.respond(upstreamResponse.status, headers, responseBody); +}); + const serveStaticAsset = Effect.fn(function* (input: { readonly url: URL; readonly respond: Respond; From df3d1106b5369020190a799f204b55bbd32c3f9d Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:45:06 +0800 Subject: [PATCH 3/8] route deepseek gateway defaults to v4 flash --- apps/server/src/http.test.ts | 74 +++++++++++++++-- apps/server/src/http.ts | 24 +++++- apps/web/src/routes/_chat.settings.tsx | 107 +++++++++++++++++++++++++ packages/contracts/src/settings.ts | 9 ++- 4 files changed, 200 insertions(+), 14 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index f4d49d8..131f071 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -280,8 +280,8 @@ describe("createHttpRequestHandler", () => { await expect(response.json()).resolves.toMatchObject({ object: "list", data: expect.arrayContaining([ - expect.objectContaining({ id: "deepseek/deepseek-chat", object: "model" }), - expect.objectContaining({ id: "deepseek/deepseek-reasoner", object: "model" }), + expect.objectContaining({ id: "deepseek/deepseek-v4-flash", object: "model" }), + expect.objectContaining({ id: "deepseek/deepseek-v4-pro", object: "model" }), ]), }); }); @@ -317,8 +317,8 @@ describe("createHttpRequestHandler", () => { protocol: "openai-compatible", baseUrl: `${upstreamOrigin}/v1`, enabled: true, - defaultModel: "deepseek-chat", - customModels: ["deepseek-chat", "deepseek-reasoner"], + defaultModel: "deepseek-v4-flash", + customModels: ["deepseek-v4-flash", "deepseek-v4-pro"], modelAliases: {}, }, ], @@ -337,7 +337,7 @@ describe("createHttpRequestHandler", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - model: "deepseek/deepseek-reasoner", + model: "deepseek/deepseek-v4-pro", messages: [{ role: "user", content: "hello" }], }), }); @@ -354,7 +354,67 @@ describe("createHttpRequestHandler", () => { expect(observed.path).toBe("/v1/chat/completions"); expect(observed.authorization).toBe("Bearer upstream-key"); expect(observed.body).toMatchObject({ - model: "deepseek-reasoner", + model: "deepseek-v4-pro", + messages: [{ role: "user", content: "hello" }], + }); + }); + + it("maps provider-only DeepSeek requests from legacy defaults to V4 Flash", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + const observed: { body?: Record } = {}; + await withServer( + async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + observed.body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id: "chatcmpl-test", object: "chat.completion", choices: [] })); + }, + async (upstreamOrigin) => { + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + upstreamProviders: [ + { + provider: "deepseek", + displayName: "DeepSeek", + protocol: "openai-compatible", + baseUrl: `${upstreamOrigin}/v1`, + enabled: true, + defaultModel: "deepseek-chat", + customModels: ["deepseek-chat", "deepseek-reasoner"], + modelAliases: {}, + }, + ], + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: "upstream-key" }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: "Bearer desktop-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "deepseek", + messages: [{ role: "user", content: "hello" }], + }), + }); + + expect(response.status).toBe(200); + }); + }, + ); + + expect(observed.body).toMatchObject({ + model: "deepseek-v4-flash", messages: [{ role: "user", content: "hello" }], }); }); @@ -379,7 +439,7 @@ describe("createHttpRequestHandler", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - model: "deepseek/deepseek-chat", + model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hello" }], }), }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fd28c3e..dd8e1a7 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -47,6 +47,11 @@ const decodeCreatePairingCredentialInput = Schema.decodeUnknownEffect( ); const decodeRevokePairingLinkInput = Schema.decodeUnknownEffect(AuthRevokePairingLinkInput); const decodeRevokeClientSessionInput = Schema.decodeUnknownEffect(AuthRevokeClientSessionInput); +const DEEPSEEK_GATEWAY_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"] as const; +const DEEPSEEK_GATEWAY_MODEL_ALIASES = { + "deepseek-chat": "deepseek-v4-flash", + "deepseek-reasoner": "deepseek-v4-pro", +} as const; export function makeEffectHttpRouteLayer(readiness: ServerReadiness) { return Layer.mergeAll( @@ -335,11 +340,17 @@ function resolveGatewayUpstream(input: { const upstream = input.config.upstreamProviders.find((candidate) => candidate.provider === provider) ?? null; const requestedModel = - input.model && prefixedProvider === provider - ? input.model.slice(slashIndex + 1) - : input.model || upstream?.defaultModel || null; + input.model === provider + ? (upstream?.defaultModel ?? null) + : input.model && prefixedProvider === provider + ? input.model.slice(slashIndex + 1) + : input.model || upstream?.defaultModel || null; + const modelAliases: Record = + upstream?.provider === "deepseek" + ? { ...DEEPSEEK_GATEWAY_MODEL_ALIASES, ...upstream.modelAliases } + : (upstream?.modelAliases ?? {}); const resolvedModel = requestedModel - ? (upstream?.modelAliases[requestedModel] ?? requestedModel) + ? (modelAliases[requestedModel] ?? requestedModel) : null; return { upstream, requestedModel, resolvedModel }; } @@ -363,6 +374,11 @@ function makeGatewayModelsPayload(gatewayConfig: GatewayConfig) { if (!upstream.enabled) return []; const modelIds = new Set(upstream.customModels); if (upstream.defaultModel) modelIds.add(upstream.defaultModel); + if (upstream.provider === "deepseek") { + for (const model of DEEPSEEK_GATEWAY_MODELS) { + modelIds.add(model); + } + } return Array.from(modelIds).map((model) => ({ id: `${upstream.provider}/${model}`, object: "model", diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 52aba6c..86cb450 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -168,8 +168,30 @@ const MODEL_CHANNELS: ReadonlyArray = [ }, ]; +const DEEPSEEK_GATEWAY_MODEL_OPTIONS = [ + { + slug: "deepseek-v4-flash", + label: "DeepSeek V4 Flash", + description: "Fast default for Codex gateway usage.", + }, + { + slug: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", + description: "Higher capability route when you want stronger reasoning.", + }, +] as const; +const DEEPSEEK_GATEWAY_MODEL_ALIASES: Record = { + "deepseek-chat": "deepseek-v4-flash", + "deepseek-reasoner": "deepseek-v4-pro", +}; + const MODEL_CHANNELS_STORAGE_KEY = "peakcode:enabled-model-channels:v1"; +function normalizeDeepSeekGatewayModel(model: string | undefined): string { + if (!model) return DEEPSEEK_GATEWAY_MODEL_OPTIONS[0].slug; + return DEEPSEEK_GATEWAY_MODEL_ALIASES[model] ?? model; +} + function readEnabledModelChannels(): ReadonlyArray { try { const raw = localStorage.getItem(MODEL_CHANNELS_STORAGE_KEY); @@ -730,6 +752,12 @@ function SettingsRouteView() { useState>(readEnabledModelChannels); const gatewayRunning = serverSettingsQuery.data?.gateway.enabled ?? false; const gatewayUpstreams = serverSettingsQuery.data?.gateway.upstreamProviders ?? []; + const deepSeekGatewayUpstream = gatewayUpstreams.find( + (upstream) => upstream.provider === "deepseek", + ); + const selectedDeepSeekGatewayModel = normalizeDeepSeekGatewayModel( + deepSeekGatewayUpstream?.defaultModel, + ); const gatewaySecretStatusByProvider = useMemo( () => new Map( @@ -759,6 +787,36 @@ function SettingsRouteView() { }); }, }); + const gatewayDeepSeekModelMutation = useMutation({ + mutationFn: async (model: (typeof DEEPSEEK_GATEWAY_MODEL_OPTIONS)[number]["slug"]) => { + const api = ensureNativeApi(); + return api.gateway.updateConfig({ + defaultUpstreamProvider: "deepseek", + upstreamProviders: [ + { + provider: "deepseek", + enabled: true, + defaultModel: model, + customModels: DEEPSEEK_GATEWAY_MODEL_OPTIONS.map((option) => option.slug), + modelAliases: { + "deepseek-chat": "deepseek-v4-flash", + "deepseek-reasoner": "deepseek-v4-pro", + }, + }, + ], + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.settings() }); + }, + onError: (error) => { + toastManager.add({ + title: "Could not update DeepSeek model", + description: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); const gatewaySetApiKeyMutation = useMutation({ mutationFn: async (input: { provider: GatewayUpstreamProvider; apiKey: string }) => { const api = ensureNativeApi(); @@ -2677,6 +2735,55 @@ function SettingsRouteView() {
+
+

+ DeepSeek default model +

+
+
+
+ Used when Codex sends model{" "} + deepseek +
+
+ Configure Codex once against the local gateway, then switch Flash/Pro here. +
+
+
+ {DEEPSEEK_GATEWAY_MODEL_OPTIONS.map((option) => { + const selected = selectedDeepSeekGatewayModel === option.slug; + return ( + + ); + })} +
+
+
+

Upstream API Keys

diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index eb4ee15..4e2305a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -88,9 +88,12 @@ export const ServerSettings = Schema.Struct({ protocol: "openai-compatible" as const, baseUrl: "https://api.deepseek.com", enabled: true, - defaultModel: "deepseek-chat", - customModels: ["deepseek-chat", "deepseek-reasoner"], - modelAliases: {}, + defaultModel: "deepseek-v4-flash", + customModels: ["deepseek-v4-flash", "deepseek-v4-pro"], + modelAliases: { + "deepseek-chat": "deepseek-v4-flash", + "deepseek-reasoner": "deepseek-v4-pro", + }, }, { provider: "glm" as const, From 01b70a3682545f2eeeef3916052b6ef1b00df7d2 Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:11:47 +0800 Subject: [PATCH 4/8] route peakcode codex deepseek through gateway --- apps/server/src/codexAppServerManager.test.ts | 58 +++++++++++++ apps/server/src/codexAppServerManager.ts | 58 +++++++++++++ apps/server/src/codexProcessEnv.ts | 87 ++++++++++++++++++- 3 files changed, 200 insertions(+), 3 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 21039f5..c28f704 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -7,6 +7,7 @@ import { ApprovalRequestId, ThreadId } from "@peakcode/contracts"; import { buildCodexProcessEnv, + configurePeakCodeGatewayProviderInCodexConfig, disablePeakCodeBrowserPluginInCodexConfig, resolveCodexBrowserUsePipePath, } from "./codexProcessEnv"; @@ -400,6 +401,63 @@ describe("buildCodexProcessEnv", () => { '[plugins."peakcode-browser@local"]\nenabled = false', ); }); + + it("configures Peak Code gateway as a Codex custom model provider", () => { + const config = configurePeakCodeGatewayProviderInCodexConfig( + [ + 'model = "gpt-5.5"', + 'model_provider = "openai"', + "", + "[model_providers.openai]", + 'name = "OpenAI"', + ].join("\n"), + { + baseUrl: "http://127.0.0.1:3773/gateway/openai/v1", + apiKey: "local-token", + }, + ); + + expect(config).toContain('model_provider = "peakcode-gateway"'); + expect(config).toContain("[model_providers.openai]"); + expect(config).toContain("[model_providers.peakcode-gateway]"); + expect(config).toContain('base_url = "http://127.0.0.1:3773/gateway/openai/v1"'); + expect(config).toContain('env_key = "PEAKCODE_GATEWAY_API_KEY"'); + expect(config).not.toContain('model_provider = "openai"'); + }); + + it("writes gateway provider config into Peak Code's Codex home overlay", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-codex-env-")); + const runtimeHome = mkdtempSync(path.join(os.tmpdir(), "t3-runtime-home-")); + try { + writeFileSync(path.join(tempDir, "config.toml"), 'model_provider = "openai"\n', "utf8"); + + const env = buildCodexProcessEnv({ + env: { PEAKCODE_HOME: runtimeHome }, + homePath: tempDir, + platform: "darwin", + gatewayProvider: { + baseUrl: "http://127.0.0.1:3773/gateway/openai/v1", + apiKey: "local-token", + }, + }); + + expect(env.CODEX_HOME).toBe(path.join(runtimeHome, "codex-home-overlay")); + expect(env.PEAKCODE_GATEWAY_API_KEY).toBe("local-token"); + const codexHome = env.CODEX_HOME; + if (typeof codexHome !== "string") { + throw new Error("Expected CODEX_HOME to be set."); + } + const overlayConfig = readFileSync(path.join(codexHome, "config.toml"), "utf8"); + expect(overlayConfig).toContain('model_provider = "peakcode-gateway"'); + expect(overlayConfig).toContain( + 'base_url = "http://127.0.0.1:3773/gateway/openai/v1"', + ); + expect(overlayConfig).toContain('[plugins."peakcode-browser@local"]\nenabled = false'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + rmSync(runtimeHome, { recursive: true, force: true }); + } + }); }); describe("handleStdoutLine", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index c21b31b..2fbbcb3 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -50,6 +50,7 @@ import { } from "./provider/codexCliVersion"; import { isNonFatalCodexErrorMessage } from "./codexErrorClassification.ts"; import { buildCodexProcessEnv } from "./codexProcessEnv.ts"; +import { ServerConfig, type ServerConfigShape } from "./config.ts"; import { transcribeVoiceWithChatGptSession } from "./voiceTranscription.ts"; type PendingRequestKey = string; @@ -233,6 +234,13 @@ const CODEX_DEFAULT_MODEL = "gpt-5.5"; const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); const CODEX_DISCOVERY_SESSION_IDLE_MS = 10 * 60 * 1000; +const DEEPSEEK_GATEWAY_CODEX_MODELS = new Set([ + "deepseek", + "deepseek-chat", + "deepseek-reasoner", + "deepseek-v4-flash", + "deepseek-v4-pro", +]); function asObject(value: unknown): Record | undefined { if (!value || typeof value !== "object") { @@ -249,6 +257,24 @@ function normalizeCodexProcessLine(rawLine: string): string { return rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); } +function resolveDeepSeekGatewayCodexModel(model: string | undefined | null): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) return undefined; + const slashIndex = trimmed.indexOf("/"); + const unprefixed = + slashIndex > 0 && trimmed.slice(0, slashIndex) === "deepseek" + ? trimmed.slice(slashIndex + 1) + : trimmed; + return DEEPSEEK_GATEWAY_CODEX_MODELS.has(unprefixed) ? unprefixed : undefined; +} + +function localGatewayBaseUrl(config: ServerConfigShape): string { + const host = + config.host && config.host !== "0.0.0.0" && config.host !== "::" ? config.host : "127.0.0.1"; + const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${formattedHost}:${config.port}/gateway/openai/v1`; +} + function isIgnorableCodexProcessLine(rawLine: string): boolean { const line = normalizeCodexProcessLine(rawLine); if (!line) { @@ -669,6 +695,32 @@ export class CodexAppServerManager extends EventEmitter { + if (!resolveDeepSeekGatewayCodexModel(model)) { + return undefined; + } + + try { + const config = (await this.runPromise( + Effect.gen(function* () { + return yield* ServerConfig; + }), + )) as ServerConfigShape; + return { + baseUrl: localGatewayBaseUrl(config), + apiKey: config.authToken ?? "peakcode-local-gateway", + }; + } catch { + return undefined; + } + } + async startSession(input: CodexAppServerStartSessionInput): Promise { const threadId = input.threadId; const now = new Date().toISOString(); @@ -696,6 +748,7 @@ export class CodexAppServerManager extends EventEmitter { + if (insertedModelProvider) return; + output.push(`model_provider = "${PEAKCODE_GATEWAY_PROVIDER_ID}"`); + insertedModelProvider = true; + }; + + for (const line of lines) { + const trimmed = line.trim(); + const startsSection = trimmed.startsWith("[") && trimmed.endsWith("]"); + + if (startsSection) { + inTopLevel = false; + if (!insertedModelProvider) { + insertModelProvider(); + } + inGatewayProviderSection = + trimmed === `[model_providers.${PEAKCODE_GATEWAY_PROVIDER_ID}]` || + trimmed === `[model_providers."${PEAKCODE_GATEWAY_PROVIDER_ID}"]`; + skippingGatewayProviderSection = inGatewayProviderSection; + if (skippingGatewayProviderSection) { + continue; + } + output.push(line); + continue; + } + + if (skippingGatewayProviderSection) { + continue; + } + + if (inTopLevel && /^\s*model_provider\s*=/.test(line)) { + insertModelProvider(); + continue; + } + + output.push(line); + } + + if (!insertedModelProvider) { + insertModelProvider(); + } + + if (output.length > 0 && output.at(-1)?.trim()) { + output.push(""); + } + output.push( + `[model_providers.${PEAKCODE_GATEWAY_PROVIDER_ID}]`, + `base_url = "${gateway.baseUrl.replaceAll('"', '\\"')}"`, + `env_key = "${PEAKCODE_GATEWAY_API_KEY_ENV}"`, + ); + + return output.join("\n"); +} + function preparePeakCodeCodexHomeOverlay(input: { readonly env: NodeJS.ProcessEnv; readonly homePath?: string; + readonly gatewayProvider?: CodexGatewayProviderConfig; }): string | undefined { const sourceHomePath = resolveBaseCodexHomePath(input.env, input.homePath); const overlayHomePath = resolvePeakCodeCodexHomeOverlayPath(input.env, sourceHomePath); - if (path.resolve(sourceHomePath) === path.resolve(overlayHomePath)) { + if (path.resolve(sourceHomePath) === path.resolve(overlayHomePath) && !input.gatewayProvider) { return undefined; } @@ -126,9 +199,12 @@ function preparePeakCodeCodexHomeOverlay(input: { const sourceConfigPath = path.join(sourceHomePath, "config.toml"); const sourceConfig = existsSync(sourceConfigPath) ? readFileSync(sourceConfigPath, "utf8") : ""; + const overlayConfig = input.gatewayProvider + ? configurePeakCodeGatewayProviderInCodexConfig(sourceConfig, input.gatewayProvider) + : disablePeakCodeBrowserPluginInCodexConfig(sourceConfig); writeFileSync( path.join(overlayHomePath, "config.toml"), - disablePeakCodeBrowserPluginInCodexConfig(sourceConfig), + disablePeakCodeBrowserPluginInCodexConfig(overlayConfig), "utf8", ); @@ -141,19 +217,24 @@ export function buildCodexProcessEnv( readonly homePath?: string; readonly platform?: NodeJS.Platform; readonly readEnvironment?: ShellEnvironmentReader; + readonly gatewayProvider?: CodexGatewayProviderConfig; } = {}, ): NodeJS.ProcessEnv { const baseEnv = { ...(input.env ?? process.env) }; - const overlayHomePath = shouldDisablePeakCodeBrowserPlugin(baseEnv) + const overlayHomePath = shouldDisablePeakCodeBrowserPlugin(baseEnv) || input.gatewayProvider ? preparePeakCodeCodexHomeOverlay({ env: baseEnv, ...(input.homePath ? { homePath: input.homePath } : {}), + ...(input.gatewayProvider ? { gatewayProvider: input.gatewayProvider } : {}), }) : undefined; const effectiveEnv = overlayHomePath || input.homePath ? { ...baseEnv, CODEX_HOME: overlayHomePath ?? input.homePath } : baseEnv; + if (input.gatewayProvider) { + effectiveEnv[PEAKCODE_GATEWAY_API_KEY_ENV] = input.gatewayProvider.apiKey; + } const platform = input.platform ?? process.platform; if (platform === "darwin" || platform === "linux") { From 52949aede3862f7883ff474456b9648d4e616a5a Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:21:04 +0800 Subject: [PATCH 5/8] add name to codex gateway provider --- apps/server/src/codexAppServerManager.test.ts | 2 ++ apps/server/src/codexProcessEnv.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index c28f704..4624ba0 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -420,6 +420,7 @@ describe("buildCodexProcessEnv", () => { expect(config).toContain('model_provider = "peakcode-gateway"'); expect(config).toContain("[model_providers.openai]"); expect(config).toContain("[model_providers.peakcode-gateway]"); + expect(config).toContain('name = "PeakCode Gateway"'); expect(config).toContain('base_url = "http://127.0.0.1:3773/gateway/openai/v1"'); expect(config).toContain('env_key = "PEAKCODE_GATEWAY_API_KEY"'); expect(config).not.toContain('model_provider = "openai"'); @@ -449,6 +450,7 @@ describe("buildCodexProcessEnv", () => { } const overlayConfig = readFileSync(path.join(codexHome, "config.toml"), "utf8"); expect(overlayConfig).toContain('model_provider = "peakcode-gateway"'); + expect(overlayConfig).toContain('name = "PeakCode Gateway"'); expect(overlayConfig).toContain( 'base_url = "http://127.0.0.1:3773/gateway/openai/v1"', ); diff --git a/apps/server/src/codexProcessEnv.ts b/apps/server/src/codexProcessEnv.ts index 080ddff..a8084f2 100644 --- a/apps/server/src/codexProcessEnv.ts +++ b/apps/server/src/codexProcessEnv.ts @@ -159,6 +159,7 @@ export function configurePeakCodeGatewayProviderInCodexConfig( } output.push( `[model_providers.${PEAKCODE_GATEWAY_PROVIDER_ID}]`, + 'name = "PeakCode Gateway"', `base_url = "${gateway.baseUrl.replaceAll('"', '\\"')}"`, `env_key = "${PEAKCODE_GATEWAY_API_KEY_ENV}"`, ); From 608ab16ff2235e3450eeb463f0092f04c69b5cfa Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:28:52 +0800 Subject: [PATCH 6/8] Add gateway responses compatibility --- apps/server/src/http.test.ts | 152 ++++++++++++++ apps/server/src/http.ts | 376 +++++++++++++++++++++++++++++++++-- 2 files changed, 510 insertions(+), 18 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 131f071..71bf148 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -419,6 +419,158 @@ describe("createHttpRequestHandler", () => { }); }); + it("proxies gateway Responses API requests through OpenAI-compatible chat completions", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + const observed: { + path?: string; + authorization?: string; + body?: Record; + } = {}; + await withServer( + async (req, res) => { + observed.path = req.url; + observed.authorization = req.headers.authorization; + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + observed.body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + created: 123, + choices: [{ message: { role: "assistant", content: "hello from deepseek" } }], + usage: { total_tokens: 9 }, + }), + ); + }, + async (upstreamOrigin) => { + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + upstreamProviders: [ + { + provider: "deepseek", + displayName: "DeepSeek", + protocol: "openai-compatible", + baseUrl: `${upstreamOrigin}/v1`, + enabled: true, + defaultModel: "deepseek-v4-flash", + customModels: ["deepseek-v4-flash", "deepseek-v4-pro"], + modelAliases: {}, + }, + ], + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: "upstream-key" }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/responses`, { + method: "POST", + headers: { + Authorization: "Bearer desktop-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "deepseek/deepseek-v4-flash", + instructions: "Be concise.", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + max_output_tokens: 128, + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + object: "response", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from deepseek" }], + }, + ], + }); + }); + }, + ); + + expect(observed.path).toBe("/v1/chat/completions"); + expect(observed.authorization).toBe("Bearer upstream-key"); + expect(observed.body).toMatchObject({ + model: "deepseek-v4-flash", + messages: [ + { role: "system", content: "Be concise." }, + { role: "user", content: "hi" }, + ], + max_tokens: 128, + }); + }); + + it("translates streaming gateway Responses API requests to Responses SSE events", async () => { + const config = await makeConfig({ authToken: "desktop-secret" }); + await withServer( + async (_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'); + res.write('data: {"choices":[{"delta":{"content":" world"}}]}\n\n'); + res.end("data: [DONE]\n\n"); + }, + async (upstreamOrigin) => { + const settings = makeGatewaySettings({ + ...DEFAULT_SERVER_SETTINGS.gateway, + enabled: true, + upstreamProviders: [ + { + provider: "deepseek", + displayName: "DeepSeek", + protocol: "openai-compatible", + baseUrl: `${upstreamOrigin}/v1`, + enabled: true, + defaultModel: "deepseek-v4-flash", + customModels: ["deepseek-v4-flash"], + modelAliases: {}, + }, + ], + }); + const handler = await makeHandler( + config, + undefined, + makeGatewayDependencies({ settings, apiKey: "upstream-key" }), + ); + + await withServer(handler, async (origin) => { + const response = await fetch(`${origin}/gateway/openai/v1/responses`, { + method: "POST", + headers: { + Authorization: "Bearer desktop-secret", + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + model: "deepseek/deepseek-v4-flash", + input: "hi", + stream: true, + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const body = await response.text(); + expect(body).toContain("event: response.output_text.delta"); + expect(body).toContain('"delta":"hello"'); + expect(body).toContain('"text":"hello world"'); + expect(body).toContain("event: response.completed"); + }); + }, + ); + }); + it("rejects gateway chat completions when the selected upstream API key is missing", async () => { const config = await makeConfig({ authToken: "desktop-secret" }); const settings = makeGatewaySettings({ diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index dd8e1a7..9fd52a8 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,4 +1,5 @@ import type http from "node:http"; +import { randomUUID } from "node:crypto"; import Mime from "@effect/platform-node/Mime"; import { @@ -319,6 +320,10 @@ function joinGatewayUrl(baseUrl: string, suffix: string): string { return `${baseUrl.replace(/\/+$/u, "")}/${suffix.replace(/^\/+/u, "")}`; } +function makeGatewayJsonResponse(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + function resolveGatewayUpstream(input: { readonly config: GatewayConfig; readonly model: string | null; @@ -465,6 +470,326 @@ async function proxyGatewayChatCompletion(input: { }); } +function readGatewayText(value: unknown): string | null { + if (typeof value === "string") return value; + if (!isRecord(value)) return null; + const text = value.text; + if (typeof text === "string") return text; + const content = value.content; + if (typeof content === "string") return content; + return null; +} + +function flattenGatewayContentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) { + return readGatewayText(content) ?? ""; + } + return content + .flatMap((part) => { + const text = readGatewayText(part); + return text ? [text] : []; + }) + .join("\n"); +} + +function responseInputToChatMessages(payload: Record): Record[] { + const messages: Record[] = []; + if (typeof payload.instructions === "string" && payload.instructions.trim().length > 0) { + messages.push({ role: "system", content: payload.instructions }); + } + + const input = payload.input; + if (typeof input === "string") { + messages.push({ role: "user", content: input }); + return messages; + } + + if (!Array.isArray(input)) return messages; + + for (const item of input) { + if (!isRecord(item)) continue; + const roleValue = typeof item.role === "string" ? item.role : "user"; + const role = roleValue === "developer" ? "system" : roleValue; + const content = flattenGatewayContentText(item.content ?? item.text); + if (content.length > 0) { + messages.push({ role, content }); + continue; + } + if (item.type === "function_call_output" && typeof item.output === "string") { + messages.push({ role: "tool", tool_call_id: item.call_id, content: item.output }); + } + } + + return messages; +} + +function responsesToolsToChatTools(tools: unknown): unknown { + if (!Array.isArray(tools)) return undefined; + const chatTools = tools.flatMap((tool) => { + if (!isRecord(tool) || tool.type !== "function" || typeof tool.name !== "string") return []; + return [ + { + type: "function", + function: { + name: tool.name, + description: typeof tool.description === "string" ? tool.description : undefined, + parameters: isRecord(tool.parameters) ? tool.parameters : undefined, + strict: typeof tool.strict === "boolean" ? tool.strict : undefined, + }, + }, + ]; + }); + return chatTools.length > 0 ? chatTools : undefined; +} + +function responsePayloadToChatPayload(payload: Record): Record { + const chatPayload: Record = { + model: payload.model, + messages: responseInputToChatMessages(payload), + stream: payload.stream === true, + }; + for (const key of ["temperature", "top_p", "presence_penalty", "frequency_penalty"] as const) { + if (payload[key] !== undefined) chatPayload[key] = payload[key]; + } + if (payload.max_output_tokens !== undefined) chatPayload.max_tokens = payload.max_output_tokens; + const tools = responsesToolsToChatTools(payload.tools); + if (tools) chatPayload.tools = tools; + if (payload.tool_choice !== undefined) chatPayload.tool_choice = payload.tool_choice; + return chatPayload; +} + +function makeResponseId(source: unknown): string { + return typeof source === "string" && source.length > 0 ? source : `resp_${randomUUID()}`; +} + +function chatMessageToResponseOutput(message: unknown): unknown[] { + if (!isRecord(message)) return []; + const output: unknown[] = []; + const content = flattenGatewayContentText(message.content); + if (content.length > 0) { + output.push({ + id: `msg_${randomUUID()}`, + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: content, annotations: [] }], + }); + } + if (Array.isArray(message.tool_calls)) { + for (const toolCall of message.tool_calls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + output.push({ + id: typeof toolCall.id === "string" ? toolCall.id : `fc_${randomUUID()}`, + type: "function_call", + status: "completed", + call_id: typeof toolCall.id === "string" ? toolCall.id : `call_${randomUUID()}`, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }); + } + } + return output; +} + +async function chatCompletionToResponsePayload(response: Response, requestedModel: unknown): Promise { + const payload = await response.json(); + if (!response.ok) return makeGatewayJsonResponse(payload, response.status); + const firstChoice = + isRecord(payload) && Array.isArray(payload.choices) && isRecord(payload.choices[0]) + ? payload.choices[0] + : null; + const responsePayload = { + id: makeResponseId(isRecord(payload) ? payload.id : null), + object: "response", + created_at: + isRecord(payload) && typeof payload.created === "number" + ? payload.created + : Math.floor(Date.now() / 1000), + status: "completed", + model: + typeof requestedModel === "string" + ? requestedModel + : isRecord(payload) && typeof payload.model === "string" + ? payload.model + : undefined, + output: firstChoice ? chatMessageToResponseOutput(firstChoice.message) : [], + usage: isRecord(payload) ? payload.usage : undefined, + }; + return makeGatewayJsonResponse(responsePayload, response.status); +} + +function encodeGatewaySseEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function streamChatCompletionAsResponse(input: { + readonly upstreamResponse: Response; + readonly requestedModel: unknown; +}): Response { + const responseId = `resp_${randomUUID()}`; + const itemId = `msg_${randomUUID()}`; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const upstreamBody = input.upstreamResponse.body; + if (!upstreamBody) { + return makeGatewayJsonResponse( + { error: { message: "Gateway upstream stream was empty.", type: "peakcode_gateway_error" } }, + 502, + ); + } + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: string, data: unknown) => + controller.enqueue(encoder.encode(encodeGatewaySseEvent(event, data))); + send("response.created", { + type: "response.created", + response: { + id: responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "in_progress", + model: input.requestedModel, + output: [], + }, + }); + send("response.output_item.added", { + type: "response.output_item.added", + output_index: 0, + item: { id: itemId, type: "message", status: "in_progress", role: "assistant", content: [] }, + }); + send("response.content_part.added", { + type: "response.content_part.added", + item_id: itemId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + + const reader = upstreamBody.getReader(); + let buffer = ""; + let text = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + for (const rawEvent of events) { + const dataLines = rawEvent + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()); + const data = dataLines.join("\n"); + if (!data || data === "[DONE]") continue; + const chunk = JSON.parse(data) as unknown; + const choices = isRecord(chunk) && Array.isArray(chunk.choices) ? chunk.choices : []; + for (const choice of choices) { + if (!isRecord(choice) || !isRecord(choice.delta)) continue; + const delta = choice.delta.content; + if (typeof delta !== "string" || delta.length === 0) continue; + text += delta; + send("response.output_text.delta", { + type: "response.output_text.delta", + response_id: responseId, + item_id: itemId, + output_index: 0, + content_index: 0, + delta, + }); + } + } + } + send("response.output_text.done", { + type: "response.output_text.done", + response_id: responseId, + item_id: itemId, + output_index: 0, + content_index: 0, + text, + }); + send("response.content_part.done", { + type: "response.content_part.done", + item_id: itemId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text, annotations: [] }, + }); + send("response.output_item.done", { + type: "response.output_item.done", + output_index: 0, + item: { + id: itemId, + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + }); + send("response.completed", { + type: "response.completed", + response: { + id: responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: input.requestedModel, + output: [ + { + id: itemId, + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + }, + }); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } catch (cause) { + controller.error(cause); + } finally { + reader.releaseLock(); + } + }, + }); + + return new Response(stream, { + status: input.upstreamResponse.status, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + +async function proxyGatewayResponse(input: { + readonly gatewayConfig: GatewayConfig; + readonly payload: Record; + readonly apiKey: string; + readonly accept?: string | undefined; +}): Promise { + const chatPayload = responsePayloadToChatPayload(input.payload); + const upstreamResponse = await proxyGatewayChatCompletion({ + gatewayConfig: input.gatewayConfig, + payload: chatPayload, + apiKey: input.apiKey, + accept: chatPayload.stream === true ? "text/event-stream" : input.accept, + }); + if (!upstreamResponse.ok) return upstreamResponse; + if (chatPayload.stream === true) { + return streamChatCompletionAsResponse({ + upstreamResponse, + requestedModel: input.payload.model, + }); + } + return chatCompletionToResponsePayload(upstreamResponse, input.payload.model); +} + const gatewayEffectRouteLayer = HttpRouter.add( "*", "/gateway/openai/v1/*", @@ -486,11 +811,13 @@ const gatewayEffectRouteLayer = HttpRouter.add( return HttpServerResponse.jsonUnsafe(makeGatewayModelsPayload(gatewayConfig)); } - if (request.method !== "POST" || url.pathname !== "/gateway/openai/v1/chat/completions") { + const isChatCompletions = url.pathname === "/gateway/openai/v1/chat/completions"; + const isResponses = url.pathname === "/gateway/openai/v1/responses"; + if (request.method !== "POST" || (!isChatCompletions && !isResponses)) { return HttpServerResponse.text("Not Found", { status: 404 }); } - const payload = yield* readEffectJson(request, "Invalid gateway chat completions payload.").pipe( + const payload = yield* readEffectJson(request, "Invalid gateway OpenAI-compatible payload.").pipe( Effect.mapError(() => ({ message: "Invalid JSON request body.", status: 400 as const })), ); if (!isRecord(payload)) { @@ -522,12 +849,19 @@ const gatewayEffectRouteLayer = HttpRouter.add( : request.headers.accept; const upstreamResponse = yield* Effect.tryPromise({ try: () => - proxyGatewayChatCompletion({ - gatewayConfig, - payload, - apiKey, - accept: acceptHeader, - }), + isResponses + ? proxyGatewayResponse({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, + }) + : proxyGatewayChatCompletion({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, + }), catch: (cause) => ({ message: cause instanceof Error ? cause.message : "Gateway upstream request failed.", status: 502 as const, @@ -1103,10 +1437,9 @@ const serveGatewayOpenAi = Effect.fn(function* (input: { return; } - if ( - input.req.method !== "POST" || - input.url.pathname !== "/gateway/openai/v1/chat/completions" - ) { + const isChatCompletions = input.url.pathname === "/gateway/openai/v1/chat/completions"; + const isResponses = input.url.pathname === "/gateway/openai/v1/responses"; + if (input.req.method !== "POST" || (!isChatCompletions && !isResponses)) { input.respond(404, { "Content-Type": "text/plain" }, "Not Found"); return; } @@ -1192,12 +1525,19 @@ const serveGatewayOpenAi = Effect.fn(function* (input: { : input.req.headers.accept; const upstreamResponse = yield* Effect.tryPromise({ try: () => - proxyGatewayChatCompletion({ - gatewayConfig, - payload, - apiKey, - accept: acceptHeader, - }), + isResponses + ? proxyGatewayResponse({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, + }) + : proxyGatewayChatCompletion({ + gatewayConfig, + payload, + apiKey, + accept: acceptHeader, + }), catch: (cause) => cause instanceof Error ? cause : new Error("Gateway upstream request failed."), }).pipe( From 4ed4f3555f209ce8a98e581bd45d3d4b64237a83 Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:39:13 +0800 Subject: [PATCH 7/8] Reset codex provider after gateway sessions --- apps/server/src/codexAppServerManager.test.ts | 63 +++++++++++++++++++ apps/server/src/codexProcessEnv.ts | 39 +++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 4624ba0..cef78f7 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -9,6 +9,7 @@ import { buildCodexProcessEnv, configurePeakCodeGatewayProviderInCodexConfig, disablePeakCodeBrowserPluginInCodexConfig, + removePeakCodeGatewayProviderFromCodexConfig, resolveCodexBrowserUsePipePath, } from "./codexProcessEnv"; import { @@ -426,6 +427,29 @@ describe("buildCodexProcessEnv", () => { expect(config).not.toContain('model_provider = "openai"'); }); + it("removes Peak Code gateway provider config when a normal Codex session is launched", () => { + const config = removePeakCodeGatewayProviderFromCodexConfig( + [ + 'model = "gpt-5.5"', + 'model_provider = "peakcode-gateway"', + "", + "[model_providers.peakcode-gateway]", + 'name = "PeakCode Gateway"', + 'base_url = "http://127.0.0.1:3773/gateway/openai/v1"', + 'env_key = "PEAKCODE_GATEWAY_API_KEY"', + "", + '[plugins."browser@openai-bundled"]', + "enabled = true", + ].join("\n"), + ); + + expect(config).toContain('model = "gpt-5.5"'); + expect(config).toContain('[plugins."browser@openai-bundled"]'); + expect(config).not.toContain('model_provider = "peakcode-gateway"'); + expect(config).not.toContain("[model_providers.peakcode-gateway]"); + expect(config).not.toContain("PEAKCODE_GATEWAY_API_KEY"); + }); + it("writes gateway provider config into Peak Code's Codex home overlay", () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-codex-env-")); const runtimeHome = mkdtempSync(path.join(os.tmpdir(), "t3-runtime-home-")); @@ -460,6 +484,45 @@ describe("buildCodexProcessEnv", () => { rmSync(runtimeHome, { recursive: true, force: true }); } }); + + it("rewrites a stale gateway overlay back to normal Codex provider config", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-codex-env-")); + const runtimeHome = mkdtempSync(path.join(os.tmpdir(), "t3-runtime-home-")); + try { + writeFileSync( + path.join(tempDir, "config.toml"), + [ + 'model = "gpt-5.5"', + 'model_provider = "peakcode-gateway"', + "", + "[model_providers.peakcode-gateway]", + 'name = "PeakCode Gateway"', + 'base_url = "http://127.0.0.1:3773/gateway/openai/v1"', + 'env_key = "PEAKCODE_GATEWAY_API_KEY"', + ].join("\n"), + "utf8", + ); + + const env = buildCodexProcessEnv({ + env: { PEAKCODE_HOME: runtimeHome }, + homePath: tempDir, + platform: "darwin", + }); + + const codexHome = env.CODEX_HOME; + if (typeof codexHome !== "string") { + throw new Error("Expected CODEX_HOME to be set."); + } + const overlayConfig = readFileSync(path.join(codexHome, "config.toml"), "utf8"); + expect(overlayConfig).toContain('model = "gpt-5.5"'); + expect(overlayConfig).not.toContain('model_provider = "peakcode-gateway"'); + expect(overlayConfig).not.toContain("[model_providers.peakcode-gateway]"); + expect(overlayConfig).toContain('[plugins."peakcode-browser@local"]\nenabled = false'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + rmSync(runtimeHome, { recursive: true, force: true }); + } + }); }); describe("handleStdoutLine", () => { diff --git a/apps/server/src/codexProcessEnv.ts b/apps/server/src/codexProcessEnv.ts index a8084f2..2076d2b 100644 --- a/apps/server/src/codexProcessEnv.ts +++ b/apps/server/src/codexProcessEnv.ts @@ -167,6 +167,41 @@ export function configurePeakCodeGatewayProviderInCodexConfig( return output.join("\n"); } +export function removePeakCodeGatewayProviderFromCodexConfig(config: string): string { + const lines = config.split(/\r?\n/); + const output: string[] = []; + let skippingGatewayProviderSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + const startsSection = trimmed.startsWith("[") && trimmed.endsWith("]"); + + if (startsSection) { + skippingGatewayProviderSection = + trimmed === `[model_providers.${PEAKCODE_GATEWAY_PROVIDER_ID}]` || + trimmed === `[model_providers."${PEAKCODE_GATEWAY_PROVIDER_ID}"]`; + if (skippingGatewayProviderSection) { + continue; + } + } + + if (skippingGatewayProviderSection) { + continue; + } + + if ( + /^\s*model_provider\s*=/.test(line) && + line.includes(`"${PEAKCODE_GATEWAY_PROVIDER_ID}"`) + ) { + continue; + } + + output.push(line); + } + + return output.join("\n"); +} + function preparePeakCodeCodexHomeOverlay(input: { readonly env: NodeJS.ProcessEnv; readonly homePath?: string; @@ -202,7 +237,9 @@ function preparePeakCodeCodexHomeOverlay(input: { const sourceConfig = existsSync(sourceConfigPath) ? readFileSync(sourceConfigPath, "utf8") : ""; const overlayConfig = input.gatewayProvider ? configurePeakCodeGatewayProviderInCodexConfig(sourceConfig, input.gatewayProvider) - : disablePeakCodeBrowserPluginInCodexConfig(sourceConfig); + : disablePeakCodeBrowserPluginInCodexConfig( + removePeakCodeGatewayProviderFromCodexConfig(sourceConfig), + ); writeFileSync( path.join(overlayHomePath, "config.toml"), disablePeakCodeBrowserPluginInCodexConfig(overlayConfig), From 3716d335e9e9b6340aaba292f35b599b8895a2dc Mon Sep 17 00:00:00 2001 From: mriwiki <71009631+Neonity2020@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:28:02 +0800 Subject: [PATCH 8/8] Fix provider update command resolution --- .../server/src/persistence/BunSqliteClient.ts | 187 ++++++++++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 2 +- .../src/provider/Layers/ProviderHealth.ts | 3 + .../src/provider/providerMaintenance.test.ts | 80 ++++++++ .../src/provider/providerMaintenance.ts | 31 ++- apps/web/src/routes/__root.tsx | 12 +- 6 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/persistence/BunSqliteClient.ts diff --git a/apps/server/src/persistence/BunSqliteClient.ts b/apps/server/src/persistence/BunSqliteClient.ts new file mode 100644 index 0000000..9ff6f18 --- /dev/null +++ b/apps/server/src/persistence/BunSqliteClient.ts @@ -0,0 +1,187 @@ +import { Database, type Statement } from "bun:sqlite"; + +import * as Cache from "effect/Cache"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { identity } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as ServiceMap from "effect/ServiceMap"; +import * as Stream from "effect/Stream"; +import * as Reactivity from "effect/unstable/reactivity/Reactivity"; +import * as Client from "effect/unstable/sql/SqlClient"; +import type { Connection } from "effect/unstable/sql/SqlConnection"; +import { SqlError } from "effect/unstable/sql/SqlError"; +import * as StatementCompiler from "effect/unstable/sql/Statement"; + +const ATTR_DB_SYSTEM_NAME = "db.system.name"; + +export const TypeId: TypeId = "~local/sqlite-bun/SqliteClient"; + +export type TypeId = "~local/sqlite-bun/SqliteClient"; + +export const SqliteClient = ServiceMap.Service("t3/persistence/BunSqliteClient"); + +export interface SqliteClientConfig { + readonly filename: string; + readonly readonly?: boolean | undefined; + readonly prepareCacheSize?: number | undefined; + readonly prepareCacheTTL?: Duration.Input | undefined; + readonly spanAttributes?: Record | undefined; + readonly transformResultNames?: ((str: string) => string) | undefined; + readonly transformQueryNames?: ((str: string) => string) | undefined; +} + +const makeWithDatabase = ( + options: SqliteClientConfig, + openDatabase: () => Database, +): Effect.Effect => + Effect.gen(function* () { + const compiler = StatementCompiler.makeCompilerSqlite(options.transformQueryNames); + const transformRows = options.transformResultNames + ? StatementCompiler.defaultTransforms(options.transformResultNames).array + : undefined; + + const makeConnection = Effect.gen(function* () { + const scope = yield* Effect.scope; + const db = openDatabase(); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => db.close()), + ); + + const statementReaderCache = new WeakMap(); + const hasRows = (statement: Statement): boolean => { + const cached = statementReaderCache.get(statement); + if (cached !== undefined) { + return cached; + } + const value = statement.columnNames.length > 0; + statementReaderCache.set(statement, value); + return value; + }; + + const prepareCache = yield* Cache.make({ + capacity: options.prepareCacheSize ?? 200, + timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), + lookup: (sql: string) => + Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + }), + }); + + const runStatement = ( + statement: Statement, + params: ReadonlyArray, + raw: boolean, + ) => + Effect.withFiber, SqlError>((fiber) => { + statement.safeIntegers?.(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); + try { + if (hasRows(statement)) { + return Effect.succeed(statement.all(...(params as any))); + } + const result = statement.run(...(params as any)); + return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); + } catch (cause) { + return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + } + }); + + const run = (sql: string, params: ReadonlyArray, raw = false) => + Effect.flatMap(Cache.get(prepareCache, sql), (statement) => + runStatement(statement, params, raw), + ); + + const runValues = (sql: string, params: ReadonlyArray) => + Effect.flatMap(Cache.get(prepareCache, sql), (statement) => + Effect.try({ + try: () => statement.values(...(params as any)), + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + }), + ); + + return identity({ + execute(sql, params, rowTransform) { + return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); + }, + executeRaw(sql, params) { + return run(sql, params, true); + }, + executeValues(sql, params) { + return runValues(sql, params); + }, + executeUnprepared(sql, params, rowTransform) { + const effect = runStatement(db.prepare(sql), params ?? [], false); + return rowTransform ? Effect.map(effect, rowTransform) : effect; + }, + executeStream(_sql, _params) { + return Stream.die("executeStream not implemented"); + }, + }); + }); + + const semaphore = yield* Semaphore.make(1); + const connection = yield* makeConnection; + + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => + Scope.addFinalizer(scope, semaphore.release(1)), + ), + connection, + ); + }); + + return yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + }); + }); + +const make = ( + options: SqliteClientConfig, +): Effect.Effect => + makeWithDatabase( + options, + () => + new Database(options.filename, { + readonly: options.readonly ?? false, + readwrite: options.readonly === true ? false : true, + create: options.readonly === true ? false : true, + }), + ); + +export const layerConfig = ( + config: Config.Wrap, +): Layer.Layer => + Layer.effectServices( + Config.unwrap(config) + .asEffect() + .pipe( + Effect.flatMap(make), + Effect.map((client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectServices( + Effect.map(make(config), (client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 2dddfc3..df06665 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -12,7 +12,7 @@ type Loader = { layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; }; const defaultSqliteClientLoaders = { - bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), + bun: () => import("../BunSqliteClient.ts"), node: () => import("../NodeSqliteClient.ts"), } satisfies Record Promise>; diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 0e20b7d..da195d8 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -66,6 +66,7 @@ import { import { makeProviderMaintenanceCommandCoordinator } from "../providerMaintenanceCommandCoordinator"; import { enrichProviderStatusWithVersionAdvisory, + invalidateLatestProviderVersionCache, makeProviderMaintenanceCapabilities, normalizeCommandPath, parseGenericCliVersion, @@ -1954,6 +1955,7 @@ export const ProviderHealthLive = Layer.effect( }) { const child = yield* spawner.spawn( ChildProcess.make(input.command, [...input.args], { + cwd: OS.homedir(), shell: process.platform === "win32", env: process.env, }), @@ -2057,6 +2059,7 @@ export const ProviderHealthLive = Layer.effect( return { providers }; } + invalidateLatestProviderVersionCache(capabilities); const providers = yield* refreshNow.pipe(Effect.mapError(toUpdateError)); const refreshed = providers.find((status) => status.provider === provider); const stillOutdated = refreshed?.versionAdvisory?.status === "behind_latest"; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 83768eb..7aa216f 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,9 +1,12 @@ import { describe, it, assert } from "@effect/vitest"; +import { Effect } from "effect"; import { createProviderVersionAdvisory, + invalidateLatestProviderVersionCache, parseGenericCliVersion, resolvePackageManagedProviderMaintenance, + resolveLatestProviderVersion, type PackageManagedProviderMaintenanceDefinition, } from "./providerMaintenance"; @@ -33,6 +36,19 @@ const OPENCODE_DEFINITION = { }, } as const satisfies PackageManagedProviderMaintenanceDefinition; +const PI_DEFINITION = { + provider: "pi", + binaryName: "pi", + npmPackageName: "@earendil-works/pi-coding-agent", + homebrew: null, + nativeUpdate: { + executable: "pi", + args: () => ["update"], + lockKey: "pi-native", + strategy: "always", + }, +} as const satisfies PackageManagedProviderMaintenanceDefinition; + describe("providerMaintenance", () => { it("parses generic CLI versions", () => { assert.strictEqual(parseGenericCliVersion("codex-cli 0.130.0\n"), "0.130.0"); @@ -92,6 +108,30 @@ describe("providerMaintenance", () => { }); }); + it("does not self-update project-local package bin shims", () => { + const capabilities = resolvePackageManagedProviderMaintenance(PI_DEFINITION, { + binaryPath: "/repo/apps/server/node_modules/.bin/pi", + realCommandPath: "/repo/apps/server/node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + }); + + assert.strictEqual(capabilities.update, null); + }); + + it("uses the resolved global executable for native provider updates", () => { + const capabilities = resolvePackageManagedProviderMaintenance(PI_DEFINITION, { + binaryPath: "/Users/test/.bun/bin/pi", + realCommandPath: + "/Users/test/.bun/install/global/node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "/Users/test/.bun/bin/pi update", + executable: "/Users/test/.bun/bin/pi", + args: ["update"], + lockKey: "pi-native", + }); + }); + it("uses Homebrew directly for tapped OpenCode installs", () => { const capabilities = resolvePackageManagedProviderMaintenance(OPENCODE_DEFINITION, { binaryPath: "opencode", @@ -121,4 +161,44 @@ describe("providerMaintenance", () => { assert.strictEqual(advisory.currentVersion, "0.129.0"); assert.strictEqual(advisory.latestVersion, "0.130.0"); }); + + it("invalidates cached latest provider versions after updates", async () => { + const originalFetch = globalThis.fetch; + let callCount = 0; + globalThis.fetch = (async () => { + callCount += 1; + return new Response(JSON.stringify({ version: `0.13${callCount}.0` }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const capabilities = resolvePackageManagedProviderMaintenance(CODEX_DEFINITION, { + binaryPath: "codex", + realCommandPath: "/Users/test/.npm-global/lib/node_modules/@openai/codex/bin/codex", + }); + + try { + assert.strictEqual( + await Effect.runPromise(resolveLatestProviderVersion(capabilities)), + "0.131.0", + ); + assert.strictEqual( + await Effect.runPromise(resolveLatestProviderVersion(capabilities)), + "0.131.0", + ); + assert.strictEqual(callCount, 1); + + invalidateLatestProviderVersionCache(capabilities); + + assert.strictEqual( + await Effect.runPromise(resolveLatestProviderVersion(capabilities)), + "0.132.0", + ); + assert.strictEqual(callCount, 2); + } finally { + globalThis.fetch = originalFetch; + invalidateLatestProviderVersionCache(capabilities); + } + }); }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 19ac752..4a225d1 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -73,6 +73,12 @@ const latestVersionCache = new Map< >(); const SEMVER_NUMBER_SEGMENT = /^\d+$/; +function latestVersionCacheKey(source: ProviderLatestVersionSource): string { + return source.kind === "homebrew" + ? `homebrew:${source.homebrewKind ?? "unknown"}:${source.name}` + : `npm:${source.name}`; +} + function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -410,12 +416,15 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean { function isNpmGlobalCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( - normalized.includes("/node_modules/.bin/") || normalized.includes("/lib/node_modules/") || normalized.includes("/npm/node_modules/") ); } +function isNodeModulesBinCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/node_modules/.bin/"); +} + function isHomebrewCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -496,12 +505,15 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( if (!exists) { continue; } + if (isNodeModulesBinCommandPath(candidate)) { + continue; + } const realCommandPath = yield* fileSystem .realPath(candidate) .pipe(Effect.catch(() => Effect.succeed(candidate))); return resolvePackageManagedProviderMaintenance(definition, { ...options, - binaryPath, + binaryPath: candidate, realCommandPath, }); } @@ -608,10 +620,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } - const cacheKey = - source.kind === "homebrew" - ? `homebrew:${source.homebrewKind ?? "unknown"}:${source.name}` - : `npm:${source.name}`; + const cacheKey = latestVersionCacheKey(source); const cached = latestVersionCache.get(cacheKey); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { @@ -629,6 +638,16 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return version; }); +export function invalidateLatestProviderVersionCache( + maintenanceCapabilities: ProviderMaintenanceCapabilities, +): void { + const source = maintenanceCapabilities.latestVersionSource; + if (!source) { + return; + } + latestVersionCache.delete(latestVersionCacheKey(source)); +} + export const enrichProviderStatusWithVersionAdvisory = Effect.fn( "enrichProviderStatusWithVersionAdvisory", )(function* ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ff5e2f2..9cca9f0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -299,15 +299,15 @@ function ProviderUpdateNotifications() { return; } - const firstProvider = outdatedProviders[0]!; - const additionalCount = outdatedProviders.length - 1; + const firstProvider = newNotifications[0]!; + const additionalCount = newNotifications.length - 1; const providerName = PROVIDER_DISPLAY_NAMES[firstProvider.provider]; const title = - outdatedProviders.length === 1 + newNotifications.length === 1 ? messages.notification.providerUpdate.availableTitleOne(providerName) - : messages.notification.providerUpdate.availableTitleMany(outdatedProviders.length); + : messages.notification.providerUpdate.availableTitleMany(newNotifications.length); const description = - outdatedProviders.length === 1 + newNotifications.length === 1 ? messages.notification.providerUpdate.availableDescriptionOne(providerName) : messages.notification.providerUpdate.availableDescriptionMany( providerName, @@ -336,7 +336,7 @@ function ProviderUpdateNotifications() { secondaryActionProps: { children: messages.notification.providerUpdate.actionUpdateAll, onClick: () => { - void updateAll(outdatedProviders); + void updateAll(newNotifications); }, }, },