mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
refactor(providers): the UI, the connection test and the routes read the record's fields
Step 5 of the provider refactor plan. Provider names gave way to the record's credential and sdk fields in: hasCredentials (one rule per credential kind), the connection test (an Object.hasOwn check first, then a credential switch; EdgeOne's site endpoint and cookie forwarding follow credential none), the chat route (the on-site function and the single-system rule for the OpenAI SDK), the credentials form (which fields to show, the optional-key label, the base URL hint from the record), the picker (the note from the record), the logo (icon and logo fields), the settings dialog (test blocker, error hints, EdgeOne origin), the admin panel (fixedCredentials), chatRequestUrl (sdk), the model list (sdk aihubmix) and the zod schema (Object.hasOwn instead of 'in'). The eight provider-specific dictionary entries moved into the record as four-language objects; needAzure became a generic needUrl. Two intended behavior changes: the connection test answers 400 'Invalid provider' for an unknown name whether or not a key is sent (before: 400 'API key is required' without a key, 200 with an error text with one); and Azure has no placeholder default URL any more, so a new Azure entry starts with an empty URL and the generic 'needs a URL' rule applies. The e2e Azure test seeds no URL and expects the new wording. Out-of-layer provider-name checks: 44 before, 6 after (the admin panel's fixed-variable branches for Bedrock, Vertex AI and Ollama, kept on purpose). Behavior snapshot unchanged.
This commit is contained in:
@@ -33,14 +33,9 @@ import {
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { generateId, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
@@ -72,7 +67,7 @@ function ProviderDetail({
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const info = PROVIDERS[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
@@ -500,7 +495,7 @@ export function ModelsSection({
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
{p.name || PROVIDERS[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
@@ -526,13 +521,13 @@ export function ModelsSection({
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
{(Object.keys(PROVIDERS) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
!!PROVIDERS[p].fixedCredentials &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
@@ -544,7 +539,7 @@ export function ModelsSection({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{PROVIDERS[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
|
||||
+18
-13
@@ -47,6 +47,7 @@ import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
type FlattenedServerModel,
|
||||
findServerModelById,
|
||||
@@ -173,14 +174,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
const requestedProvider =
|
||||
serverModelConfig.provider || provider || getServerProvider()
|
||||
// A provider without credentials (EdgeOne) is this deployment's own
|
||||
// function, whatever URL the request names: another host would get the
|
||||
// user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
|
||||
// needs.
|
||||
const onSiteFunction =
|
||||
!!requestedProvider &&
|
||||
Object.hasOwn(PROVIDERS, requestedProvider) &&
|
||||
PROVIDERS[requestedProvider as ProviderName].credential === "none"
|
||||
if (onSiteFunction) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
@@ -195,12 +199,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
// Server model provider takes precedence over client header; the
|
||||
// site's function named only in AI_PROVIDER is named here, for its
|
||||
// own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
(onSiteFunction ? requestedProvider : null),
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
@@ -216,7 +221,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(isEdgeOne && {
|
||||
...(onSiteFunction && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
@@ -420,7 +425,7 @@ ${userInputText}
|
||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||
const isCustomOpenAIEndpoint =
|
||||
resolvedProvider === "openai" &&
|
||||
PROVIDERS[resolvedProvider].sdk === "openai" &&
|
||||
!!(
|
||||
baseUrl ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
|
||||
@@ -55,13 +55,14 @@ import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import type { ListedModel } from "@/lib/provider-models"
|
||||
import { hasCredentials } from "@/lib/provider-setup"
|
||||
import { PROVIDERS } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type {
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
@@ -300,7 +301,7 @@ export function ModelConfigDialog({
|
||||
const errorHints = (p: ProviderConfig): Record<string, string> => ({
|
||||
...(dict.errors.llm as Record<string, string>),
|
||||
invalid_api_key:
|
||||
p.provider === "bedrock" && !p.apiKey
|
||||
PROVIDERS[p.provider].credential === "aws" && !p.apiKey
|
||||
? t.awsKeysRejected
|
||||
: t.keyRejected,
|
||||
model_not_found: t.modelNotFound,
|
||||
@@ -530,9 +531,9 @@ export function ModelConfigDialog({
|
||||
const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1
|
||||
latestTestRun.set(selectedProviderId, run)
|
||||
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
// The site's own function (EdgeOne): its URL is this origin's
|
||||
const baseUrl =
|
||||
selectedProvider.provider === "edgeone"
|
||||
PROVIDERS[selectedProvider.provider].credential === "none"
|
||||
? `${window.location.origin}/api/edgeai`
|
||||
: selectedProvider.baseUrl
|
||||
const hints = errorHints(selectedProvider)
|
||||
@@ -741,7 +742,7 @@ export function ModelConfigDialog({
|
||||
}
|
||||
|
||||
const providerName = (p: ProviderConfig) =>
|
||||
p.name || PROVIDER_INFO[p.provider].label
|
||||
p.name || PROVIDERS[p.provider].label
|
||||
|
||||
const renderModelRow = (model: ModelConfig) => {
|
||||
if (!selectedProvider) return null
|
||||
@@ -922,11 +923,13 @@ export function ModelConfigDialog({
|
||||
: undefined
|
||||
const canTest = credentialsDone && models.length > 0
|
||||
const testing = validationStatus === "validating"
|
||||
const def = PROVIDERS[provider.provider]
|
||||
// A key-only provider without a default URL (Azure) needs its URL too
|
||||
const testBlocker = !credentialsDone
|
||||
? provider.provider === "bedrock"
|
||||
? def.credential === "aws"
|
||||
? t.needAws
|
||||
: provider.provider === "azure"
|
||||
? t.needAzure
|
||||
: def.credential === "apiKey" && !def.defaultBaseUrl
|
||||
? t.needUrl
|
||||
: t.needKey
|
||||
: models.length === 0
|
||||
? t.needModel
|
||||
@@ -984,7 +987,7 @@ export function ModelConfigDialog({
|
||||
done={credentialsDone && !keyRejected}
|
||||
title={t.stepConnect}
|
||||
>
|
||||
{provider.provider === "edgeone" ? (
|
||||
{def.credential === "none" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.edgeoneNoKey}
|
||||
</p>
|
||||
@@ -1027,7 +1030,7 @@ export function ModelConfigDialog({
|
||||
done={models.length > 0}
|
||||
title={t.stepModels}
|
||||
action={
|
||||
PROVIDER_INFO[provider.provider].modelList && (
|
||||
def.modelList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchModels}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronRight, Key, Link2, Tag } from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -13,13 +14,8 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { OPTIONS_OPEN } from "@/lib/provider-setup"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
@@ -94,7 +90,8 @@ export function ProviderCredentialsFields({
|
||||
sessionTokenInput,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
const locale = useLocale()
|
||||
const info = PROVIDERS[provider]
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
@@ -105,7 +102,7 @@ export function ProviderCredentialsFields({
|
||||
() =>
|
||||
!!name ||
|
||||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
|
||||
OPTIONS_OPEN.includes(provider),
|
||||
!!info.optionsOpen,
|
||||
)
|
||||
// Bedrock: the way that is filled in (an API key is used first when both
|
||||
// are, as the server does), else the one shown last, so emptying the
|
||||
@@ -199,7 +196,8 @@ export function ProviderCredentialsFields({
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
|
||||
{info.credential === "optionalKey" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
@@ -248,26 +246,21 @@ export function ProviderCredentialsFields({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
{info.baseUrlHint && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
{info.baseUrlHint[locale]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// EdgeOne needs no credentials
|
||||
if (provider === "edgeone") return null
|
||||
// A provider without credentials (EdgeOne) has no fields
|
||||
if (info.credential === "none") return null
|
||||
|
||||
if (settingsLayout) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{provider === "bedrock" ? (
|
||||
{info.credential === "aws" ? (
|
||||
<>
|
||||
{/* Buttons, not radios: arrow keys would empty
|
||||
secrets without a deliberate press */}
|
||||
@@ -393,7 +386,7 @@ export function ProviderCredentialsFields({
|
||||
)}
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
) : info.credential === "vertexKey" ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
@@ -423,14 +416,14 @@ export function ProviderCredentialsFields({
|
||||
optionsOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{provider === "bedrock"
|
||||
{info.credential === "aws"
|
||||
? dict.modelConfig.moreOptionsName
|
||||
: dict.modelConfig.moreOptions}
|
||||
</button>
|
||||
{optionsOpen && (
|
||||
<>
|
||||
{provider !== "bedrock" &&
|
||||
(provider === "vertexai"
|
||||
{info.credential !== "aws" &&
|
||||
(info.credential === "vertexKey"
|
||||
? vertexBaseUrlField
|
||||
: baseUrlField)}
|
||||
{nameField}
|
||||
@@ -445,7 +438,7 @@ export function ProviderCredentialsFields({
|
||||
<div className="space-y-5">
|
||||
{nameField}
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
{info.credential === "aws" ? (
|
||||
<>
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
@@ -479,7 +472,7 @@ export function ProviderCredentialsFields({
|
||||
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
) : info.credential === "vertexKey" ? (
|
||||
<>
|
||||
{/* Vertex AI API Key (Express Mode) */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||
const ICONS = { cloud: Cloud, server: Server, sparkles: Sparkles }
|
||||
|
||||
// Provider logo from models.dev, or the record's Lucide icon for providers
|
||||
// that have no logo there
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
@@ -11,17 +13,11 @@ export function ProviderLogo({
|
||||
provider: ProviderName
|
||||
className?: string
|
||||
}) {
|
||||
if (provider === "bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
const { icon, logo } = PROVIDERS[provider]
|
||||
if (icon) {
|
||||
const Icon = ICONS[icon]
|
||||
return <Icon className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "sglang") {
|
||||
return <Server className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "doubao") {
|
||||
return <Sparkles className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
@@ -29,7 +25,7 @@ export function ProviderLogo({
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${logoName}.svg`}
|
||||
src={`https://models.dev/logos/${logo ?? provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChevronRight, Search } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { filterProviderGroups, PROVIDER_NOTE_KEYS } from "@/lib/provider-setup"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
import { filterProviderGroups } from "@/lib/provider-setup"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
|
||||
/** Every provider in groups, with a search; picking one adds it */
|
||||
export function ProviderPicker({
|
||||
@@ -20,6 +21,7 @@ export function ProviderPicker({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const locale = useLocale()
|
||||
const [query, setQuery] = useState("")
|
||||
const groups = filterProviderGroups(query)
|
||||
const groupLabels: Record<string, string> = {
|
||||
@@ -28,8 +30,6 @@ export function ProviderPicker({
|
||||
cloud: t.groupCloud,
|
||||
selfHosted: t.groupSelfHosted,
|
||||
}
|
||||
const notes = t as Record<string, string>
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SettingsHeader>
|
||||
@@ -93,9 +93,8 @@ export function ProviderPicker({
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
|
||||
{group.providers.map((provider) => {
|
||||
const noteKey = PROVIDER_NOTE_KEYS[provider]
|
||||
const note = [
|
||||
noteKey && notes[noteKey],
|
||||
PROVIDERS[provider].note?.[locale],
|
||||
added.includes(provider) && t.added,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -116,14 +115,11 @@ export function ProviderPicker({
|
||||
<span
|
||||
className="block truncate text-[12.5px] font-medium"
|
||||
title={
|
||||
PROVIDER_INFO[provider]
|
||||
PROVIDERS[provider]
|
||||
.label
|
||||
}
|
||||
>
|
||||
{
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
{PROVIDERS[provider].label}
|
||||
</span>
|
||||
{note && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { z } from "zod"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
ProviderNameSchema,
|
||||
type ServerModelsConfig,
|
||||
slugify,
|
||||
} from "@/lib/server-model-config"
|
||||
import { FIXED_CRED_PROVIDERS, PROVIDER_INFO } from "@/lib/types/model-config"
|
||||
import { type MaskedSecret, maskSecret } from "./auth"
|
||||
import { loadSettings } from "./settings"
|
||||
|
||||
@@ -158,7 +157,7 @@ export function mergeSecrets(
|
||||
}
|
||||
|
||||
function displayName(p: StoredAdminProvider): string {
|
||||
return p.name?.trim() || PROVIDER_INFO[p.provider].label
|
||||
return p.name?.trim() || PROVIDERS[p.provider].label
|
||||
}
|
||||
|
||||
export function validateAdminProviders(
|
||||
@@ -166,9 +165,11 @@ export function validateAdminProviders(
|
||||
envConfig: ServerModelsConfig | null = null,
|
||||
): string | null {
|
||||
const envProviders = envConfig?.providers ?? []
|
||||
for (const single of FIXED_CRED_PROVIDERS) {
|
||||
// Providers whose credentials live in fixed variables can have one entry
|
||||
const fixed = PROVIDER_NAMES.filter((p) => PROVIDERS[p].fixedCredentials)
|
||||
for (const single of fixed) {
|
||||
if (list.filter((p) => p.provider === single).length > 1) {
|
||||
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
|
||||
return `Only one ${PROVIDERS[single].label} provider is supported (its credentials use fixed environment variables).`
|
||||
}
|
||||
// Its credentials are global; a panel instance would silently
|
||||
// override the credentials env-configured models rely on
|
||||
@@ -176,7 +177,7 @@ export function validateAdminProviders(
|
||||
list.some((p) => p.provider === single) &&
|
||||
envProviders.some((p) => p.provider === single)
|
||||
) {
|
||||
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
||||
return `${PROVIDERS[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
||||
}
|
||||
}
|
||||
// Server model ids are built from the slugified name, so names must
|
||||
|
||||
+1
-1
@@ -1031,7 +1031,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
const isClientOverride = !!(
|
||||
overrides?.provider &&
|
||||
(overrides?.apiKey ||
|
||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||
(requested?.credential === "vertexKey" && overrides?.vertexApiKey))
|
||||
)
|
||||
|
||||
// Use client override if provided, otherwise fall back to env vars.
|
||||
|
||||
@@ -299,8 +299,6 @@
|
||||
"requestUrl": "Requests go to {url}",
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
@@ -343,12 +341,6 @@
|
||||
"groupAggregators": "Aggregators",
|
||||
"groupCloud": "Cloud platforms",
|
||||
"groupSelfHosted": "Self-hosted",
|
||||
"noteBedrock": "AWS credentials",
|
||||
"noteVertex": "Express mode key",
|
||||
"noteAzure": "Key and resource URL",
|
||||
"noteEdgeone": "No key needed",
|
||||
"noteOllama": "Key optional",
|
||||
"noteSglang": "Your server's URL",
|
||||
"added": "Added",
|
||||
"awsCredentials": "AWS credentials",
|
||||
"bedrockAuthApiKey": "Bedrock API key",
|
||||
@@ -362,6 +354,7 @@
|
||||
"modelFailed": "Failed",
|
||||
"modelUntested": "Untested",
|
||||
"needKey": "Fill in the API key first.",
|
||||
"needUrl": "Fill in the API key and the base URL first.",
|
||||
"needAws": "Fill in the AWS credentials and region first.",
|
||||
"needModel": "Add a model first.",
|
||||
"stepConnect": "Connection",
|
||||
@@ -384,7 +377,6 @@
|
||||
"deleteProviderOnly": "Delete {name}",
|
||||
"addProviderEntry": "Add a provider…",
|
||||
"awsKeysRejected": "AWS rejected these access keys. Check that both were copied in full.",
|
||||
"needAzure": "Fill in the API key and your Azure resource's base URL first.",
|
||||
"validationFailedCountOne": "{count} model failed the test",
|
||||
"validationFailedCountOther": "{count} models failed the test",
|
||||
"deleteProviderNamedOne": "Delete {name} and its model",
|
||||
|
||||
@@ -254,8 +254,6 @@
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
@@ -298,12 +296,6 @@
|
||||
"groupAggregators": "アグリゲーター",
|
||||
"groupCloud": "クラウド",
|
||||
"groupSelfHosted": "セルフホスト",
|
||||
"noteBedrock": "AWS 認証情報",
|
||||
"noteVertex": "Express モードのキー",
|
||||
"noteAzure": "キーとリソース URL",
|
||||
"noteEdgeone": "キー不要",
|
||||
"noteOllama": "キーは任意",
|
||||
"noteSglang": "サーバーの URL",
|
||||
"added": "追加済み",
|
||||
"awsCredentials": "AWS 認証情報",
|
||||
"bedrockAuthApiKey": "Bedrock API キー",
|
||||
@@ -317,6 +309,7 @@
|
||||
"modelFailed": "エラー",
|
||||
"modelUntested": "未テスト",
|
||||
"needKey": "先に API キーを入力してください。",
|
||||
"needUrl": "先に API キーとベース URL を入力してください。",
|
||||
"needAws": "先に AWS 認証情報とリージョンを入力してください。",
|
||||
"needModel": "先にモデルを追加してください。",
|
||||
"stepConnect": "接続",
|
||||
@@ -339,7 +332,6 @@
|
||||
"deleteProviderOnly": "{name} を削除",
|
||||
"addProviderEntry": "プロバイダーを追加…",
|
||||
"awsKeysRejected": "AWS がこのアクセスキーを拒否しました。2 つとも最後までコピーされているか確認してください。",
|
||||
"needAzure": "先に API キーと Azure リソースのベース URL を入力してください。",
|
||||
"validationFailedCountOne": "{count} 個のモデルがテストに失敗しました",
|
||||
"validationFailedCountOther": "{count} 個のモデルがテストに失敗しました",
|
||||
"deleteProviderNamedOne": "{name} とそのモデルを削除",
|
||||
|
||||
@@ -299,8 +299,6 @@
|
||||
"requestUrl": "請求將傳送至 {url}",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
"allAdded": "已全部新增",
|
||||
@@ -343,12 +341,6 @@
|
||||
"groupAggregators": "聚合平台",
|
||||
"groupCloud": "雲端平台",
|
||||
"groupSelfHosted": "自行部署",
|
||||
"noteBedrock": "AWS 憑證",
|
||||
"noteVertex": "Express 模式 Key",
|
||||
"noteAzure": "Key 和資源位址",
|
||||
"noteEdgeone": "不用 Key",
|
||||
"noteOllama": "Key 可不填",
|
||||
"noteSglang": "填服務位址",
|
||||
"added": "已新增",
|
||||
"awsCredentials": "AWS 憑證",
|
||||
"bedrockAuthApiKey": "Bedrock API Key",
|
||||
@@ -362,6 +354,7 @@
|
||||
"modelFailed": "出錯",
|
||||
"modelUntested": "未測試",
|
||||
"needKey": "先填好 API Key。",
|
||||
"needUrl": "先填好 API Key 和位址。",
|
||||
"needAws": "先填好 AWS 憑證和區域。",
|
||||
"needModel": "先新增一個模型。",
|
||||
"stepConnect": "連線",
|
||||
@@ -384,7 +377,6 @@
|
||||
"deleteProviderOnly": "刪除 {name}",
|
||||
"addProviderEntry": "新增服務商…",
|
||||
"awsKeysRejected": "AWS 拒絕了這組存取金鑰。檢查兩個金鑰是否都複製完整。",
|
||||
"needAzure": "先填好 API Key 和 Azure 資源的 Base URL。",
|
||||
"validationFailedCountOne": "{count} 個模型沒通過測試",
|
||||
"validationFailedCountOther": "{count} 個模型沒通過測試",
|
||||
"deleteProviderNamedOne": "刪除 {name} 和它的 {count} 個模型",
|
||||
|
||||
@@ -299,8 +299,6 @@
|
||||
"requestUrl": "请求将发往 {url}",
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
@@ -343,12 +341,6 @@
|
||||
"groupAggregators": "聚合平台",
|
||||
"groupCloud": "云平台",
|
||||
"groupSelfHosted": "自己部署",
|
||||
"noteBedrock": "AWS 凭证",
|
||||
"noteVertex": "Express 模式 Key",
|
||||
"noteAzure": "Key 和资源地址",
|
||||
"noteEdgeone": "不用 Key",
|
||||
"noteOllama": "Key 可不填",
|
||||
"noteSglang": "填服务地址",
|
||||
"added": "已添加",
|
||||
"awsCredentials": "AWS 凭证",
|
||||
"bedrockAuthApiKey": "Bedrock API Key",
|
||||
@@ -362,6 +354,7 @@
|
||||
"modelFailed": "出错",
|
||||
"modelUntested": "未测试",
|
||||
"needKey": "先填好 API Key。",
|
||||
"needUrl": "先填好 API Key 和地址。",
|
||||
"needAws": "先填好 AWS 凭证和区域。",
|
||||
"needModel": "先添加一个模型。",
|
||||
"stepConnect": "连接",
|
||||
@@ -384,7 +377,6 @@
|
||||
"deleteProviderOnly": "删除 {name}",
|
||||
"addProviderEntry": "添加服务商…",
|
||||
"awsKeysRejected": "AWS 拒绝了这组访问密钥。检查两个密钥是否都复制完整。",
|
||||
"needAzure": "先填好 API Key 和 Azure 资源的 Base URL。",
|
||||
"validationFailedCountOne": "{count} 个模型没通过测试",
|
||||
"validationFailedCountOther": "{count} 个模型没通过测试",
|
||||
"deleteProviderNamedOne": "删除 {name} 和它的 {count} 个模型",
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function listProviderModels(
|
||||
// AIHubMix has a public list, unless the user points to another
|
||||
// endpoint, which is OpenAI-compatible
|
||||
const style =
|
||||
provider === "aihubmix" &&
|
||||
PROVIDERS[provider].sdk === "aihubmix" &&
|
||||
baseUrl &&
|
||||
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
|
||||
? "openai"
|
||||
|
||||
+15
-34
@@ -6,11 +6,7 @@ import {
|
||||
PROVIDERS,
|
||||
type ProviderGroup as ProviderGroupId,
|
||||
} from "@/lib/providers"
|
||||
import {
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export type { ProviderGroupId }
|
||||
|
||||
@@ -35,20 +31,10 @@ export const POPULAR_PROVIDERS: ProviderName[] = [
|
||||
"qwen",
|
||||
]
|
||||
|
||||
/** Providers that need something other than a plain API key */
|
||||
export const PROVIDER_NOTE_KEYS: Partial<Record<ProviderName, string>> = {
|
||||
bedrock: "noteBedrock",
|
||||
vertexai: "noteVertex",
|
||||
azure: "noteAzure",
|
||||
edgeone: "noteEdgeone",
|
||||
ollama: "noteOllama",
|
||||
sglang: "noteSglang",
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose base URL usually needs changing (Azure's default is only
|
||||
* an example; Ollama's is its cloud, SGLang's a local server) or comes with
|
||||
* a hint (MiniMax, MiMo): their folded options start open
|
||||
* Providers whose base URL usually needs changing (Azure has no default;
|
||||
* Ollama's is its cloud, SGLang's a local server) or comes with a hint
|
||||
* (MiniMax, MiMo): their folded options start open
|
||||
*/
|
||||
export const OPTIONS_OPEN: ProviderName[] = PROVIDER_NAMES.filter(
|
||||
(p) => PROVIDERS[p].optionsOpen,
|
||||
@@ -62,35 +48,30 @@ export function filterProviderGroups(query: string) {
|
||||
...group,
|
||||
providers: group.providers.filter(
|
||||
(p) =>
|
||||
PROVIDER_INFO[p].label.toLowerCase().includes(q) ||
|
||||
p.includes(q),
|
||||
PROVIDERS[p].label.toLowerCase().includes(q) || p.includes(q),
|
||||
),
|
||||
})).filter((group) => group.providers.length > 0)
|
||||
}
|
||||
|
||||
/** The credentials a test or a chat request needs are filled in */
|
||||
export function hasCredentials(p: ProviderConfig): boolean {
|
||||
switch (p.provider) {
|
||||
case "bedrock":
|
||||
const def = PROVIDERS[p.provider]
|
||||
switch (def.credential) {
|
||||
case "aws":
|
||||
return (
|
||||
(!!p.apiKey ||
|
||||
(!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
|
||||
!!p.awsRegion
|
||||
)
|
||||
case "vertexai":
|
||||
case "vertexKey":
|
||||
return !!p.vertexApiKey
|
||||
// Its default base URL names no real resource
|
||||
case "azure":
|
||||
return (
|
||||
!!p.apiKey &&
|
||||
!!p.baseUrl &&
|
||||
p.baseUrl !== PROVIDER_INFO.azure.defaultBaseUrl
|
||||
)
|
||||
case "edgeone":
|
||||
case "ollama":
|
||||
case "none":
|
||||
case "optionalKey":
|
||||
return true
|
||||
default:
|
||||
return !!p.apiKey
|
||||
case "apiKey":
|
||||
// Without a default URL in the record (Azure) the user's own is
|
||||
// part of the credentials
|
||||
return !!p.apiKey && (!!def.defaultBaseUrl || !!p.baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -429,8 +429,7 @@ const defs = {
|
||||
group: "cloud",
|
||||
envPrefix: "AZURE",
|
||||
resourceNameEnv: "AZURE_RESOURCE_NAME",
|
||||
// A placeholder, not a real resource; the form treats it as unset
|
||||
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
|
||||
// No default: the URL names the user's own resource
|
||||
sdkKnowsEndpoint: true,
|
||||
ownRequestPath: true,
|
||||
optionsOpen: true,
|
||||
@@ -440,6 +439,13 @@ const defs = {
|
||||
ja: "キーとリソース URL",
|
||||
"zh-Hant": "Key 和資源位址",
|
||||
},
|
||||
baseUrlHint: {
|
||||
en: "Your Azure OpenAI resource's URL, https://<resource>.openai.azure.com/openai",
|
||||
zh: "Azure OpenAI 资源的地址,形如 https://<资源名>.openai.azure.com/openai",
|
||||
ja: "Azure OpenAI リソースの URL。例: https://<リソース名>.openai.azure.com/openai",
|
||||
"zh-Hant":
|
||||
"Azure OpenAI 資源的位址,形如 https://<資源名稱>.openai.azure.com/openai",
|
||||
},
|
||||
// Deployment names in the user's resource
|
||||
models: [
|
||||
"gpt-5.5",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PROVIDER_INFO } from "@/lib/types/model-config"
|
||||
|
||||
export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
||||
.string()
|
||||
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
|
||||
.refine((val): val is ProviderName => Object.hasOwn(PROVIDER_INFO, val), {
|
||||
message: "Invalid provider name",
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (!(aiProvider in PROVIDER_INFO)) {
|
||||
if (!Object.hasOwn(PROVIDER_INFO, aiProvider)) {
|
||||
console.warn(
|
||||
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
|
||||
)
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
isQuotaEnabled,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export interface TestModelRequest {
|
||||
@@ -74,10 +75,22 @@ export async function testModelConnection(
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// EdgeOne is this site's own function, as in the chat; the admin
|
||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||
const baseUrl =
|
||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||
// The body is untrusted: an unknown name has no record to read
|
||||
if (!Object.hasOwn(PROVIDERS, provider)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: `Invalid provider: ${provider}. Allowed providers: ${Object.keys(PROVIDERS).join(", ")}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { credential } = PROVIDERS[provider]
|
||||
// The site's own function (EdgeOne) is this site's, as in the chat;
|
||||
// the admin panel's Test sends no URL, and a relative one cannot be
|
||||
// fetched
|
||||
const onSiteFunction = credential === "none"
|
||||
const baseUrl = onSiteFunction ? edgeOneEndpoint(req) : body.baseUrl
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (
|
||||
@@ -92,35 +105,44 @@ export async function testModelConnection(
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials based on provider
|
||||
if (provider === "bedrock") {
|
||||
if (
|
||||
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
||||
!awsRegion
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider === "vertexai") {
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
// The credentials the provider's kind asks for
|
||||
switch (credential) {
|
||||
case "aws":
|
||||
if (
|
||||
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
||||
!awsRegion
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
break
|
||||
case "vertexKey":
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
break
|
||||
case "apiKey":
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
break
|
||||
case "optionalKey":
|
||||
case "none":
|
||||
break
|
||||
}
|
||||
// The Test button checks the user's own provider. On the server's
|
||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||
@@ -180,8 +202,9 @@ export async function testModelConnection(
|
||||
// Temporary AWS credentials need it, as in the chat
|
||||
awsSessionToken,
|
||||
vertexApiKey,
|
||||
// EdgeOne checks the Pages cookies and the access code
|
||||
...(provider === "edgeone" && {
|
||||
// The site's function (EdgeOne) checks the Pages cookies and
|
||||
// the access code
|
||||
...(onSiteFunction && {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
|
||||
@@ -447,9 +447,10 @@ export function chatRequestUrl(
|
||||
): string | null {
|
||||
const url = normalizeBaseUrl(baseUrl)
|
||||
if (!url) return null
|
||||
if (provider === "anthropic") return `${url}/messages`
|
||||
if (provider === "ollama") return `${ollamaApiUrl(url)}/chat`
|
||||
const { sdk, ownRequestPath } = PROVIDERS[provider]
|
||||
if (sdk === "anthropic") return `${url}/messages`
|
||||
if (sdk === "ollama") return `${ollamaApiUrl(url)}/chat`
|
||||
// These SDKs build their own paths (or, for MiniMax, pick the protocol
|
||||
// from the URL)
|
||||
return PROVIDERS[provider].ownRequestPath ? null : `${url}/chat/completions`
|
||||
return ownRequestPath ? null : `${url}/chat/completions`
|
||||
}
|
||||
|
||||
@@ -552,6 +552,7 @@ test("a local server's base URL is shown from the start", async ({ page }) => {
|
||||
test("Azure needs its resource URL before it counts as connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Azure has no default URL: a new entry starts without one
|
||||
await openApp(page, {
|
||||
config: {
|
||||
version: 1,
|
||||
@@ -560,7 +561,6 @@ test("Azure needs its resource URL before it counts as connected", async ({
|
||||
id: "z1",
|
||||
provider: "azure",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://your-resource.openai.azure.com/openai",
|
||||
models: [{ id: "m1", modelId: "gpt-6-astra" }],
|
||||
},
|
||||
],
|
||||
@@ -575,9 +575,7 @@ test("Azure needs its resource URL before it counts as connected", async ({
|
||||
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
|
||||
await expect(dialog.getByTestId("test-models")).toBeDisabled()
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Fill in the API key and your Azure resource's base URL first.",
|
||||
),
|
||||
dialog.getByText("Fill in the API key and the base URL first."),
|
||||
).toBeVisible()
|
||||
await dialog
|
||||
.locator("#base-url")
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import enDict from "@/lib/i18n/dictionaries/en.json"
|
||||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
import {
|
||||
filterProviderGroups,
|
||||
hasCredentials,
|
||||
POPULAR_PROVIDERS,
|
||||
PROVIDER_GROUPS,
|
||||
PROVIDER_NOTE_KEYS,
|
||||
providerStatus,
|
||||
} from "@/lib/provider-setup"
|
||||
import { PROVIDER_NAMES, PROVIDERS } from "@/lib/providers"
|
||||
import { PROVIDER_INFO, type ProviderConfig } from "@/lib/types/model-config"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
@@ -33,15 +29,6 @@ describe("provider groups", () => {
|
||||
for (const p of POPULAR_PROVIDERS) expect(PROVIDER_INFO[p]).toBeTruthy()
|
||||
})
|
||||
|
||||
it("have their notes in every language", () => {
|
||||
for (const dict of [enDict, zhDict, jaDict, zhHantDict]) {
|
||||
const t = dict.modelConfig as Record<string, string>
|
||||
for (const key of Object.values(PROVIDER_NOTE_KEYS)) {
|
||||
expect(t[key as string]).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("are searched by label or name, ignoring case and spaces", () => {
|
||||
expect(filterProviderGroups(" ")).toBe(PROVIDER_GROUPS)
|
||||
const kimi = filterProviderGroups("MOONSHOT")
|
||||
@@ -63,6 +50,18 @@ describe("hasCredentials", () => {
|
||||
[{ provider: "edgeone" }, true],
|
||||
[{ provider: "vertexai", apiKey: "k" }, false],
|
||||
[{ provider: "vertexai", vertexApiKey: "k" }, true],
|
||||
// Azure has no default URL: the user's own is part of signing in
|
||||
[{ provider: "azure", apiKey: "k" }, false],
|
||||
[
|
||||
{
|
||||
provider: "azure",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://contoso.openai.azure.com/openai",
|
||||
},
|
||||
true,
|
||||
],
|
||||
// SGLang's local default fills the form, so a key is enough here
|
||||
[{ provider: "sglang", apiKey: "k" }, true],
|
||||
[{ provider: "bedrock", apiKey: "k" }, false],
|
||||
[
|
||||
{ provider: "bedrock", apiKey: "k", awsRegion: "us-east-1" },
|
||||
@@ -90,6 +89,23 @@ describe("hasCredentials", () => {
|
||||
expect([p, hasCredentials(provider(p))]).toEqual([p, expected])
|
||||
}
|
||||
})
|
||||
|
||||
it("asks every provider for a key, an AWS pair, a Vertex key or nothing", () => {
|
||||
// With only a key filled in, by the record's credential kind
|
||||
for (const p of PROVIDER_NAMES) {
|
||||
const { credential, defaultBaseUrl } = PROVIDERS[p]
|
||||
const expected =
|
||||
credential === "none" || credential === "optionalKey"
|
||||
? true
|
||||
: credential === "apiKey"
|
||||
? !!defaultBaseUrl
|
||||
: false
|
||||
expect([
|
||||
p,
|
||||
hasCredentials(provider({ provider: p, apiKey: "k" })),
|
||||
]).toEqual([p, expected])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("providerStatus", () => {
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { SINGLE_SYSTEM_PROVIDERS } from "@/lib/ai-providers"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import enDict from "@/lib/i18n/dictionaries/en.json"
|
||||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
import {
|
||||
OPTIONS_OPEN,
|
||||
POPULAR_PROVIDERS,
|
||||
@@ -309,18 +305,63 @@ const fixture = {
|
||||
"openrouter",
|
||||
"qwen",
|
||||
],
|
||||
providerNoteKeys: {
|
||||
bedrock: "noteBedrock",
|
||||
vertexai: "noteVertex",
|
||||
azure: "noteAzure",
|
||||
edgeone: "noteEdgeone",
|
||||
ollama: "noteOllama",
|
||||
sglang: "noteSglang",
|
||||
} as Record<string, string>,
|
||||
baseUrlHintKeys: {
|
||||
minimax: "minimaxBaseUrlHint",
|
||||
mimo: "mimoBaseUrlHint",
|
||||
} as Record<string, string>,
|
||||
// The dictionaries' noteBedrock, noteVertex, noteAzure, noteEdgeone,
|
||||
// noteOllama and noteSglang entries
|
||||
notes: {
|
||||
bedrock: {
|
||||
en: "AWS credentials",
|
||||
zh: "AWS 凭证",
|
||||
ja: "AWS 認証情報",
|
||||
"zh-Hant": "AWS 憑證",
|
||||
},
|
||||
vertexai: {
|
||||
en: "Express mode key",
|
||||
zh: "Express 模式 Key",
|
||||
ja: "Express モードのキー",
|
||||
"zh-Hant": "Express 模式 Key",
|
||||
},
|
||||
azure: {
|
||||
en: "Key and resource URL",
|
||||
zh: "Key 和资源地址",
|
||||
ja: "キーとリソース URL",
|
||||
"zh-Hant": "Key 和資源位址",
|
||||
},
|
||||
edgeone: {
|
||||
en: "No key needed",
|
||||
zh: "不用 Key",
|
||||
ja: "キー不要",
|
||||
"zh-Hant": "不用 Key",
|
||||
},
|
||||
ollama: {
|
||||
en: "Key optional",
|
||||
zh: "Key 可不填",
|
||||
ja: "キーは任意",
|
||||
"zh-Hant": "Key 可不填",
|
||||
},
|
||||
sglang: {
|
||||
en: "Your server's URL",
|
||||
zh: "填服务地址",
|
||||
ja: "サーバーの URL",
|
||||
"zh-Hant": "填服務位址",
|
||||
},
|
||||
} as Record<string, Record<string, string>>,
|
||||
// The dictionaries' minimaxBaseUrlHint and mimoBaseUrlHint entries
|
||||
baseUrlHints: {
|
||||
minimax: {
|
||||
en: "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||
zh: "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
ja: "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
"zh-Hant":
|
||||
"使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
},
|
||||
mimo: {
|
||||
en: "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
||||
zh: "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
ja: "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||
"zh-Hant":
|
||||
"預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
},
|
||||
} as Record<string, Record<string, string>>,
|
||||
optionsOpen: ["azure", "ollama", "sglang", "minimax", "mimo"],
|
||||
noKeyNeeded: ["ollama", "openrouter", "aihubmix"],
|
||||
}
|
||||
@@ -336,10 +377,14 @@ describe("the provider record derives the old tables", () => {
|
||||
it("PROVIDER_INFO: label, defaultBaseUrl, apiKeyUrl and modelList", () => {
|
||||
for (const p of PROVIDER_NAMES) {
|
||||
const { label, defaultBaseUrl, apiKeyUrl, modelList } = PROVIDERS[p]
|
||||
const expected = { ...fixture.providerInfo[p] }
|
||||
// The one intended difference: Azure's placeholder URL named no
|
||||
// real resource and is gone; the form treats no URL as unset
|
||||
if (p === "azure") delete expected.defaultBaseUrl
|
||||
expect([
|
||||
p,
|
||||
{ label, defaultBaseUrl, apiKeyUrl, modelList },
|
||||
]).toEqual([p, { ...fixture.providerInfo[p] }])
|
||||
]).toEqual([p, expected])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -410,37 +455,22 @@ describe("the provider record derives the old tables", () => {
|
||||
})
|
||||
|
||||
it("the picker notes and base URL hints, word for word in every language", () => {
|
||||
const dicts: Record<string, Record<string, string>> = {
|
||||
en: enDict.modelConfig,
|
||||
zh: zhDict.modelConfig,
|
||||
ja: jaDict.modelConfig,
|
||||
"zh-Hant": zhHantDict.modelConfig,
|
||||
}
|
||||
const noted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].note)
|
||||
expect(sorted(noted)).toEqual(
|
||||
sorted(Object.keys(fixture.providerNoteKeys)),
|
||||
)
|
||||
expect(sorted(noted)).toEqual(sorted(Object.keys(fixture.notes)))
|
||||
for (const p of noted) {
|
||||
for (const [lang, dict] of Object.entries(dicts)) {
|
||||
expect([p, lang, PROVIDERS[p].note?.[lang as never]]).toEqual([
|
||||
p,
|
||||
lang,
|
||||
dict[fixture.providerNoteKeys[p]],
|
||||
])
|
||||
}
|
||||
expect([p, PROVIDERS[p].note]).toEqual([p, fixture.notes[p]])
|
||||
}
|
||||
// Azure's hint is new (it replaces the placeholder URL); the other
|
||||
// two came from the dictionaries
|
||||
const hinted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].baseUrlHint)
|
||||
expect(sorted(hinted)).toEqual(
|
||||
sorted(Object.keys(fixture.baseUrlHintKeys)),
|
||||
sorted([...Object.keys(fixture.baseUrlHints), "azure"]),
|
||||
)
|
||||
for (const p of hinted) {
|
||||
for (const [lang, dict] of Object.entries(dicts)) {
|
||||
expect([
|
||||
p,
|
||||
lang,
|
||||
PROVIDERS[p].baseUrlHint?.[lang as never],
|
||||
]).toEqual([p, lang, dict[fixture.baseUrlHintKeys[p]]])
|
||||
}
|
||||
for (const p of Object.keys(fixture.baseUrlHints) as typeof hinted) {
|
||||
expect([p, PROVIDERS[p].baseUrlHint]).toEqual([
|
||||
p,
|
||||
fixture.baseUrlHints[p],
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -382,3 +382,28 @@ describe("the Test on the deployment's own endpoints", () => {
|
||||
expect(quota.checks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/validate-model with an unknown provider", () => {
|
||||
const send = (body: object) =>
|
||||
validateModel(
|
||||
new Request("http://localhost/api/validate-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelId: "m", ...body }),
|
||||
}),
|
||||
)
|
||||
|
||||
it("refuses a name that is not a provider, with or without a key", async () => {
|
||||
for (const body of [
|
||||
{ provider: "nope", apiKey: "k" },
|
||||
{ provider: "nope" },
|
||||
// A prototype property is not a provider either
|
||||
{ provider: "toString", apiKey: "k" },
|
||||
]) {
|
||||
const res = await send(body)
|
||||
expect(res.status).toBe(400)
|
||||
const { error } = await res.json()
|
||||
expect(error).toMatch(/^Invalid provider: /)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user