From 6ab1c55cda37e6af3d6b494cb6fe87cc0868c7a7 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:14:51 -0400 Subject: [PATCH 1/8] feat: add synthetic provider type and API key support - Add 'synthetic' to ProviderType, ApiKeyProvider, and CLIENT_STORAGE_KEYS - Add SYNTHETIC_API_KEY to server env schema - Add synthetic utility model (GLM 4.7 Flash) - Add GLM 4.7 Flash to tool model options - Add 'glm-4.7-flash' to toolModelIds --- src/features/chat/server/providers/types.ts | 6 +++++- src/features/settings/types.ts | 2 +- src/lib/api-keys/types.ts | 3 ++- src/lib/env.ts | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/chat/server/providers/types.ts b/src/features/chat/server/providers/types.ts index b3442999..becc8b7d 100644 --- a/src/features/chat/server/providers/types.ts +++ b/src/features/chat/server/providers/types.ts @@ -1,4 +1,4 @@ -export type ProviderType = "openrouter" | "openai" | "anthropic"; +export type ProviderType = "openrouter" | "openai" | "anthropic" | "synthetic"; export type ResolvedProvider = { providerType: ProviderType; @@ -14,6 +14,7 @@ export const UTILITY_MODELS: Record = { openrouter: "google/gemini-3.1-flash-lite-preview", openai: "gpt-5-nano", anthropic: "claude-haiku-4-5-20251001", + synthetic: "hf:zai-org/GLM-4.7-Flash", }; /** @@ -30,6 +31,8 @@ export type ToolModelOption = { openaiModelId?: string; /** Model ID when routing directly to Anthropic. */ anthropicModelId?: string; + /** Model ID when routing directly to Synthetic. */ + syntheticModelId?: string; /** Model ID when routing via OpenRouter. */ openrouterModelId: string; }; @@ -40,4 +43,5 @@ export const TOOL_MODEL_OPTIONS: ToolModelOption[] = [ { id: "claude-3-haiku", label: "Claude 3 Haiku", providers: ["anthropic", "openrouter"], anthropicModelId: "claude-3-haiku-20240307", openrouterModelId: "anthropic/claude-3-haiku" }, { id: "gpt-5-nano", label: "GPT-5 Nano", providers: ["openai", "openrouter"], openaiModelId: "gpt-5-nano", openrouterModelId: "openai/gpt-5-nano" }, { id: "gpt-5-mini", label: "GPT-5 Mini", providers: ["openai", "openrouter"], openaiModelId: "gpt-5-mini", openrouterModelId: "openai/gpt-5-mini" }, + { id: "glm-4.7-flash", label: "GLM 4.7 Flash", providers: ["synthetic", "openrouter"], syntheticModelId: "hf:zai-org/GLM-4.7-Flash", openrouterModelId: "zai-org/glm-4.7-flash" }, ]; diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts index 48f5e971..77cd955c 100644 --- a/src/features/settings/types.ts +++ b/src/features/settings/types.ts @@ -31,7 +31,7 @@ const accentColorSchema = z.union([ z.number().min(0).max(360), ]); -export const toolModelIds = ["gemini-flash-lite", "claude-haiku", "claude-3-haiku", "gpt-5-nano", "gpt-5-mini"] as const; +export const toolModelIds = ["gemini-flash-lite", "claude-haiku", "claude-3-haiku", "gpt-5-nano", "gpt-5-mini", "glm-4.7-flash"] as const; export type ToolModelId = (typeof toolModelIds)[number]; export const preferencesSchema = z.object({ diff --git a/src/lib/api-keys/types.ts b/src/lib/api-keys/types.ts index 374ed13f..63c8c456 100644 --- a/src/lib/api-keys/types.ts +++ b/src/lib/api-keys/types.ts @@ -1,7 +1,7 @@ /** * Supported API key providers. */ -export type ApiKeyProvider = "openrouter" | "openai" | "anthropic" | "parallel"; +export type ApiKeyProvider = "openrouter" | "openai" | "anthropic" | "synthetic" | "parallel"; /** * localStorage keys for client-side API key storage. @@ -11,5 +11,6 @@ export const CLIENT_STORAGE_KEYS: Record = { openrouter: "openrouter_api_key", openai: "openai_api_key", anthropic: "anthropic_api_key", + synthetic: "synthetic_api_key", parallel: "parallel_api_key", }; diff --git a/src/lib/env.ts b/src/lib/env.ts index defd4a1c..57ced447 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,6 +20,7 @@ const schema = { TOOLING_OPENROUTER_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), + SYNTHETIC_API_KEY: z.string().optional(), CRON_SECRET: z.string().optional(), POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_SANDBOX: z.string().optional().transform(v => v === "true"), From 0c58883af52a99f57601b66f374e4e70c8da1488 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:15:02 -0400 Subject: [PATCH 2/8] feat: add synthetic provider implementation Create synthetic.ts provider module using @ai-sdk/openai with Synthetic's OpenAI-compatible base URL (https://api.synthetic.new/v1/). --- .../chat/server/providers/synthetic.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/features/chat/server/providers/synthetic.ts diff --git a/src/features/chat/server/providers/synthetic.ts b/src/features/chat/server/providers/synthetic.ts new file mode 100644 index 00000000..068041ea --- /dev/null +++ b/src/features/chat/server/providers/synthetic.ts @@ -0,0 +1,28 @@ +import type { ProviderOptions } from "@ai-sdk/provider-utils"; + +import { createOpenAI } from "@ai-sdk/openai"; + +/** + * Creates a Synthetic AI SDK provider instance. + * + * Synthetic exposes an OpenAI-compatible API at https://api.synthetic.new/v1/, + * so we reuse the OpenAI provider with a custom base URL. + */ +export function createSyntheticProvider(apiKey: string) { + return createOpenAI({ + apiKey, + baseURL: "https://api.synthetic.new/v1/", + name: "synthetic", + }); +} + +/** + * Builds Synthetic-specific `providerOptions` for `streamText`. + * + * Synthetic is OpenAI-compatible so no special provider options are needed. + */ +export function buildSyntheticProviderOptions(_options: { + reasoningLevel?: string; +}): ProviderOptions { + return {}; +} From c2ad1b82577a285a9251cb39d496f93cd293c17a Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:15:50 -0400 Subject: [PATCH 3/8] feat: integrate synthetic provider into chat service - Add synthetic to DIRECT_PROVIDER_MAP for direct model routing - Add synthetic to resolveUtilityProvider and resolveToolProvider - Wire createSyntheticProvider/buildSyntheticProviderOptions in service - Treat synthetic as a direct provider for PDF handling - Skip reasoning stripping for synthetic (OpenAI-compatible API) --- src/features/chat/server/providers/index.ts | 16 ++++++++++++++++ src/features/chat/server/service.ts | 16 +++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/features/chat/server/providers/index.ts b/src/features/chat/server/providers/index.ts index 349c4cd9..6d3038e9 100644 --- a/src/features/chat/server/providers/index.ts +++ b/src/features/chat/server/providers/index.ts @@ -19,6 +19,7 @@ import { TOOL_MODEL_OPTIONS, UTILITY_MODELS } from "./types"; const DIRECT_PROVIDER_MAP: Record = { openai: { keyProvider: "openai", providerType: "openai" }, anthropic: { keyProvider: "anthropic", providerType: "anthropic" }, + synthetic: { keyProvider: "synthetic", providerType: "synthetic" }, }; /** @@ -114,6 +115,15 @@ export function resolveUtilityProvider( }; } + const syntheticKey = resolvedKeys.synthetic; + if (syntheticKey) { + return { + providerType: "synthetic", + providerModelId: UTILITY_MODELS.synthetic, + apiKey: syntheticKey, + }; + } + if (tier && PAID_TIERS.includes(tier) && serverEnv.TOOLING_OPENROUTER_API_KEY) { return { providerType: "openrouter", @@ -154,6 +164,11 @@ export function resolveToolProvider( return { providerType: "anthropic", providerModelId: option.anthropicModelId, apiKey: resolvedKeys.anthropic }; } + // Try direct Synthetic + if (option.syntheticModelId && resolvedKeys.synthetic) { + return { providerType: "synthetic", providerModelId: option.syntheticModelId, apiKey: resolvedKeys.synthetic }; + } + // Fall back to OpenRouter if (resolvedKeys.openrouter && option.providers.includes("openrouter")) { return { providerType: "openrouter", providerModelId: option.openrouterModelId, apiKey: resolvedKeys.openrouter }; @@ -171,5 +186,6 @@ export function resolveToolProvider( export { buildAnthropicProviderOptions, createAnthropicProvider } from "./anthropic"; export { buildOpenAIProviderOptions, createOpenAIProvider } from "./openai"; export { buildOpenRouterProviderOptions, createOpenRouterProvider } from "./openrouter"; +export { buildSyntheticProviderOptions, createSyntheticProvider } from "./synthetic"; export { TOOL_MODEL_OPTIONS, UTILITY_MODELS } from "./types"; export type { ProviderType, ResolvedProvider, ToolModelOption } from "./types"; diff --git a/src/features/chat/server/service.ts b/src/features/chat/server/service.ts index 2f7cfc9c..1021d702 100644 --- a/src/features/chat/server/service.ts +++ b/src/features/chat/server/service.ts @@ -13,9 +13,11 @@ import { buildAnthropicProviderOptions, buildOpenAIProviderOptions, buildOpenRouterProviderOptions, + buildSyntheticProviderOptions, createAnthropicProvider, createOpenAIProvider, createOpenRouterProvider, + createSyntheticProvider, } from "./providers"; import { createStreamHandlers, processStreamChunk } from "./stream"; import { createHandoffTool, createSearchTools } from "./tools"; @@ -89,13 +91,15 @@ export async function streamChatResponse( ? createOpenAIProvider(resolvedProvider.apiKey) : resolvedProvider.providerType === "anthropic" ? createAnthropicProvider(resolvedProvider.apiKey) - : createOpenRouterProvider(resolvedProvider.apiKey); + : resolvedProvider.providerType === "synthetic" + ? createSyntheticProvider(resolvedProvider.apiKey) + : createOpenRouterProvider(resolvedProvider.apiKey); const hasPdf = hasPdfAttachment(messages); // Direct providers (OpenAI, Anthropic) handle PDFs natively — they don't // need OpenRouter's file-parser plugin or OCR pipeline. - const isDirectProvider = resolvedProvider.providerType === "openai" || resolvedProvider.providerType === "anthropic"; + const isDirectProvider = resolvedProvider.providerType === "openai" || resolvedProvider.providerType === "anthropic" || resolvedProvider.providerType === "synthetic"; const effectivePdfConfig: PdfEngineConfig | undefined = isDirectProvider ? { useOcrForPdfs: false, supportsNativePdf: true } : pdfEngineConfig; @@ -116,7 +120,7 @@ export async function streamChatResponse( // in subsequent requests through proxies like OpenRouter. // OpenAI's Responses API requires reasoning items to be sent back with conversation history, // so we only strip them for non-OpenAI providers. - const messagesWithoutReasoning = resolvedProvider.providerType === "openai" + const messagesWithoutReasoning = resolvedProvider.providerType === "openai" || resolvedProvider.providerType === "synthetic" ? processedMessages : processedMessages.map((msg) => { if (msg.role !== "assistant" || !msg.parts) @@ -161,11 +165,13 @@ export async function streamChatResponse( ? buildOpenAIProviderOptions({ reasoningLevel }) : resolvedProvider.providerType === "anthropic" ? buildAnthropicProviderOptions({ reasoningLevel }) - : buildOpenRouterProviderOptions({ hasPdf, pdfEngineConfig: effectivePdfConfig, reasoningLevel }); + : resolvedProvider.providerType === "synthetic" + ? buildSyntheticProviderOptions({ reasoningLevel }) + : buildOpenRouterProviderOptions({ hasPdf, pdfEngineConfig: effectivePdfConfig, reasoningLevel }); // Strip reasoning content from model messages to prevent stale thinking block signatures // from being replayed. Used both for initial message history and between multi-step executions. - const stripReasoningFromModelMessages = resolvedProvider.providerType === "openai" + const stripReasoningFromModelMessages = resolvedProvider.providerType === "openai" || resolvedProvider.providerType === "synthetic" ? undefined : (msgs: ModelMessage[]): ModelMessage[] => msgs.map((msg) => { const currentMessageProviderOptions = (msg as { providerOptions?: unknown }).providerOptions; From 50795e0f60fb6c3965bd9a51cc92089c15943143 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:16:30 -0400 Subject: [PATCH 4/8] feat: add synthetic model sync to cron job - Add syncSyntheticProviderAvailability() to cross-reference Synthetic's /v1/models endpoint with the local models table - Wire into the cron sync-models route - Match Synthetic hf: prefixed IDs against OpenRouter model slugs --- src/app/api/cron/sync-models/route.ts | 11 +- src/features/models/server/sync-models.ts | 122 ++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/app/api/cron/sync-models/route.ts b/src/app/api/cron/sync-models/route.ts index 549b5850..863c811b 100644 --- a/src/app/api/cron/sync-models/route.ts +++ b/src/app/api/cron/sync-models/route.ts @@ -1,7 +1,7 @@ /* eslint-disable node/no-process-env */ import { NextResponse } from "next/server"; -import { syncAnthropicProviderAvailability, syncDirectProviderAvailability, syncModelsFromOpenRouter } from "~/features/models/server/sync-models"; +import { syncAnthropicProviderAvailability, syncDirectProviderAvailability, syncModelsFromOpenRouter, syncSyntheticProviderAvailability } from "~/features/models/server/sync-models"; import { serverEnv } from "~/lib/env"; // Cron endpoint to sync models from OpenRouter @@ -27,6 +27,7 @@ export async function GET(request: Request) { const providerResult = await syncDirectProviderAvailability(); const anthropicResult = await syncAnthropicProviderAvailability(); + const syntheticResult = await syncSyntheticProviderAvailability(); return NextResponse.json({ success: true, @@ -48,5 +49,13 @@ export async function GET(request: Request) { ...(anthropicResult.skipped && { skipped: true }), ...(anthropicResult.error && { error: anthropicResult.error }), }, + syntheticProviderSync: { + success: syntheticResult.success, + upserted: syntheticResult.upserted, + removed: syntheticResult.removed, + durationMs: syntheticResult.durationMs, + ...(syntheticResult.skipped && { skipped: true }), + ...(syntheticResult.error && { error: syntheticResult.error }), + }, }); } diff --git a/src/features/models/server/sync-models.ts b/src/features/models/server/sync-models.ts index a0e8b2a4..b023941a 100644 --- a/src/features/models/server/sync-models.ts +++ b/src/features/models/server/sync-models.ts @@ -419,6 +419,128 @@ export async function syncAnthropicProviderAvailability(): Promise<{ } } +/** + * Sync direct provider availability for Synthetic models. + * Fetches the Synthetic model list and cross-references with our models table + * to determine which models can be routed directly through Synthetic. + * + * Synthetic model IDs use the `hf:` prefix (e.g. "hf:zai-org/GLM-4.7"). + * We match them against OpenRouter models by comparing the HuggingFace + * org/model portion against known provider prefixes in our DB. + */ +export async function syncSyntheticProviderAvailability(): Promise<{ + success: boolean; + upserted: number; + removed: number; + durationMs: number; + skipped?: boolean; + error?: string; +}> { + const startTime = Date.now(); + + const syntheticKey = serverEnv.SYNTHETIC_API_KEY; + if (!syntheticKey) { + return { + success: true, + upserted: 0, + removed: 0, + durationMs: Date.now() - startTime, + skipped: true, + }; + } + + try { + const response = await fetch("https://api.synthetic.new/v1/models", { + headers: { Authorization: `Bearer ${syntheticKey}` }, + }); + + if (!response.ok) { + throw new Error(`Synthetic API returned ${response.status}: ${await response.text()}`); + } + + const data = await response.json() as { data: { id: string }[] }; + const syntheticModelIds = new Set(data.data.map(m => m.id)); + + // Get all models from our DB and try to match against Synthetic's catalogue. + // Synthetic uses HuggingFace-style IDs like "hf:org/Model-Name". + // We need to check which of our OpenRouter models have a corresponding + // Synthetic model available. + const allModels = await db + .select({ modelId: models.modelId }) + .from(models); + + let upserted = 0; + let removed = 0; + const now = new Date(); + + for (const { modelId } of allModels) { + // Try to find a matching Synthetic model for this OpenRouter model. + // OpenRouter IDs look like "zai-org/glm-4.7" while Synthetic uses "hf:zai-org/GLM-4.7". + const strippedId = modelId.replace(/^[^/]+\//, ""); + + if (isOpenRouterOnlyVariant(strippedId)) { + continue; + } + + // Check if any Synthetic model ID contains this model's slug (case-insensitive) + const matchedSyntheticId = [...syntheticModelIds].find((synId) => { + const synSlug = synId.replace(/^hf:/, ""); + return synSlug.toLowerCase() === modelId.toLowerCase() + || synSlug.toLowerCase() === strippedId.toLowerCase(); + }); + + if (matchedSyntheticId) { + await db + .insert(modelProviderAvailability) + .values({ + modelId, + provider: "synthetic", + providerModelId: matchedSyntheticId, + lastVerifiedAt: now, + }) + .onConflictDoUpdate({ + target: [modelProviderAvailability.modelId, modelProviderAvailability.provider], + set: { + providerModelId: matchedSyntheticId, + lastVerifiedAt: now, + }, + }); + upserted++; + } + else { + await db + .delete(modelProviderAvailability) + .where( + and( + eq(modelProviderAvailability.modelId, modelId), + eq(modelProviderAvailability.provider, "synthetic"), + ), + ); + removed++; + } + } + + return { + success: true, + upserted, + removed, + durationMs: Date.now() - startTime, + }; + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Failed to sync Synthetic provider availability:", error); + + return { + success: false, + upserted: 0, + removed: 0, + durationMs: Date.now() - startTime, + error: errorMessage, + }; + } +} + /** * Get the last successful sync time */ From 0056167f49a076098a48163036ec771632f2d201 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:18:31 -0400 Subject: [PATCH 5/8] feat: add synthetic provider to UI - Add Synthetic to integrations tab with logo and API key config - Include synthetic in all hasAnyChatKey checks across model hooks - Add synthetic to direct provider detection for PDF handling - Update API key status widget, prefetch, tools section, models tab - Update warning badge text to mention Synthetic --- public/logos/providers/synthetic.svg | 4 ++++ .../sidebar/widgets/api-key-status-widget.tsx | 9 ++++++--- src/features/chat/components/chat-input.tsx | 2 +- .../messages/inline-message-editor.tsx | 5 ++++- .../chat/hooks/use-chat-input-controller.ts | 10 +++++++--- src/features/chat/store.ts | 2 +- src/features/models/hooks/use-models.ts | 19 +++++++++++++------ .../components/sections/tools-section.tsx | 4 ++++ .../components/tabs/integrations-tab.tsx | 8 ++++++++ .../settings/components/tabs/models-tab.tsx | 9 +++++---- src/features/settings/queries.ts | 2 +- src/lib/queries/prefetch-user-data.ts | 5 ++++- 12 files changed, 58 insertions(+), 21 deletions(-) create mode 100644 public/logos/providers/synthetic.svg diff --git a/public/logos/providers/synthetic.svg b/public/logos/providers/synthetic.svg new file mode 100644 index 00000000..fcde1095 --- /dev/null +++ b/public/logos/providers/synthetic.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/src/components/sidebar/widgets/api-key-status-widget.tsx b/src/components/sidebar/widgets/api-key-status-widget.tsx index 65431aed..44faf6ca 100644 --- a/src/components/sidebar/widgets/api-key-status-widget.tsx +++ b/src/components/sidebar/widgets/api-key-status-widget.tsx @@ -9,15 +9,16 @@ export function ApiKeyStatusWidget() { const { hasKey: hasParallelKey, isLoading: isParallelLoading } = useApiKeyStatus("parallel"); const { hasKey: hasOpenAIKey, isLoading: isOpenAILoading } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey, isLoading: isAnthropicLoading } = useApiKeyStatus("anthropic"); + const { hasKey: hasSyntheticKey, isLoading: isSyntheticLoading } = useApiKeyStatus("synthetic"); - const isLoading = isOpenRouterLoading || isParallelLoading || isOpenAILoading || isAnthropicLoading; + const isLoading = isOpenRouterLoading || isParallelLoading || isOpenAILoading || isAnthropicLoading || isSyntheticLoading; const getApiKeyStatus = () => { - const keyCount = [hasOpenRouterKey, hasOpenAIKey, hasAnthropicKey, hasParallelKey].filter(Boolean).length; + const keyCount = [hasOpenRouterKey, hasOpenAIKey, hasAnthropicKey, hasSyntheticKey, hasParallelKey].filter(Boolean).length; if (keyCount === 0) { return "No API Keys Set"; } - if (keyCount === 4) { + if (keyCount === 5) { return "All API Keys Set"; } const names: string[] = []; @@ -27,6 +28,8 @@ export function ApiKeyStatusWidget() { names.push("OpenAI"); if (hasAnthropicKey) names.push("Anthropic"); + if (hasSyntheticKey) + names.push("Synthetic"); if (hasParallelKey) names.push("Parallel"); return `${names.join(", ")} ${keyCount === 1 ? "Key" : "Keys"} Set`; diff --git a/src/features/chat/components/chat-input.tsx b/src/features/chat/components/chat-input.tsx index 06cf1bba..137255f5 100644 --- a/src/features/chat/components/chat-input.tsx +++ b/src/features/chat/components/chat-input.tsx @@ -221,7 +221,7 @@ function ApiWarningBadge() { >
- No API key configured. Set up an API key (OpenRouter, OpenAI, or Anthropic) in + No API key configured. Set up an API key (OpenRouter, OpenAI, Anthropic, or Synthetic) in ( @@ -99,8 +100,10 @@ export function InlineMessageEditor({ return true; if (prefix === "anthropic" && hasAnthropicKey) return true; + if (prefix === "synthetic" && hasSyntheticKey) + return true; return false; - }, [selectedModelId, hasOpenAIKey, hasAnthropicKey]); + }, [selectedModelId, hasOpenAIKey, hasAnthropicKey, hasSyntheticKey]); const effectiveSupportsNativePdf = capabilities.supportsNativePdf || willUseDirectProvider; const canUpload = canUploadFiles(capabilities); const acceptedTypes = getAcceptedFileTypes(capabilities); diff --git a/src/features/chat/hooks/use-chat-input-controller.ts b/src/features/chat/hooks/use-chat-input-controller.ts index bc03bade..dd564ce0 100644 --- a/src/features/chat/hooks/use-chat-input-controller.ts +++ b/src/features/chat/hooks/use-chat-input-controller.ts @@ -40,11 +40,13 @@ export function useChatInputController({ = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey, isLoading: isAnthropicLoading } = useApiKeyStatus("anthropic"); + const { hasKey: hasSyntheticKey, isLoading: isSyntheticLoading } + = useApiKeyStatus("synthetic"); const { hasKey: hasParallelApiKey, isLoading: isParallelApiLoading } = useApiKeyStatus("parallel"); - const hasAnyChatKey = (hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey) ?? false; - const isAnyChatKeyLoading = isOpenRouterLoading || isOpenAILoading || isAnthropicLoading; + const hasAnyChatKey = (hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey) ?? false; + const isAnyChatKeyLoading = isOpenRouterLoading || isOpenAILoading || isAnthropicLoading || isSyntheticLoading; const { models: favoriteModels, isLoading: isModelsLoading, unavailableModelIds } = useFavoriteModels(); @@ -98,8 +100,10 @@ export function useChatInputController({ return true; if (prefix === "anthropic" && hasAnthropicKey) return true; + if (prefix === "synthetic" && hasSyntheticKey) + return true; return false; - }, [selectedModelId, hasOpenAIKey, hasAnthropicKey]); + }, [selectedModelId, hasOpenAIKey, hasAnthropicKey, hasSyntheticKey]); const effectiveSupportsNativePdf = capabilities.supportsNativePdf || willUseDirectProvider; diff --git a/src/features/chat/store.ts b/src/features/chat/store.ts index 73c30c8f..6a56d86d 100644 --- a/src/features/chat/store.ts +++ b/src/features/chat/store.ts @@ -99,7 +99,7 @@ export const useChatUIStore = create()( clientKeys: {}, loadApiKeysFromStorage: () => { const keys: Partial> = {}; - for (const provider of ["openrouter", "openai", "anthropic", "parallel"] as const) { + for (const provider of ["openrouter", "openai", "anthropic", "synthetic", "parallel"] as const) { const key = getClientKey(provider); if (key) keys[provider] = key; diff --git a/src/features/models/hooks/use-models.ts b/src/features/models/hooks/use-models.ts index 19f5193d..243b97b3 100644 --- a/src/features/models/hooks/use-models.ts +++ b/src/features/models/hooks/use-models.ts @@ -25,7 +25,8 @@ export function useModelsQuery( const { hasKey: hasOpenRouterKey } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic"); - const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; + const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic"); + const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; return useQuery({ queryKey: [...MODELS_KEY, params], @@ -46,7 +47,8 @@ export function useFavoriteModels(): { models: Model[]; isLoading: boolean; unav const { hasKey: hasOpenRouterKey } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic"); - const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; + const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic"); + const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; const { data: settings, isPending: isSettingsPending } = useUserSettings(); const favoriteIds = useMemo(() => settings?.favoriteModels ?? [], [settings?.favoriteModels]); @@ -65,8 +67,10 @@ export function useFavoriteModels(): { models: Model[]; isLoading: boolean; unav providers.push("openai"); if (hasAnthropicKey) providers.push("anthropic"); + if (hasSyntheticKey) + providers.push("synthetic"); return providers; - }, [hasOpenAIKey, hasAnthropicKey]); + }, [hasOpenAIKey, hasAnthropicKey, hasSyntheticKey]); // Only check availability when user doesn't have OpenRouter (which supports everything) const needsAvailabilityCheck = !hasOpenRouterKey && directProviders.length > 0 && favoriteIds.length > 0; @@ -105,7 +109,8 @@ export function useFavoriteModelsForList(): { models: ModelListItem[]; isLoading const { hasKey: hasOpenRouterKey } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic"); - const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; + const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic"); + const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; const { data: settings, isLoading: isSettingsLoading } = useUserSettings(); const favoriteIds = settings?.favoriteModels ?? []; @@ -135,7 +140,8 @@ export function useInfiniteModelsQuery( const { hasKey: hasOpenRouterKey } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic"); - const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; + const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic"); + const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; const pageSize = params.pageSize ?? DEFAULT_PAGE_SIZE; const query = useInfiniteQuery({ @@ -193,7 +199,8 @@ export function useInfiniteModelsListQuery( const { hasKey: hasOpenRouterKey } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic"); - const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; + const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic"); + const hasAnyChatKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; const pageSize = params.pageSize ?? DEFAULT_PAGE_SIZE; const query = useInfiniteQuery({ diff --git a/src/features/settings/components/sections/tools-section.tsx b/src/features/settings/components/sections/tools-section.tsx index 2fd8a8a4..552af6e2 100644 --- a/src/features/settings/components/sections/tools-section.tsx +++ b/src/features/settings/components/sections/tools-section.tsx @@ -111,12 +111,16 @@ export function ToolsSection({ settings }: ToolsSectionProps) { if (settings.configuredApiKeys?.anthropic || clientKeys.anthropic) { providers.add("anthropic"); } + if (settings.configuredApiKeys?.synthetic || clientKeys.synthetic) { + providers.add("synthetic"); + } // If no keys configured at all, show everything so the user can see options if (providers.size === 0) { providers.add("openrouter"); providers.add("openai"); providers.add("anthropic"); + providers.add("synthetic"); } return providers; diff --git a/src/features/settings/components/tabs/integrations-tab.tsx b/src/features/settings/components/tabs/integrations-tab.tsx index 86063974..833958d7 100644 --- a/src/features/settings/components/tabs/integrations-tab.tsx +++ b/src/features/settings/components/tabs/integrations-tab.tsx @@ -33,6 +33,14 @@ const modelProviders: ApiKeyConfig[] = [ logo: "/logos/providers/anthropic.svg", link: { href: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com/settings/keys" }, }, + { + provider: "synthetic", + label: "Synthetic", + description: "Use your Synthetic API key for direct access to open-source models hosted on Synthetic's infrastructure.", + placeholder: "sk-...", + logo: "/logos/providers/synthetic.svg", + link: { href: "https://synthetic.new/settings", label: "synthetic.new/settings" }, + }, ]; const searchProviders: ApiKeyConfig[] = [ diff --git a/src/features/settings/components/tabs/models-tab.tsx b/src/features/settings/components/tabs/models-tab.tsx index 4811a532..f80ec362 100644 --- a/src/features/settings/components/tabs/models-tab.tsx +++ b/src/features/settings/components/tabs/models-tab.tsx @@ -38,11 +38,12 @@ export function ModelsTab() { const { hasKey: hasOpenRouterKey, isLoading: isOpenRouterKeyLoading } = useApiKeyStatus("openrouter"); const { hasKey: hasOpenAIKey, isLoading: isOpenAIKeyLoading } = useApiKeyStatus("openai"); const { hasKey: hasAnthropicKey, isLoading: isAnthropicKeyLoading } = useApiKeyStatus("anthropic"); - const hasAnyKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey; - const isKeyLoading = isOpenRouterKeyLoading || isOpenAIKeyLoading || isAnthropicKeyLoading; + const { hasKey: hasSyntheticKey, isLoading: isSyntheticKeyLoading } = useApiKeyStatus("synthetic"); + const hasAnyKey = hasOpenRouterKey || hasOpenAIKey || hasAnthropicKey || hasSyntheticKey; + const isKeyLoading = isOpenRouterKeyLoading || isOpenAIKeyLoading || isAnthropicKeyLoading || isSyntheticKeyLoading; - const directProviderFilter = !hasOpenRouterKey && (hasOpenAIKey || hasAnthropicKey) - ? [hasOpenAIKey && "openai", hasAnthropicKey && "anthropic"].filter(Boolean) as string[] + const directProviderFilter = !hasOpenRouterKey && (hasOpenAIKey || hasAnthropicKey || hasSyntheticKey) + ? [hasOpenAIKey && "openai", hasAnthropicKey && "anthropic", hasSyntheticKey && "synthetic"].filter(Boolean) as string[] : undefined; const [searchQuery, setSearchQuery] = useState(""); diff --git a/src/features/settings/queries.ts b/src/features/settings/queries.ts index 4e52643a..c99c0b03 100644 --- a/src/features/settings/queries.ts +++ b/src/features/settings/queries.ts @@ -79,7 +79,7 @@ export async function getUserSettingsAndKeys( const { settings, encryptedApiKeys } = await getUserSettingsRow(userId); const resolvedKeys: ResolvedUserData["resolvedKeys"] = {}; - const providers: ApiKeyProvider[] = ["openrouter", "openai", "anthropic", "parallel"]; + const providers: ApiKeyProvider[] = ["openrouter", "openai", "anthropic", "synthetic", "parallel"]; for (const provider of providers) { if (clientKeys?.[provider]) { diff --git a/src/lib/queries/prefetch-user-data.ts b/src/lib/queries/prefetch-user-data.ts index 5da4b76b..4478bfad 100644 --- a/src/lib/queries/prefetch-user-data.ts +++ b/src/lib/queries/prefetch-user-data.ts @@ -14,6 +14,7 @@ export async function prefetchUserData(userId: string) { const hasOpenRouterPromise = hasEncryptedKey(userId, "openrouter"); const hasOpenAIPromise = hasEncryptedKey(userId, "openai"); const hasAnthropicPromise = hasEncryptedKey(userId, "anthropic"); + const hasSyntheticPromise = hasEncryptedKey(userId, "synthetic"); const hasParallelPromise = hasEncryptedKey(userId, "parallel"); const threadsPromise = queryClient.prefetchInfiniteQuery({ queryKey: THREADS_KEY, @@ -30,11 +31,12 @@ export async function prefetchUserData(userId: string) { }); // Wait for settings + API key status (needed to determine favorite IDs) - const [settings, hasOpenRouter, hasOpenAI, hasAnthropic, hasParallel] = await Promise.all([ + const [settings, hasOpenRouter, hasOpenAI, hasAnthropic, hasSynthetic, hasParallel] = await Promise.all([ settingsPromise, hasOpenRouterPromise, hasOpenAIPromise, hasAnthropicPromise, + hasSyntheticPromise, hasParallelPromise, ]); @@ -52,6 +54,7 @@ export async function prefetchUserData(userId: string) { openrouter: hasOpenRouter, openai: hasOpenAI, anthropic: hasAnthropic, + synthetic: hasSynthetic, parallel: hasParallel, }, }); From da0a007ad75b4f59d55ae0b041065ccc4a447f01 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:25:52 -0400 Subject: [PATCH 6/8] refactor: update synthetic provider logo --- public/logos/providers/synthetic.svg | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) mode change 100644 => 100755 public/logos/providers/synthetic.svg diff --git a/public/logos/providers/synthetic.svg b/public/logos/providers/synthetic.svg old mode 100644 new mode 100755 index fcde1095..326513a3 --- a/public/logos/providers/synthetic.svg +++ b/public/logos/providers/synthetic.svg @@ -1,4 +1 @@ - - - S - + \ No newline at end of file From 456c9ffaf8bd044992b7c2c4725d841ec788e6e4 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:26:35 -0400 Subject: [PATCH 7/8] refactor: correct perms on synthetic logo --- public/logos/providers/synthetic.svg | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 public/logos/providers/synthetic.svg diff --git a/public/logos/providers/synthetic.svg b/public/logos/providers/synthetic.svg old mode 100755 new mode 100644 From 7f237116ae5bd8c2a23c2f4c11a8692c8e2c7698 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 19 Apr 2026 17:48:06 -0400 Subject: [PATCH 8/8] fix: persist and sync Synthetic-only models to the models table - Skip Synthetic-sourced models during OpenRouter cleanup - Insert Synthetic-only models (no OpenRouter equivalent) into models table - Clean up stale Synthetic-only models removed from Synthetic catalog - Add resolveProvider fallback for non-prefix-matched models (hf:org/Model) --- src/app/api/cron/sync-models/route.ts | 1 + src/features/chat/server/providers/index.ts | 31 ++++ src/features/models/server/sync-models.ts | 150 ++++++++++++++++---- 3 files changed, 158 insertions(+), 24 deletions(-) diff --git a/src/app/api/cron/sync-models/route.ts b/src/app/api/cron/sync-models/route.ts index 863c811b..58d29d02 100644 --- a/src/app/api/cron/sync-models/route.ts +++ b/src/app/api/cron/sync-models/route.ts @@ -53,6 +53,7 @@ export async function GET(request: Request) { success: syntheticResult.success, upserted: syntheticResult.upserted, removed: syntheticResult.removed, + inserted: syntheticResult.inserted, durationMs: syntheticResult.durationMs, ...(syntheticResult.skipped && { skipped: true }), ...(syntheticResult.error && { error: syntheticResult.error }), diff --git a/src/features/chat/server/providers/index.ts b/src/features/chat/server/providers/index.ts index 6d3038e9..c94cba0b 100644 --- a/src/features/chat/server/providers/index.ts +++ b/src/features/chat/server/providers/index.ts @@ -63,6 +63,37 @@ export async function resolveProvider( } } + // For models not matched by prefix (e.g. Synthetic-only "hf:org/Model"), + // check provider availability directly. + if (!directMapping) { + const availability = await db + .select({ + provider: modelProviderAvailability.provider, + providerModelId: modelProviderAvailability.providerModelId, + }) + .from(modelProviderAvailability) + .where(eq(modelProviderAvailability.modelId, modelId)) + .limit(1); + + if (availability.length > 0) { + const entry = availability[0]; + const mapping = Object.values(DIRECT_PROVIDER_MAP).find( + m => m.providerType === entry.provider, + ); + + if (mapping) { + const key = resolvedKeys[mapping.keyProvider]; + if (key) { + return { + providerType: mapping.providerType, + providerModelId: entry.providerModelId, + apiKey: key, + }; + } + } + } + } + const openrouterKey = resolvedKeys.openrouter; if (openrouterKey) { return { diff --git a/src/features/models/server/sync-models.ts b/src/features/models/server/sync-models.ts index b023941a..370ecc8c 100644 --- a/src/features/models/server/sync-models.ts +++ b/src/features/models/server/sync-models.ts @@ -86,11 +86,12 @@ export async function syncModelsFromOpenRouter(): Promise<{ }); } - // Delete models that no longer exist in OpenRouter + // Delete models that no longer exist in OpenRouter. + // Skip Synthetic-sourced models — they are managed by syncSyntheticProviderAvailability. const currentModelIds = new Set(filteredModels.map((m: Model) => m.id)); - const existingModels = await db.select({ modelId: models.modelId }).from(models); + const existingModels = await db.select({ modelId: models.modelId, provider: models.provider }).from(models); for (const existing of existingModels) { - if (!currentModelIds.has(existing.modelId)) { + if (!currentModelIds.has(existing.modelId) && existing.provider !== "synthetic") { await db.delete(models).where(eq(models.modelId, existing.modelId)); } } @@ -424,14 +425,18 @@ export async function syncAnthropicProviderAvailability(): Promise<{ * Fetches the Synthetic model list and cross-references with our models table * to determine which models can be routed directly through Synthetic. * + * Models that exist on both OpenRouter and Synthetic get a provider availability + * entry. Models that are Synthetic-only (no OpenRouter equivalent) are inserted + * directly into the `models` table with provider "synthetic" so they appear in + * the model picker. + * * Synthetic model IDs use the `hf:` prefix (e.g. "hf:zai-org/GLM-4.7"). - * We match them against OpenRouter models by comparing the HuggingFace - * org/model portion against known provider prefixes in our DB. */ export async function syncSyntheticProviderAvailability(): Promise<{ success: boolean; upserted: number; removed: number; + inserted: number; durationMs: number; skipped?: boolean; error?: string; @@ -444,6 +449,7 @@ export async function syncSyntheticProviderAvailability(): Promise<{ success: true, upserted: 0, removed: 0, + inserted: 0, durationMs: Date.now() - startTime, skipped: true, }; @@ -458,50 +464,55 @@ export async function syncSyntheticProviderAvailability(): Promise<{ throw new Error(`Synthetic API returned ${response.status}: ${await response.text()}`); } - const data = await response.json() as { data: { id: string }[] }; - const syntheticModelIds = new Set(data.data.map(m => m.id)); + const data = await response.json() as { data: { id: string; created?: number }[] }; + const syntheticModels = data.data; + const syntheticModelIds = new Set(syntheticModels.map(m => m.id)); - // Get all models from our DB and try to match against Synthetic's catalogue. - // Synthetic uses HuggingFace-style IDs like "hf:org/Model-Name". - // We need to check which of our OpenRouter models have a corresponding - // Synthetic model available. - const allModels = await db + // Build a lookup from lowercase slug → Synthetic model for matching + const syntheticBySlug = new Map(); + for (const m of syntheticModels) { + const slug = m.id.replace(/^hf:/, ""); + syntheticBySlug.set(slug.toLowerCase(), m); + } + + // Get all existing models from our DB + const allDbModels = await db .select({ modelId: models.modelId }) .from(models); + const dbModelIdSet = new Set(allDbModels.map(m => m.modelId)); let upserted = 0; let removed = 0; + let inserted = 0; const now = new Date(); + const matchedSyntheticIds = new Set(); - for (const { modelId } of allModels) { - // Try to find a matching Synthetic model for this OpenRouter model. - // OpenRouter IDs look like "zai-org/glm-4.7" while Synthetic uses "hf:zai-org/GLM-4.7". + // Phase 1: Cross-reference existing DB models against Synthetic's catalogue + for (const { modelId } of allDbModels) { const strippedId = modelId.replace(/^[^/]+\//, ""); if (isOpenRouterOnlyVariant(strippedId)) { continue; } - // Check if any Synthetic model ID contains this model's slug (case-insensitive) - const matchedSyntheticId = [...syntheticModelIds].find((synId) => { - const synSlug = synId.replace(/^hf:/, ""); - return synSlug.toLowerCase() === modelId.toLowerCase() - || synSlug.toLowerCase() === strippedId.toLowerCase(); - }); + // Try matching by full modelId or stripped slug (case-insensitive) + const matched = syntheticBySlug.get(modelId.toLowerCase()) + ?? syntheticBySlug.get(strippedId.toLowerCase()); - if (matchedSyntheticId) { + if (matched) { + matchedSyntheticIds.add(matched.id); await db .insert(modelProviderAvailability) .values({ modelId, provider: "synthetic", - providerModelId: matchedSyntheticId, + providerModelId: matched.id, lastVerifiedAt: now, }) .onConflictDoUpdate({ target: [modelProviderAvailability.modelId, modelProviderAvailability.provider], set: { - providerModelId: matchedSyntheticId, + providerModelId: matched.id, lastVerifiedAt: now, }, }); @@ -520,10 +531,100 @@ export async function syncSyntheticProviderAvailability(): Promise<{ } } + // Phase 2: Insert Synthetic-only models that have no OpenRouter equivalent. + // These use the Synthetic model ID directly as their modelId (e.g. "hf:zai-org/GLM-4.7"). + for (const synModel of syntheticModels) { + if (matchedSyntheticIds.has(synModel.id)) { + continue; + } + + // Skip embedding models (they don't have chat completions) + if (synModel.id.toLowerCase().includes("embed")) { + continue; + } + + const syntheticModelId = synModel.id; + + // Derive a human-readable name from the HF-style ID: "hf:org/Model-Name" → "Model-Name" + const slug = syntheticModelId.replace(/^hf:/, ""); + const namePart = slug.split("/").pop() ?? slug; + + // Check if this Synthetic model was already inserted in a previous sync + if (!dbModelIdSet.has(syntheticModelId)) { + await db + .insert(models) + .values({ + modelId: syntheticModelId, + canonicalSlug: slug, + name: namePart, + description: null, + provider: "synthetic", + contextLength: null, + created: synModel.created ?? Math.floor(Date.now() / 1000), + pricingPrompt: null, + pricingCompletion: null, + pricingImage: null, + pricingRequest: null, + inputModalities: ["text"], + outputModalities: ["text"], + supportedParameters: [], + rawData: synModel as unknown as Record, + lastSyncedAt: now, + }) + .onConflictDoUpdate({ + target: models.modelId, + set: { + rawData: synModel as unknown as Record, + lastSyncedAt: now, + }, + }); + inserted++; + } + + // Also add provider availability entry for the Synthetic-only model + await db + .insert(modelProviderAvailability) + .values({ + modelId: syntheticModelId, + provider: "synthetic", + providerModelId: syntheticModelId, + lastVerifiedAt: now, + }) + .onConflictDoUpdate({ + target: [modelProviderAvailability.modelId, modelProviderAvailability.provider], + set: { + providerModelId: syntheticModelId, + lastVerifiedAt: now, + }, + }); + upserted++; + } + + // Phase 3: Remove Synthetic-only models from the models table if they + // no longer appear in Synthetic's catalogue. + const syntheticOnlyModels = await db + .select({ modelId: models.modelId }) + .from(models) + .where(eq(models.provider, "synthetic")); + + for (const { modelId } of syntheticOnlyModels) { + if (!syntheticModelIds.has(modelId)) { + await db.delete(modelProviderAvailability).where( + and( + eq(modelProviderAvailability.modelId, modelId), + eq(modelProviderAvailability.provider, "synthetic"), + ), + ); + await db.delete(models).where(eq(models.modelId, modelId)); + removed++; + } + } + return { success: true, upserted, removed, + inserted, durationMs: Date.now() - startTime, }; } @@ -535,6 +636,7 @@ export async function syncSyntheticProviderAvailability(): Promise<{ success: false, upserted: 0, removed: 0, + inserted: 0, durationMs: Date.now() - startTime, error: errorMessage, };