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,
},
});