Merge pull request #2314 from oyi77/feat/gitlawb-opengateway

feat: gitlawb opengateway provider + providers page filter chips

Three independent contributions from oyi77 bundled in this PR:

1. Gitlawb Opengateway provider (b83d1a0fc):
   - gitlawb (alias glb) — xiaomi-mimo endpoint with 5 MiMo models
   - gitlawb-gmi (alias glb-gmi) — gmi-cloud endpoint with 40+ models
   - Both flagged free tier, CLI-mimicking headers to avoid upstream
     rate limiting
   - 9 unit tests in tests/unit/gitlawb-provider.test.ts (all green)

2. hasFree flag (d7dcd233a): friendliai, chutes, featherless-ai now
   correctly surface free tier badge in the providers page.

3. UI additions (debf7cb28):
   - CollapsibleSection.tsx (coexists with our existing Collapsible.tsx —
     different API for different use cases)
   - Category filter chips bar on /dashboard/providers (auto-merged)
   - Free-only toggle on /dashboard/providers (auto-merged)
   - Extended filterConfiguredProviderEntries with showFreeOnly param
   - i18n tooltip keys for Caveman/RTK settings (union with our
     simpleMode/advancedMode/filterCatalog keys)
   - InfoTooltip + PresetSlider identical to PR #2316 versions
     (silent dedup by auto-merge)

Conflict resolution:
- src/shared/components/index.tsx: union — added CollapsibleSection
  export alongside our NoAuthProviderCard.
- src/i18n/messages/en.json: union — kept all our keys from #2316
  (simpleMode/advancedMode/filterCatalog/filterCatalogDesc) and added
  PR's tooltip keys (searchFilters, tooltipDedup, tooltipMaxChars,
  tooltipMaxLines, tooltipAutoTrigger, tooltipCompressionRate,
  tooltipMaxTokens, tooltipMinLength, tooltipMinSavings, ultraSettings,
  ultraSettingsDesc).

Closes #2314
This commit is contained in:
diegosouzapw
2026-05-17 02:37:05 -03:00
8 changed files with 593 additions and 18 deletions

View File

