Skip to content

Commit 26a9b78

Browse files
committed
fix vendor matching
1 parent b972587 commit 26a9b78

4 files changed

Lines changed: 214 additions & 55 deletions

File tree

src/components/dashboard/ModelSelector.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Lock,
99
Zap,
1010
} from "lucide-react";
11+
import type { ComponentType, SVGProps } from "react";
1112
import { useEffect, useState } from "react";
1213
import {
1314
Command,
@@ -33,23 +34,38 @@ import { OpenaiDark } from "@/components/ui/svgs/openaiDark";
3334
import { ZaiDark } from "@/components/ui/svgs/zaiDark";
3435
import { ZaiLight } from "@/components/ui/svgs/zaiLight";
3536
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
37+
import { toVendorSlug } from "@/lib/modelVendor";
3638
import { cn } from "@/lib/utils";
3739

3840
const VENDOR_LOGOS: Record<
3941
string,
40-
{ light: React.ComponentType<any>; dark: React.ComponentType<any> }
42+
{
43+
light: ComponentType<SVGProps<SVGSVGElement>>;
44+
dark: ComponentType<SVGProps<SVGSVGElement>>;
45+
}
4146
> = {
4247
openai: { light: Openai, dark: OpenaiDark },
4348
anthropic: { light: AnthropicBlack, dark: AnthropicWhite },
4449
google: { light: Gemini, dark: Gemini },
45-
"z.ai": { light: ZaiLight, dark: ZaiDark },
4650
"z-ai": { light: ZaiLight, dark: ZaiDark },
4751
minimax: { light: Minimax, dark: MinimaxDark },
4852
kimi: { light: Kimi, dark: Kimi },
49-
moonshotai: { light: Kimi, dark: Kimi },
5053
moonshot: { light: Kimi, dark: Kimi },
5154
};
5255

56+
function getVendorLogoVariants(
57+
vendor: string,
58+
provider: string,
59+
): {
60+
light: ComponentType<SVGProps<SVGSVGElement>>;
61+
dark: ComponentType<SVGProps<SVGSVGElement>>;
62+
} | null {
63+
const vendorKey = toVendorSlug(vendor);
64+
const providerKey = toVendorSlug(provider);
65+
66+
return VENDOR_LOGOS[vendorKey] ?? VENDOR_LOGOS[providerKey] ?? null;
67+
}
68+
5369
interface ModelSelectorProps {
5470
models: ReadonlyArray<{
5571
id: string;
@@ -132,7 +148,7 @@ export function ModelSelector({
132148
}, [models, selectedModel, onModelChange]);
133149

134150
const vendorLogo = selectedModel
135-
? VENDOR_LOGOS[selectedModel.vendor.toLowerCase()]
151+
? getVendorLogoVariants(selectedModel.vendor, selectedModel.provider)
136152
: null;
137153

138154
const renderLogo = (logoVariants: typeof vendorLogo) => {
@@ -196,8 +212,10 @@ export function ModelSelector({
196212
<CommandGroup key={provider} heading={provider}>
197213
{providerModels.map((model) => {
198214
const isAvailable = model.available !== false;
199-
const modelVendorLogo =
200-
VENDOR_LOGOS[model.vendor.toLowerCase()];
215+
const modelVendorLogo = getVendorLogoVariants(
216+
model.vendor,
217+
model.provider,
218+
);
201219
const modelKey = getModelKey(model.provider, model.id);
202220
const item = (
203221
<CommandItem

src/lib/modelVendor.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const VENDOR_TOKEN_TO_SLUG: Record<string, string> = {
2+
openai: "openai",
3+
openaicom: "openai",
4+
anthropic: "anthropic",
5+
google: "google",
6+
xai: "xai",
7+
zai: "z-ai",
8+
minimax: "minimax",
9+
kimi: "kimi",
10+
moonshot: "moonshot",
11+
moonshotai: "moonshot",
12+
openrouter: "openrouter",
13+
opencode: "opencode",
14+
};
15+
16+
export function toVendorToken(value: string): string {
17+
return value
18+
.trim()
19+
.toLowerCase()
20+
.replace(/[^a-z]/g, "");
21+
}
22+
23+
export function toVendorSlug(value: string): string {
24+
const token = toVendorToken(value);
25+
if (!token) {
26+
return value.trim().toLowerCase();
27+
}
28+
29+
return VENDOR_TOKEN_TO_SLUG[token] ?? token;
30+
}

src/server/opencode/models.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { toVendorSlug } from "@/lib/modelVendor";
2+
import { resolveModelVendor } from "./models";
3+
4+
interface TestResult {
5+
test: string;
6+
passed: boolean;
7+
error?: string;
8+
}
9+
10+
const results: TestResult[] = [];
11+
12+
function test(name: string, fn: () => void): void {
13+
try {
14+
fn();
15+
results.push({ test: name, passed: true });
16+
console.log(`PASS ${name}`);
17+
} catch (error) {
18+
const errorMessage = error instanceof Error ? error.message : String(error);
19+
results.push({ test: name, passed: false, error: errorMessage });
20+
console.log(`FAIL ${name}: ${errorMessage}`);
21+
}
22+
}
23+
24+
function assertEqual(
25+
actual: unknown,
26+
expected: unknown,
27+
message?: string,
28+
): void {
29+
if (actual !== expected) {
30+
throw new Error(
31+
message ?? `Expected ${String(expected)}, got ${String(actual)}`,
32+
);
33+
}
34+
}
35+
36+
test("direct OpenAI provider resolves to openai", () => {
37+
assertEqual(resolveModelVendor("openai", "gpt-5.2-codex"), "openai");
38+
});
39+
40+
test("OpenRouter vendor/model format resolves vendor from model ID", () => {
41+
assertEqual(
42+
resolveModelVendor("openrouter", "openai/gpt-5.2-codex"),
43+
"openai",
44+
);
45+
});
46+
47+
test("OpenRouter vendor extraction normalizes case", () => {
48+
assertEqual(resolveModelVendor("openrouter", "OpenAI/gpt-4.1"), "openai");
49+
});
50+
51+
test("OpenCode gpt model infers openai vendor", () => {
52+
assertEqual(resolveModelVendor("opencode", "gpt-5-codex"), "openai");
53+
});
54+
55+
test("OpenCode claude model infers anthropic vendor", () => {
56+
assertEqual(resolveModelVendor("opencode", "claude-sonnet-4.5"), "anthropic");
57+
});
58+
59+
test("OpenCode model with vendor/model format uses explicit vendor", () => {
60+
assertEqual(
61+
resolveModelVendor("opencode", "google/gemini-3-flash"),
62+
"google",
63+
);
64+
});
65+
66+
test("zai provider alias normalizes to z-ai", () => {
67+
assertEqual(resolveModelVendor("zai", "glm-5"), "z-ai");
68+
});
69+
70+
test("x-ai provider alias normalizes to xai", () => {
71+
assertEqual(resolveModelVendor("x-ai", "grok-4"), "xai");
72+
});
73+
74+
test("OpenCode unknown model falls back to opencode provider slug", () => {
75+
assertEqual(resolveModelVendor("opencode", "custom-model"), "opencode");
76+
});
77+
78+
test("vendor slug strips punctuation for z.ai", () => {
79+
assertEqual(toVendorSlug("z.ai"), "z-ai");
80+
});
81+
82+
test("vendor slug strips punctuation for zai", () => {
83+
assertEqual(toVendorSlug("zai"), "z-ai");
84+
});
85+
86+
test("vendor slug strips punctuation for openai.com", () => {
87+
assertEqual(toVendorSlug("openai.com"), "openai");
88+
});
89+
90+
test("vendor slug strips punctuation for open-ai", () => {
91+
assertEqual(toVendorSlug("open-ai"), "openai");
92+
});
93+
94+
test("vendor slug lowercases and canonicalizes moonshotai", () => {
95+
assertEqual(toVendorSlug("MoonshotAI"), "moonshot");
96+
});
97+
98+
const passed = results.filter((result) => result.passed).length;
99+
const failed = results.length - passed;
100+
101+
console.log(`\n${passed}/${results.length} tests passed`);
102+
103+
if (failed > 0) {
104+
process.exitCode = 1;
105+
}

src/server/opencode/models.ts

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toVendorSlug } from "@/lib/modelVendor";
12
import { logger } from "@/server/logger";
23
import { getModelById, getModelsIndex } from "./modelsDev";
34

@@ -8,6 +9,59 @@ export interface ModelCapabilities {
89
contextLimit: number;
910
}
1011

12+
const MODEL_VENDOR_PATTERNS: Array<{ pattern: RegExp; vendor: string }> = [
13+
{ pattern: /^gpt[-_]/, vendor: "openai" },
14+
{ pattern: /^claude[-_]/, vendor: "anthropic" },
15+
{ pattern: /^gemini[-_]/, vendor: "google" },
16+
{ pattern: /^grok[-_]/, vendor: "xai" },
17+
{ pattern: /(^|[-_])kimi([-_.]|$)/, vendor: "moonshot" },
18+
{ pattern: /^glm[-_]/, vendor: "z-ai" },
19+
];
20+
21+
const MULTI_VENDOR_PROVIDERS = new Set(["openrouter", "opencode"]);
22+
23+
function extractVendorFromModelId(modelId: string): string | null {
24+
const separatorIndex = modelId.indexOf("/");
25+
if (separatorIndex <= 0) {
26+
return null;
27+
}
28+
29+
const vendorPart = modelId.slice(0, separatorIndex);
30+
if (!vendorPart) {
31+
return null;
32+
}
33+
34+
return toVendorSlug(vendorPart);
35+
}
36+
37+
function inferVendorFromModelId(modelId: string): string | null {
38+
const normalizedModelId = modelId.trim().toLowerCase();
39+
40+
for (const { pattern, vendor } of MODEL_VENDOR_PATTERNS) {
41+
if (pattern.test(normalizedModelId)) {
42+
return vendor;
43+
}
44+
}
45+
46+
return null;
47+
}
48+
49+
export function resolveModelVendor(
50+
providerId: string,
51+
modelId: string,
52+
): string {
53+
const normalizedProvider = toVendorSlug(providerId);
54+
const vendorFromModelId = extractVendorFromModelId(modelId);
55+
56+
if (!MULTI_VENDOR_PROVIDERS.has(normalizedProvider)) {
57+
return normalizedProvider;
58+
}
59+
60+
return (
61+
vendorFromModelId ?? inferVendorFromModelId(modelId) ?? normalizedProvider
62+
);
63+
}
64+
1165
/**
1266
* Normalize model IDs from models.dev for each provider.
1367
* Returns the model ID as-is (matching models.dev format) and infers vendor for display.
@@ -24,55 +78,7 @@ function normalizeModelId(
2478
modelId: string,
2579
providerId: string,
2680
): { id: string; vendor: string } {
27-
// For openrouter, models are already in vendor/model format
28-
// Return as-is and extract vendor from the ID
29-
if (providerId === "openrouter") {
30-
// Extract vendor from the model ID (first part before /)
31-
const parts = modelId.split("/");
32-
const vendor = parts[0] ?? modelId;
33-
return { id: modelId, vendor };
34-
}
35-
36-
// If already in vendor/model format, return as-is (for other providers with vendor prefix)
37-
if (modelId.includes("/")) {
38-
const parts = modelId.split("/");
39-
const vendor = parts[0];
40-
if (!vendor) {
41-
throw new Error(`Invalid model ID format: ${modelId}`);
42-
}
43-
return { id: modelId, vendor };
44-
}
45-
46-
// For opencode provider, models don't have vendor prefix in their ID
47-
// Infer vendor from model name for display/grouping purposes only
48-
if (providerId === "opencode") {
49-
if (modelId.startsWith("gpt-") || modelId.startsWith("gpt_")) {
50-
return { id: modelId, vendor: "openai" };
51-
}
52-
if (modelId.startsWith("claude-")) {
53-
return { id: modelId, vendor: "anthropic" };
54-
}
55-
if (modelId.startsWith("gemini-")) {
56-
return { id: modelId, vendor: "google" };
57-
}
58-
if (modelId.startsWith("grok-")) {
59-
return { id: modelId, vendor: "xai" };
60-
}
61-
if (modelId.includes("kimi") || modelId.startsWith("kimi-")) {
62-
return { id: modelId, vendor: "moonshot" };
63-
}
64-
if (modelId.startsWith("glm-") || modelId.startsWith("glm_")) {
65-
return { id: modelId, vendor: "z-ai" };
66-
}
67-
// Default: use the model ID as vendor if no match
68-
return { id: modelId, vendor: modelId };
69-
}
70-
71-
// For other providers, use the first part of the model ID as vendor if available
72-
const parts = modelId.split("/");
73-
const vendor = parts[0] ?? modelId;
74-
75-
return { id: modelId, vendor };
81+
return { id: modelId, vendor: resolveModelVendor(providerId, modelId) };
7682
}
7783

7884
/**

0 commit comments

Comments
 (0)