diff --git a/public/logos/providers/synthetic.svg b/public/logos/providers/synthetic.svg new file mode 100644 index 00000000..326513a3 --- /dev/null +++ b/public/logos/providers/synthetic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/cron/sync-models/route.ts b/src/app/api/cron/sync-models/route.ts index 549b5850..58d29d02 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,14 @@ 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, + inserted: syntheticResult.inserted, + durationMs: syntheticResult.durationMs, + ...(syntheticResult.skipped && { skipped: true }), + ...(syntheticResult.error && { error: syntheticResult.error }), + }, }); } 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/server/providers/index.ts b/src/features/chat/server/providers/index.ts index 349c4cd9..c94cba0b 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" }, }; /** @@ -62,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 { @@ -114,6 +146,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 +195,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 +217,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/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 {}; +} 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/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; 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/models/server/sync-models.ts b/src/features/models/server/sync-models.ts index a0e8b2a4..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)); } } @@ -419,6 +420,229 @@ 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. + * + * 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"). + */ +export async function syncSyntheticProviderAvailability(): Promise<{ + success: boolean; + upserted: number; + removed: number; + inserted: 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, + inserted: 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; created?: number }[] }; + const syntheticModels = data.data; + const syntheticModelIds = new Set(syntheticModels.map(m => m.id)); + + // 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(); + + // Phase 1: Cross-reference existing DB models against Synthetic's catalogue + for (const { modelId } of allDbModels) { + const strippedId = modelId.replace(/^[^/]+\//, ""); + + if (isOpenRouterOnlyVariant(strippedId)) { + continue; + } + + // Try matching by full modelId or stripped slug (case-insensitive) + const matched = syntheticBySlug.get(modelId.toLowerCase()) + ?? syntheticBySlug.get(strippedId.toLowerCase()); + + if (matched) { + matchedSyntheticIds.add(matched.id); + await db + .insert(modelProviderAvailability) + .values({ + modelId, + provider: "synthetic", + providerModelId: matched.id, + lastVerifiedAt: now, + }) + .onConflictDoUpdate({ + target: [modelProviderAvailability.modelId, modelProviderAvailability.provider], + set: { + providerModelId: matched.id, + lastVerifiedAt: now, + }, + }); + upserted++; + } + else { + await db + .delete(modelProviderAvailability) + .where( + and( + eq(modelProviderAvailability.modelId, modelId), + eq(modelProviderAvailability.provider, "synthetic"), + ), + ); + removed++; + } + } + + // 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, + }; + } + 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, + inserted: 0, + durationMs: Date.now() - startTime, + error: errorMessage, + }; + } +} + /** * Get the last successful sync time */ 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/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"), 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, }, });