@@ -308,6 +308,226 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 },
{ id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 },
],
gitlawb: [
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 },
{ id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 },
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro", contextLength: 262144, maxOutputTokens: 131072 },
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 },
{ id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 },
],
"gitlawb-gmi": [
{
id: "XiaomiMiMo/MiMo-V2.5-Pro",
name: "MiMo-V2.5-Pro (GMI)",
contextLength: 1050000,
maxOutputTokens: 131072,
},
{
id: "XiaomiMiMo/MiMo-V2.5",
name: "MiMo-V2.5 (GMI)",
contextLength: 1050000,
maxOutputTokens: 131072,
},
{ id: "openai/gpt-5.5", name: "GPT-5.5", contextLength: 1050000, maxOutputTokens: 131072 },
{
id: "openai/gpt-5.4-pro",
name: "GPT-5.4 Pro",
contextLength: 409600,
maxOutputTokens: 131072,
},
{ id: "openai/gpt-5.4", name: "GPT-5.4", contextLength: 409600, maxOutputTokens: 131072 },
{
id: "openai/gpt-5.4-mini",
name: "GPT-5.4 Mini",
contextLength: 409600,
maxOutputTokens: 131072,
},
{
id: "openai/gpt-5.4-nano",
name: "GPT-5.4 Nano",
contextLength: 409600,
maxOutputTokens: 131072,
},
{
id: "openai/gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextLength: 409600,
maxOutputTokens: 131072,
},
{
id: "openai/gpt-5.2-codex",
name: "GPT-5.2 Codex",
contextLength: 409600,
maxOutputTokens: 131072,
},
{ id: "openai/gpt-5.2", name: "GPT-5.2", contextLength: 409600, maxOutputTokens: 131072 },
{ id: "openai/gpt-5.1", name: "GPT-5.1", contextLength: 409600, maxOutputTokens: 131072 },
{ id: "openai/gpt-5", name: "GPT-5", contextLength: 409600, maxOutputTokens: 131072 },
{ id: "openai/gpt-4o", name: "GPT-4o", contextLength: 131072, maxOutputTokens: 16384 },
{
id: "openai/gpt-4o-mini",
name: "GPT-4o Mini",
contextLength: 131072,
maxOutputTokens: 16384,
},
{
id: "anthropic/claude-opus-4.7",
name: "Claude Opus 4.7",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-opus-4.1",
name: "Claude Opus 4.1",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "anthropic/claude-haiku-4.5",
name: "Claude Haiku 4.5",
contextLength: 409600,
maxOutputTokens: 131072,
targetFormat: "claude",
},
{
id: "deepseek-ai/DeepSeek-V4-Pro",
name: "DeepSeek V4 Pro",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
},
{
id: "deepseek-ai/DeepSeek-V4-Flash",
name: "DeepSeek V4 Flash",
contextLength: 1048575,
maxOutputTokens: 131072,
supportsReasoning: true,
},
{
id: "deepseek-ai/DeepSeek-R1-0528",
name: "DeepSeek R1",
contextLength: 163840,
maxOutputTokens: 131072,
supportsReasoning: true,
},
{
id: "deepseek-ai/DeepSeek-V3.2",
name: "DeepSeek V3.2",
contextLength: 163840,
maxOutputTokens: 131072,
},
{
id: "google/gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{
id: "google/gemini-3.1-flash-lite-preview",
name: "Gemini 3.1 Flash Lite",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{
id: "google/gemini-3-flash-preview",
name: "Gemini 3 Flash",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{ id: "zai-org/GLM-5.1-FP8", name: "GLM-5.1", contextLength: 202752, maxOutputTokens: 131072 },
{ id: "zai-org/GLM-5-FP8", name: "GLM-5", contextLength: 202752, maxOutputTokens: 131072 },
{
id: "moonshotai/Kimi-K2.6",
name: "Kimi K2.6",
contextLength: 65536,
maxOutputTokens: 131072,
},
{
id: "moonshotai/Kimi-K2.5",
name: "Kimi K2.5",
contextLength: 262144,
maxOutputTokens: 131072,
},
{
id: "MiniMaxAI/MiniMax-M2.7",
name: "MiniMax M2.7",
contextLength: 196608,
maxOutputTokens: 131072,
},
{
id: "MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
contextLength: 196608,
maxOutputTokens: 131072,
},
{
id: "Qwen/Qwen3.6-Max-Preview",
name: "Qwen3.6 Max",
contextLength: 262144,
maxOutputTokens: 131072,
},
{
id: "Qwen/Qwen3.6-Plus",
name: "Qwen3.6 Plus",
contextLength: 262144,
maxOutputTokens: 131072,
},
{
id: "Qwen/Qwen3.5-397B-A17B",
name: "Qwen3.5 397B",
contextLength: 262144,
maxOutputTokens: 131072,
},
{
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
name: "Qwen3 Coder 480B",
contextLength: 262128,
maxOutputTokens: 131072,
},
{
id: "nvidia/NVIDIA-Nemotron-3-Nano-Omni",
name: "Nemotron 3 Nano",
contextLength: 262144,
maxOutputTokens: 131072,
},
],
"inference-net": buildModels([
"meta-llama/Llama-3.3-70B-Instruct",
"deepseek-ai/DeepSeek-R1",
@@ -2744,6 +2964,39 @@ export const REGISTRY: Record<string, RegistryEntry> = {
models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"],
},
gitlawb: {
id: "gitlawb",
alias: "glb",
format: "openai",
executor: "default",
baseUrl: "https://opengateway.gitlawb.com/v1/xiaomi-mimo",
authType: "apikey",
authHeader: "bearer",
headers: {
"User-Agent": "OpenClaude/1.0 (linux; x86_64)",
"X-Title": "OpenClaude CLI",
"HTTP-Referer": "https://github.com/Gitlawb/openclaude",
},
models: CHAT_OPENAI_COMPAT_MODELS["gitlawb"],
},
"gitlawb-gmi": {
id: "gitlawb-gmi",
alias: "glb-gmi",
format: "openai",
executor: "default",
baseUrl: "https://opengateway.gitlawb.com/v1/gmi-cloud",
authType: "apikey",
authHeader: "bearer",
headers: {
"User-Agent": "OpenClaude/1.0 (linux; x86_64)",
"X-Title": "OpenClaude CLI",
"HTTP-Referer": "https://github.com/Gitlawb/openclaude",
},
passthroughModels: true,
models: CHAT_OPENAI_COMPAT_MODELS["gitlawb-gmi"],
},
"inference-net": {
id: "inference-net",
alias: "inet",

View File

@@ -1,7 +1,15 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components";
import {
Card,
CardSkeleton,
Badge,
Button,
Input,
Toggle,
CollapsibleSection,
} from "@/shared/components";
import {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
@@ -123,7 +131,10 @@ export default function ProvidersPage() {
} | null>(null);
const [repairingEnv, setRepairingEnv] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const notify = useNotificationStore();
const showSection = (category: string) => !activeCategory || activeCategory === category;
const t = useTranslations("providers");
const tc = useTranslations("common");
const ccCompatibleLabel = t("ccCompatibleLabel");
@@ -419,7 +430,8 @@ export default function ProvidersPage() {
const oauthProviderEntries = filterConfiguredProviderEntries(
oauthProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
@@ -433,74 +445,86 @@ export default function ProvidersPage() {
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const aggregatorProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const imageProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const enterpriseProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const videoProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
const webCookieProviderEntries = filterConfiguredProviderEntries(
webCookieProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
const localProviderEntries = filterConfiguredProviderEntries(
localProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
const searchProviderEntries = filterConfiguredProviderEntries(
searchProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
const audioProviderEntries = filterConfiguredProviderEntries(
audioProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const compatibleProviderEntriesAll = [
@@ -529,7 +553,8 @@ export default function ProvidersPage() {
const compatibleProviderEntries = filterConfiguredProviderEntries(
compatibleProviderEntriesAll,
showConfiguredOnly,
searchQuery
searchQuery,
showFreeOnly
);
const FREE_SECTION_IDS = new Set([
@@ -788,6 +813,48 @@ export default function ProvidersPage() {
)}
</div>
{/* Category Filter Chips */}
<div className="flex flex-wrap items-center gap-2">
{[
{ key: null, label: t("allProviders") || "All" },
{ key: "free", label: t("freeProviders") || "Free" },
{ key: "oauth", label: t("oauthProviders") },
{ key: "apikey", label: t("apiKeyProviders") },
{ key: "webcookie", label: t("webCookieProviders") || "Web Cookie" },
{ key: "search", label: t("searchProviders") || "Search" },
{ key: "audio", label: t("audioProviders") || "Audio" },
{ key: "cloudagent", label: t("cloudAgentProviders") || "Cloud Agent" },
{ key: "local", label: t("localProviders") || "Local" },
{ key: "compatible", label: t("compatibleProviders") || "Compatible" },
].map((cat) => (
<button
key={cat.key ?? "all"}
onClick={() => {
if (cat.key === "free") {
setShowFreeOnly(true);
setActiveCategory(null);
} else {
setShowFreeOnly(false);
setActiveCategory(cat.key);
}
}}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
(cat.key === "free" && showFreeOnly) ||
(cat.key !== "free" && !showFreeOnly && activeCategory === cat.key)
? "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30"
}`}
>
{cat.key === "free" && (
<span className="material-symbols-outlined text-[12px] mr-0.5 align-middle">
star
</span>
)}
{cat.label}
</button>
))}
</div>
{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
@@ -878,6 +945,20 @@ export default function ProvidersPage() {
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
</h2>
<div className="flex items-center gap-2">
<Toggle
size="sm"
checked={showFreeOnly}
onChange={setShowFreeOnly}
label={t("showFreeOnly") || "Free only"}
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<Toggle
size="sm"
checked={showConfiguredOnly}
onChange={setShowConfiguredOnly}
label={t("showConfiguredOnly")}
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<button
onClick={handleZedImport}
disabled={importingZed}

View File

@@ -86,7 +86,8 @@ export function buildStaticProviderEntries(
export function filterConfiguredProviderEntries<TProvider>(
entries: ProviderEntry<TProvider>[],
showConfiguredOnly: boolean,
searchQuery?: string
searchQuery?: string,
showFreeOnly?: boolean
): ProviderEntry<TProvider>[] {
let filtered = entries;
@@ -94,6 +95,13 @@ export function filterConfiguredProviderEntries<TProvider>(
filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0);
}
if (showFreeOnly) {
filtered = filtered.filter((entry) => {
const provider = entry.provider as Record<string, unknown>;
return provider.hasFree === true;
});
}
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
filtered = filtered.filter((entry) => {

View File

@@ -3978,10 +3978,17 @@
"toolResults": "Tool results",
"assistantMessages": "Assistant messages",
"codeBlocks": "Code blocks",
"filterCatalog": "Filter Catalog",
"filterCatalogDesc": "Available output filters by category",
"guidedConfig": "Configuration",
"guidedConfigDesc": "Adjust how RTK filters your terminal output",
"maxLines": "Max lines",
"maxChars": "Max chars",
"deduplicateThreshold": "Deduplicate threshold",
"customFilters": "Custom filters",
"detectedType": "Detected type",
"confidence": "Confidence",
"beforeAfter": "Before / After",
"trustProjectFilters": "Trust project filters",
"rawOutputRetention": "Raw output retention",
"rawOutputNever": "Never",
@@ -3990,6 +3997,11 @@
"rawOutputMaxBytes": "Raw output max bytes",
"filterTesting": "Filter Testing",
"pasteOutput": "Paste tool output to test filtering...",
"presetHigh": "Aggressive",
"presetLow": "Light",
"presetMaxChars": "Max output characters",
"presetMaxLines": "Max output lines",
"presetMedium": "Standard",
"run": "Run",
"result": "Result",
"previewEmpty": "Run a sample to preview RTK output.",
@@ -4001,7 +4013,11 @@
"simpleMode": "Simple",
"advancedMode": "Advanced",
"filterCatalog": "Filter Catalog",
"filterCatalogDesc": "Available output filters by category"
"filterCatalogDesc": "Available output filters by category",
"searchFilters": "Search filters...",
"tooltipDedup": "How aggressively to remove repeated lines.",
"tooltipMaxChars": "Maximum characters per output block.",
"tooltipMaxLines": "Maximum lines to keep from command output. Excess is truncated."
},
"contextCombos": {
"title": "Compression Combos",
@@ -4028,24 +4044,43 @@
"contextCaveman": {
"title": "Caveman Engine",
"description": "Rule-based message compression, language packs, analytics and output mode controls.",
"advancedConfig": "Advanced Configuration",
"advancedConfigDesc": "Fine-tune compression behavior",
"aggressiveSettings": "Aggressive Settings",
"aggressiveSettingsDesc": "Maximum compression with potential quality trade-offs",
"requests": "Requests",
"tokensSaved": "Tokens saved",
"savingsPercent": "Savings %",
"avgLatency": "Avg latency",
"languagePacks": "Language Packs",
"languagePacksDesc": "Enable compression rules for specific languages.",
"labelAutoTrigger": "Auto-compress when context exceeds",
"labelCompressionRate": "Compression strength",
"labelMaxTokens": "Target tokens per message",
"labelMinLength": "Minimum message length",
"labelMinSavings": "Minimum tokens to save",
"enabled": "Enabled",
"autoDetect": "Auto-detect language",
"rulesCount": "{count} rules",
"analyticsTitle": "Compression Analytics",
"noAnalytics": "No compression analytics yet.",
"outputMode": "Output Mode",
"outputModeDesc": "Control how compressed output appears",
"outputModeTitle": "Output Mode",
"outputModeDesc": "Instructs the LLM to respond in a terse, compact format.",
"quickSettings": "Quick Settings",
"quickSettingsDesc": "Basic compression settings to get started",
"autoClarity": "Auto-Clarity Bypass",
"bypassConditions": "Bypass conditions",
"bypassConditionsList": "security, irreversible, clarification, order-sensitive",
"simpleMode": "Simple",
"advancedMode": "Advanced",
"tooltipAutoTrigger": "Automatically compress when context exceeds this size.",
"tooltipCompressionRate": "0.0 = none, 1.0 = maximum. 0.5 = balanced.",
"tooltipMaxTokens": "Target token count after compression. Lower = more aggressive.",
"tooltipMinLength": "Messages shorter than this are not compressed. Lower = more compression.",
"tooltipMinSavings": "Only compress if it saves at least this many tokens.",
"ultraSettings": "Ultra Settings",
"ultraSettingsDesc": "SLM-powered semantic compression",
"preview": {
"lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.",
"full": "Respond terse and compact. Preserve all technical substance.",

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface CollapsibleSectionProps {
title: string;
description?: string;
count?: number;
defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
}
export default function CollapsibleSection({
title,
description,
count,
defaultOpen = true,
children,
className,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("border border-black/5 dark:border-white/5 rounded-lg", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center justify-between w-full px-4 py-3 text-left",
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
"rounded-t-lg",
!open && "rounded-b-lg"
)}
aria-expanded={open}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-semibold text-text-main truncate">{title}</span>
{count != null && (
<span className="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-black/5 dark:bg-white/10 text-text-muted">
{count}
</span>
)}
{description && (
<span className="text-xs text-text-muted truncate hidden sm:inline">{description}</span>
)}
</div>
<span
className="material-symbols-outlined text-[20px] text-text-muted transition-transform duration-200 shrink-0"
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
>
expand_more
</span>
</button>
<div
className={cn(
"overflow-hidden transition-[max-height] duration-200 ease-in-out",
open ? "max-h-[2000px]" : "max-h-0"
)}
>
<div className="px-4 pb-4 pt-1">{children}</div>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ export { default as FilterBar } from "./FilterBar";
export { default as ColumnToggle } from "./ColumnToggle";
export { default as DataTable } from "./DataTable";
export { default as NoAuthProviderCard } from "./NoAuthProviderCard";
export { default as CollapsibleSection } from "./CollapsibleSection";
export { default as InfoTooltip } from "./InfoTooltip";
export { default as PresetSlider } from "./PresetSlider";

View File

@@ -1028,6 +1028,8 @@ export const APIKEY_PROVIDERS = {
color: "#EA580C",
textIcon: "FL",
website: "https://featherless.ai",
hasFree: true,
freeNote: "Free tier available — no credit card required",
},
llm7: {
id: "llm7",
@@ -1073,6 +1075,8 @@ export const APIKEY_PROVIDERS = {
color: "#EC4899",
textIcon: "FR",
website: "https://friendli.ai",
hasFree: true,
freeNote: "Free tier for serverless inference — no credit card required",
},
llamagate: {
id: "llamagate",
@@ -1230,6 +1234,30 @@ export const APIKEY_PROVIDERS = {
textIcon: "MM",
website: "https://mimo.mi.com",
},
gitlawb: {
id: "gitlawb",
alias: "glb",
name: "Gitlawb Opengateway (MiMo)",
icon: "hub",
color: "#10B981",
textIcon: "GLB",
website: "https://opengateway.gitlawb.com",
hasFree: true,
freeNote: "Free tier available — no credit card required",
apiHint: "Get your API key from Gitlawb Opengateway dashboard.",
},
"gitlawb-gmi": {
id: "gitlawb-gmi",
alias: "glb-gmi",
name: "Gitlawb Opengateway (GMI Cloud)",
icon: "hub",
color: "#10B981",
textIcon: "GMI",
website: "https://opengateway.gitlawb.com",
hasFree: true,
freeNote: "Free tier available — no credit card required",
apiHint: "Get your API key from Gitlawb Opengateway dashboard.",
},
"inference-net": {
id: "inference-net",
alias: "inet",
@@ -1446,6 +1474,8 @@ export const APIKEY_PROVIDERS = {
color: "#06B6D4",
textIcon: "CH",
website: "https://chutes.ai",
hasFree: true,
freeNote: "Free tier available — no credit card required",
authHint: "Bearer API key for the Chutes OpenAI-compatible gateway.",
passthroughModels: true,
},

View File

@@ -0,0 +1,101 @@
import { describe, it } from "node:test";
import assert from "node:assert";
describe("Gitlawb Opengateway providers", () => {
describe("gitlawb (xiaomi-mimo)", () => {
it("should be registered in APIKEY_PROVIDERS", async () => {
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
const provider = APIKEY_PROVIDERS["gitlawb"];
assert.ok(provider, "gitlawb should exist in APIKEY_PROVIDERS");
assert.strictEqual(provider.id, "gitlawb");
assert.strictEqual(provider.alias, "glb");
assert.ok(provider.name.includes("Gitlawb"));
assert.strictEqual(provider.hasFree, true);
});
it("should have registry entry with correct baseUrl", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb"];
assert.ok(entry, "gitlawb should exist in REGISTRY");
assert.strictEqual(entry.baseUrl, "https://opengateway.gitlawb.com/v1/xiaomi-mimo");
assert.strictEqual(entry.format, "openai");
assert.strictEqual(entry.executor, "default");
assert.strictEqual(entry.authType, "apikey");
});
it("should have CLI-mimicking headers", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb"];
assert.ok(entry.headers, "should have headers");
assert.ok(entry.headers["User-Agent"].includes("OpenClaude"));
assert.strictEqual(entry.headers["X-Title"], "OpenClaude CLI");
assert.ok(entry.headers["HTTP-Referer"].includes("Gitlawb/openclaude"));
});
it("should list MiMo models", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb"];
assert.ok(
entry.models.length >= 5,
`should have at least 5 models, got ${entry.models.length}`
);
const pro = entry.models.find((m: any) => m.id === "mimo-v2.5-pro");
assert.ok(pro, "mimo-v2.5-pro should be listed");
assert.strictEqual(pro.contextLength, 1048576);
});
});
describe("gitlawb-gmi (gmi-cloud)", () => {
it("should be registered in APIKEY_PROVIDERS", async () => {
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
const provider = APIKEY_PROVIDERS["gitlawb-gmi"];
assert.ok(provider, "gitlawb-gmi should exist in APIKEY_PROVIDERS");
assert.strictEqual(provider.id, "gitlawb-gmi");
assert.strictEqual(provider.alias, "glb-gmi");
assert.strictEqual(provider.hasFree, true);
});
it("should have registry entry with gmi-cloud baseUrl", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb-gmi"];
assert.ok(entry, "gitlawb-gmi should exist in REGISTRY");
assert.strictEqual(entry.baseUrl, "https://opengateway.gitlawb.com/v1/gmi-cloud");
assert.strictEqual(entry.format, "openai");
assert.strictEqual(entry.authType, "apikey");
});
it("should list GPT, Claude, DeepSeek, Gemini models", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb-gmi"];
assert.ok(entry.models.length >= 30, `should have 30+ models, got ${entry.models.length}`);
const modelIds = entry.models.map((m: any) => m.id);
assert.ok(modelIds.includes("openai/gpt-5.5"), "should have GPT-5.5");
assert.ok(
modelIds.some((id: string) => id.startsWith("anthropic/claude")),
"should have Claude models"
);
assert.ok(
modelIds.some((id: string) => id.startsWith("deepseek-ai/")),
"should have DeepSeek models"
);
assert.ok(
modelIds.some((id: string) => id.startsWith("google/gemini")),
"should have Gemini models"
);
});
it("should have CLI-mimicking headers", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const entry = REGISTRY["gitlawb-gmi"];
assert.ok(entry.headers, "should have headers");
assert.ok(entry.headers["User-Agent"].includes("OpenClaude"));
});
});
it("both providers should pass schema validation", async () => {
const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
assert.ok(AI_PROVIDERS["gitlawb"], "gitlawb should be in AI_PROVIDERS");
assert.ok(AI_PROVIDERS["gitlawb-gmi"], "gitlawb-gmi should be in AI_PROVIDERS");
});
});