From 404ce17ef50bbfebb9c31c2829a1cab5b4b976a7 Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Thu, 13 Aug 2026 16:08:00 +0800 Subject: [PATCH] feat: recognize OrcaRouter as a named upstream gateway Co-Authored-By: Claude --- src/app/v1/_lib/headers.ts | 2 +- src/app/v1/_lib/proxy/client-error-message.ts | 1 + src/lib/model-vendor-icons.test.ts | 3 ++ src/lib/model-vendor-icons.tsx | 2 ++ src/lib/model-vendor/orcarouter-icon.tsx | 36 +++++++++++++++++++ src/lib/model-vendor/vendor-inference.test.ts | 3 ++ src/lib/model-vendor/vendor-inference.ts | 1 + src/lib/public-status/vendor-icon-key.ts | 3 ++ src/lib/public-status/vendor-icon.ts | 2 ++ src/lib/utils/pricing-resolution.ts | 4 +++ .../unit/lib/utils/pricing-resolution.test.ts | 32 +++++++++++++++++ .../unit/proxy/anthropic-auth-headers.test.ts | 5 +++ tests/unit/proxy/client-error-message.test.ts | 6 ++++ 13 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/lib/model-vendor/orcarouter-icon.tsx diff --git a/src/app/v1/_lib/headers.ts b/src/app/v1/_lib/headers.ts index 14f3d9ff9..036805e32 100644 --- a/src/app/v1/_lib/headers.ts +++ b/src/app/v1/_lib/headers.ts @@ -31,7 +31,7 @@ export function looksLikeAnthropicProxyUrl(providerUrl?: string | null): boolean } // 只匹配常见 relay/proxy 标识,避免把普通业务域名误识别成代理层。 - return /(?:^|[.-])(proxy|relay|gateway|router|worker|openrouter|api2d|oaipro)(?:[.-]|$)/i.test( + return /(?:^|[.-])(proxy|relay|gateway|router|worker|openrouter|orcarouter|api2d|oaipro)(?:[.-]|$)/i.test( hostname ); } catch { diff --git a/src/app/v1/_lib/proxy/client-error-message.ts b/src/app/v1/_lib/proxy/client-error-message.ts index 2b52fcaa9..eddaf7547 100644 --- a/src/app/v1/_lib/proxy/client-error-message.ts +++ b/src/app/v1/_lib/proxy/client-error-message.ts @@ -26,6 +26,7 @@ const DEFAULT_FORBIDDEN_PROVIDER_LABELS = [ "claude", "codex", "openrouter", + "orcarouter", "siliconflow", "dashscope", "qwen", diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index b1b99775e..0813e63f8 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -25,6 +25,7 @@ describe("getModelVendor", () => { { modelId: "spark-max-32k", expectedVendor: "iflytek" }, { modelId: "anthropic/claude-sonnet-4-5", expectedVendor: "anthropic" }, { modelId: "openrouter/deepseek/deepseek-chat", expectedVendor: "deepseek" }, + { modelId: "orcarouter/deepseek/deepseek-chat", expectedVendor: "deepseek" }, { modelId: "unknown-model-xyz", expectedVendor: null }, { modelId: "", expectedVendor: null }, ]; @@ -57,6 +58,8 @@ describe("getVendorIconComponent", () => { it("resolves exact vendor slugs", () => { expect(getVendorIconComponent("anthropic")).toBeTruthy(); expect(getVendorIconComponent("openai")).toBeTruthy(); + expect(getVendorIconComponent("openrouter")).toBeTruthy(); + expect(getVendorIconComponent("orcarouter")).toBeTruthy(); expect(getVendorIconComponent("amazon-bedrock")).toBeTruthy(); }); diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index ddfbe4c2d..534b6538d 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -81,6 +81,7 @@ import { Zhipu, } from "@lobehub/icons"; import { resolveByDashPrefix } from "@/lib/model-vendor/dash-prefix-lookup"; +import { OrcaRouter } from "@/lib/model-vendor/orcarouter-icon"; import { iconFileForVendor, type VendorIconFileEntry } from "@/lib/model-vendor/vendor-icon-files"; import { inferVendorFromModelName, @@ -173,6 +174,7 @@ const VENDOR_ICON_COMPONENTS: Record = { yandex: Yandex, // 常见 provider 渠道(供应商价格对比等场景) openrouter: OpenRouter, + orcarouter: OrcaRouter, groq: Groq, azure: Azure.Color, together: Together.Color, diff --git a/src/lib/model-vendor/orcarouter-icon.tsx b/src/lib/model-vendor/orcarouter-icon.tsx new file mode 100644 index 000000000..67f3eee9a --- /dev/null +++ b/src/lib/model-vendor/orcarouter-icon.tsx @@ -0,0 +1,36 @@ +/** + * OrcaRouter 品牌图标。 + * + * @lobehub/icons 尚未收录 OrcaRouter,这里内嵌官网 /orca-logo-classic.png 的 + * 蓝鲸跳跃剪影(蓝色 #0157E0 + 白色腹纹与眼斑),保证离线可用且与云端价格表 + * providers 字典下发的图标口径一致。 + */ + +const BODY_PATH = + "M24.1 3.27 23.35 3.23 22.51 4.2 21.95 5.47 21.24 6.1 20.09 6.39 18.47 6.5 17.09 7.13 16.37 7.82 18.0 8.26 20.58 9.43 22.8 10.49 23.13 11.71 23.02 12.21 23.46 12.89 23.79 13.75 23.32 14.7 22.14 15.49 20.92 15.86 20.21 15.67 20.05 15.17 19.96 14.67 19.09 14.51 17.74 14.58 15.19 13.92 12.48 12.62 9.11 11.19 6.56 10.4 4.78 10.06 3.84 10.04 3.31 10.37 3.0 11.38 3.13 13.11 3.99 15.8 5.68 18.63 6.61 21.12 6.47 23.06 5.31 24.69 4.69 26.06 4.59 26.96 5.25 27.39 6.46 27.42 8.31 26.86 10.16 25.65 12.05 24.86 13.88 24.68 15.91 25.81 18.24 27.11 20.81 28.34 23.05 28.77 24.6 28.62 25.2 28.12 24.47 26.9 24.44 25.36 25.26 23.18 27.42 20.85 28.73 18.05 29.0 15.3 28.44 12.77 27.23 10.6 25.38 8.81 24.19 7.5 23.74 6.41 24.23 5.45 24.39 4.11Z"; + +const BELLY_PATH = + "M3.38 11.34 3.12 11.62 3.0 11.86 2.94 12.05 2.88 12.24 2.88 12.49 2.91 12.46 2.97 12.27 3.04 12.19 3.1 12.37 3.16 12.59 3.18 12.69 3.24 12.8 3.25 12.93 3.31 13.0 3.32 13.12 3.37 13.17 3.41 13.34 3.46 13.48 3.54 13.75 3.57 13.93 3.69 14.17 3.74 14.31 3.88 14.49 3.93 14.59 4.1 14.83 4.25 15.18 4.49 15.55 4.68 15.89 4.83 16.04 4.94 16.18 4.99 16.26 5.03 16.33 5.09 16.45 5.13 16.54 5.19 16.64 5.24 16.73 5.34 16.8 5.52 16.99 5.66 17.19 5.87 17.48 5.99 17.73 6.12 17.83 6.3 18.01 6.47 18.19 6.68 18.44 6.74 18.52 6.83 18.57 6.89 18.58 6.99 18.67 7.14 18.79 7.3 19.03 7.49 19.23 7.62 19.36 7.8 19.48 7.9 19.56 8.06 19.72 8.14 19.85 8.24 19.94 8.31 20.03 8.45 20.07 8.59 20.19 8.67 20.25 8.77 20.35 8.92 20.48 9.12 20.59 9.29 20.7 9.39 20.72 9.49 20.85 9.63 20.94 9.73 21.09 9.86 21.19 9.96 21.26 10.14 21.37 10.3 21.4 10.45 21.5 10.48 21.53 10.54 21.6 10.74 21.75 11.1 21.93 11.36 22.1 11.54 22.15 11.66 22.22 11.79 22.23 11.92 22.35 12.0 22.38 12.08 22.43 12.14 22.41 12.22 22.46 12.38 22.51 12.53 22.57 12.66 22.63 12.73 22.69 12.83 22.77 12.98 22.79 13.08 22.87 13.14 22.87 13.23 22.93 13.35 22.93 13.51 22.99 13.66 22.97 13.81 23.0 13.89 23.03 13.97 23.07 14.03 23.12 14.1 23.1 14.19 23.13 14.28 23.09 14.35 22.99 14.34 22.75 14.26 22.37 14.12 21.87 13.85 21.19 13.31 20.37 12.39 19.45 11.49 18.67 10.73 17.85 10.24 17.16 9.63 16.4 8.95 15.65 8.12 14.68 7.2 13.55 6.06 12.49 4.91 11.69 3.97 11.31Z"; + +export function OrcaRouter({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/src/lib/model-vendor/vendor-inference.test.ts b/src/lib/model-vendor/vendor-inference.test.ts index 73bb7abb5..30b1c5e0e 100644 --- a/src/lib/model-vendor/vendor-inference.test.ts +++ b/src/lib/model-vendor/vendor-inference.test.ts @@ -75,6 +75,8 @@ describe("inferVendorFromModelName", () => { { modelId: "z-ai/glm-4.7", expected: "zhipuai" }, // 托管商前缀跳过 org、按模型段识别 { modelId: "openrouter/deepseek/deepseek-chat", expected: "deepseek" }, + { modelId: "orcarouter/deepseek/deepseek-chat", expected: "deepseek" }, + { modelId: "orcarouter/anthropic/claude-sonnet-5", expected: "anthropic" }, { modelId: "together/meta-llama/Llama-3-70b", expected: "meta" }, { modelId: "hf/deepseek-ai/DeepSeek-V3.2", expected: "deepseek" }, { modelId: "novita/qwen/qwen3-32b", expected: "alibaba" }, @@ -141,6 +143,7 @@ describe("keywordScan", () => { describe("isHostPrefix", () => { it("recognizes hosting orgs", () => { expect(isHostPrefix("openrouter")).toBe(true); + expect(isHostPrefix("orcarouter")).toBe(true); expect(isHostPrefix("HF")).toBe(true); expect(isHostPrefix("anthropic")).toBe(false); }); diff --git a/src/lib/model-vendor/vendor-inference.ts b/src/lib/model-vendor/vendor-inference.ts index 30083cd66..a11e4357a 100644 --- a/src/lib/model-vendor/vendor-inference.ts +++ b/src/lib/model-vendor/vendor-inference.ts @@ -83,6 +83,7 @@ const HOST_PREFIXES = new Set([ "fireworks", "fireworks-ai", "openrouter", + "orcarouter", "deepinfra", "novita", "novita-ai", diff --git a/src/lib/public-status/vendor-icon-key.ts b/src/lib/public-status/vendor-icon-key.ts index 28c6b0c9d..42be3cfdf 100644 --- a/src/lib/public-status/vendor-icon-key.ts +++ b/src/lib/public-status/vendor-icon-key.ts @@ -24,6 +24,7 @@ export const PUBLIC_STATUS_VENDOR_ICON_KEYS = [ "ollama", "openai", "openrouter", + "orcarouter", "perplexity", "qwen", "sensenova", @@ -64,6 +65,7 @@ const RAW_PROVIDER_TO_PUBLIC_STATUS_ICON_KEY: Record { expect(resolved?.source).toBe("cloud_model_fallback"); }); + test("resolves orcarouter provider to orcarouter pricing node", () => { + const aliasRecord = makeRecord("gpt-5.5", { + mode: "responses", + model_family: "gpt", + litellm_provider: "orcarouter", + pricing: { + orcarouter: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: { + id: 3, + name: "OrcaRouter", + url: "https://api.orcarouter.ai/v1", + } as never, + primaryModelName: "gpt-5.5", + fallbackModelName: null, + primaryRecord: aliasRecord, + fallbackRecord: null, + }); + + expect(resolved).not.toBeNull(); + expect(resolved?.resolvedPricingProviderKey).toBe("orcarouter"); + expect(resolved?.source).toBe("cloud_exact"); + expect(resolved?.priceData.input_cost_per_token).toBe(0.0000025); + }); + test("prefers local manual prices over cloud multi-provider pricing", () => { const manualRecord = makeRecord( "gpt-5.5", diff --git a/tests/unit/proxy/anthropic-auth-headers.test.ts b/tests/unit/proxy/anthropic-auth-headers.test.ts index 33f27603b..a9cfeb809 100644 --- a/tests/unit/proxy/anthropic-auth-headers.test.ts +++ b/tests/unit/proxy/anthropic-auth-headers.test.ts @@ -28,6 +28,11 @@ describe("Anthropic auth header helpers", () => { ).toEqual({ Authorization: "Bearer sk-test", }); + expect( + resolveAnthropicAuthHeaders("sk-test", "https://orcarouter.example.com/v1/messages") + ).toEqual({ + Authorization: "Bearer sk-test", + }); }); it("honors forceBearerOnly for claude-auth style callsites", () => { diff --git a/tests/unit/proxy/client-error-message.test.ts b/tests/unit/proxy/client-error-message.test.ts index add4fdc2d..df6e12b3c 100644 --- a/tests/unit/proxy/client-error-message.test.ts +++ b/tests/unit/proxy/client-error-message.test.ts @@ -34,6 +34,12 @@ describe("deriveClientSafeUpstreamErrorMessage", () => { providerName: "Anthropic", }) ).toBeNull(); + expect( + deriveClientSafeUpstreamErrorMessage({ + candidateMessage: "Provider OrcaRouter returned: overload", + providerName: "OrcaRouter", + }) + ).toBeNull(); expect( deriveClientSafeUpstreamErrorMessage({ candidateMessage: "FAKE_200_JSON_ERROR_NON_EMPTY",