Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/logos/providers/synthetic.svg

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like there's some white in the middle of this SVG - we should clean that up

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 11 additions & 1 deletion src/app/api/cron/sync-models/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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 }),
},
});
}
9 changes: 6 additions & 3 deletions src/components/sidebar/widgets/api-key-status-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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`;
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ function ApiWarningBadge() {
>
<AlertCircle className="text-warning mt-0.5 size-5 shrink-0" />
<div className="text-warning-foreground text-sm">
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's change this to "(OpenRouter, OpenAI, Anthropic, etc.)" for the future

<Link
href="/settings?section=integrations"
className={`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function InlineMessageEditor({
const { hasKey: hasParallelApiKey, isLoading: isParallelApiLoading } = useApiKeyStatus("parallel");
const { hasKey: hasOpenAIKey } = useApiKeyStatus("openai");
const { hasKey: hasAnthropicKey } = useApiKeyStatus("anthropic");
const { hasKey: hasSyntheticKey } = useApiKeyStatus("synthetic");

const { models: favoriteModels, unavailableModelIds } = useFavoriteModels();
const [selectedModelId, setSelectedModelId] = React.useState<string | null>(
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions src/features/chat/hooks/use-chat-input-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand Down
47 changes: 47 additions & 0 deletions src/features/chat/server/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TOOL_MODEL_OPTIONS, UTILITY_MODELS } from "./types";
const DIRECT_PROVIDER_MAP: Record<string, { keyProvider: ApiKeyProvider; providerType: ProviderType }> = {
openai: { keyProvider: "openai", providerType: "openai" },
anthropic: { keyProvider: "anthropic", providerType: "anthropic" },
synthetic: { keyProvider: "synthetic", providerType: "synthetic" },
};

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 };
Expand All @@ -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";
28 changes: 28 additions & 0 deletions src/features/chat/server/providers/synthetic.ts
Original file line number Diff line number Diff line change
@@ -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 {};
}
6 changes: 5 additions & 1 deletion src/features/chat/server/providers/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProviderType = "openrouter" | "openai" | "anthropic";
export type ProviderType = "openrouter" | "openai" | "anthropic" | "synthetic";

export type ResolvedProvider = {
providerType: ProviderType;
Expand All @@ -14,6 +14,7 @@ export const UTILITY_MODELS: Record<ProviderType, string> = {
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",
};

/**
Expand All @@ -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;
};
Expand All @@ -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" },
];
16 changes: 11 additions & 5 deletions src/features/chat/server/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const useChatUIStore = create<ChatUIStore>()(
clientKeys: {},
loadApiKeysFromStorage: () => {
const keys: Partial<Record<ApiKeyProvider, string>> = {};
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;
Expand Down
Loading
Loading