mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Merge branch 'dev/3.1' of github.com:musistudio/claude-code-router into dev/3.1
This commit is contained in:
commit
78f4859a9b
20 changed files with 746 additions and 123 deletions
36
README.md
36
README.md
|
|
@ -9,12 +9,44 @@
|
|||
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
|
|
|
|||
36
README_zh.md
36
README_zh.md
|
|
@ -9,12 +9,44 @@
|
|||
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod
|
|||
| Retry | `408`, `409`, `429`, `5xx` |
|
||||
| Fallback targets | Any `4xx` or `5xx` |
|
||||
|
||||
For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
|
||||
Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
|
||||
|
||||
**Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed.
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成;
|
|||
| 继续重试 | `408`、`409`、`429`、`5xx` |
|
||||
| 失败降级目标 | 任意 `4xx` 或 `5xx` |
|
||||
|
||||
对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
|
||||
**失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,44 @@
|
|||
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
|
|
|
|||
|
|
@ -8,12 +8,44 @@
|
|||
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
|
|
|
|||
|
|
@ -420,6 +420,7 @@ export type GatewayProviderProbeRequest = {
|
|||
|
||||
export type GatewayProviderProbeCandidate = {
|
||||
baseUrl: string;
|
||||
declaredProtocols?: GatewayProviderProtocol[];
|
||||
label?: string;
|
||||
protocols: GatewayProviderProtocol[];
|
||||
source: "custom" | "preset";
|
||||
|
|
|
|||
|
|
@ -5681,7 +5681,7 @@ async function fetchUpstreamWithFallback(input: {
|
|||
});
|
||||
|
||||
if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) {
|
||||
const delayMs = retryDelayAfterStatus(response.status, response, failedAttempts.length);
|
||||
const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length);
|
||||
failedAttempts.push({
|
||||
credentialChain: attempt.credentialChain,
|
||||
credentialIds: attempt.credentialIds,
|
||||
|
|
@ -5704,10 +5704,13 @@ async function fetchUpstreamWithFallback(input: {
|
|||
};
|
||||
} catch (error) {
|
||||
const message = formatError(error);
|
||||
const delayMs = hasNextAttempt && !input.signal?.aborted
|
||||
? retryDelayAfterNetworkError(failedAttempts.length)
|
||||
: 0;
|
||||
failedAttempts.push({
|
||||
credentialChain: attempt.credentialChain,
|
||||
credentialIds: attempt.credentialIds,
|
||||
delayMs: 0,
|
||||
delayMs,
|
||||
error: message,
|
||||
model: attempt.model
|
||||
});
|
||||
|
|
@ -5719,6 +5722,9 @@ async function fetchUpstreamWithFallback(input: {
|
|||
});
|
||||
}
|
||||
if (hasNextAttempt) {
|
||||
if (delayMs > 0) {
|
||||
await delay(delayMs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new UpstreamRequestError(message, {
|
||||
|
|
@ -6170,17 +6176,30 @@ function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode)
|
|||
return false;
|
||||
}
|
||||
|
||||
function retryDelayAfterStatus(statusCode: number, response: Response, failedAttemptIndex: number): number {
|
||||
if (statusCode !== 429) {
|
||||
return 0;
|
||||
}
|
||||
const retryAfterMs = parseRetryAfterHeaderMs(response.headers.get("retry-after"));
|
||||
if (retryAfterMs !== undefined) {
|
||||
return clampNumber(retryAfterMs, 0, upstreamRetryAfterMaxMs);
|
||||
function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number {
|
||||
const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after"));
|
||||
if (retryAfterMs !== undefined && retryAfterMs > 0) {
|
||||
return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs);
|
||||
}
|
||||
return exponentialRetryBackoffMs(failedAttemptIndex);
|
||||
}
|
||||
|
||||
function retryDelayAfterNetworkError(failedAttemptIndex: number): number {
|
||||
return exponentialRetryBackoffMs(failedAttemptIndex);
|
||||
}
|
||||
|
||||
export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number {
|
||||
const headers = new Headers();
|
||||
if (input.retryAfter !== undefined && input.retryAfter !== null) {
|
||||
headers.set("retry-after", input.retryAfter);
|
||||
}
|
||||
return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0);
|
||||
}
|
||||
|
||||
export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number {
|
||||
return retryDelayAfterNetworkError(failedAttemptIndex);
|
||||
}
|
||||
|
||||
function parseRetryAfterHeaderMs(value: string | null): number | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
|
|
|
|||
|
|
@ -49,14 +49,14 @@ export function providerIdentitySafetyIssueInList(
|
|||
}
|
||||
|
||||
const selectedPreset = findProviderPresetInList(presets, input.presetId);
|
||||
if (selectedPreset && !providerPresetMatchesBaseUrl(selectedPreset, input.baseUrl)) {
|
||||
if (selectedPreset && !providerBaseUrlCanReceiveOfficialKey(selectedPreset, input.baseUrl)) {
|
||||
return createProviderIdentitySafetyIssue(selectedPreset);
|
||||
}
|
||||
|
||||
const namedPresets = findProviderPresetsByIdentity(presets, input.name);
|
||||
if (
|
||||
namedPresets.length > 0 &&
|
||||
!namedPresets.some((preset) => providerPresetMatchesBaseUrl(preset, input.baseUrl))
|
||||
!namedPresets.some((preset) => providerBaseUrlCanReceiveOfficialKey(preset, input.baseUrl))
|
||||
) {
|
||||
return createProviderIdentitySafetyIssue(namedPresets[0]);
|
||||
}
|
||||
|
|
@ -128,15 +128,30 @@ function findProviderPresetsByIdentity(presets: ProviderPreset[], name: string |
|
|||
return [];
|
||||
}
|
||||
|
||||
return presets.filter((preset) => {
|
||||
const identities = [preset.id, preset.name, ...preset.aliases]
|
||||
.map(normalizeProviderIdentityText)
|
||||
.filter(Boolean);
|
||||
return identities.some((identity) =>
|
||||
normalizedName === identity ||
|
||||
(identity.length >= 4 && normalizedName.includes(identity))
|
||||
);
|
||||
});
|
||||
return presets
|
||||
.map((preset) => ({
|
||||
preset,
|
||||
score: providerPresetIdentityMatchScore(preset, normalizedName)
|
||||
}))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((item) => item.preset);
|
||||
}
|
||||
|
||||
function providerPresetIdentityMatchScore(preset: ProviderPreset, normalizedName: string): number {
|
||||
const identities = [preset.id, preset.name, ...preset.aliases]
|
||||
.map(normalizeProviderIdentityText)
|
||||
.filter(Boolean);
|
||||
|
||||
return Math.max(0, ...identities.map((identity) => {
|
||||
if (normalizedName === identity) {
|
||||
return 10_000 + identity.length;
|
||||
}
|
||||
if (identity.length >= 4 && normalizedName.includes(identity)) {
|
||||
return identity.length;
|
||||
}
|
||||
return 0;
|
||||
}));
|
||||
}
|
||||
|
||||
function createProviderIdentitySafetyIssue(preset: ProviderPreset): ProviderIdentitySafetyIssue {
|
||||
|
|
|
|||
|
|
@ -339,21 +339,30 @@ function providerProbeCapabilities(
|
|||
probe: GatewayProviderProbeResult
|
||||
): GatewayProviderCapability[] {
|
||||
const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []);
|
||||
if (detectedCapabilities.length > 0) {
|
||||
return detectedCapabilities;
|
||||
}
|
||||
const presetCapabilities = providerProbePresetCapabilities(candidate);
|
||||
return mergeProviderCapabilities(detectedCapabilities, presetCapabilities);
|
||||
}
|
||||
|
||||
function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidate): GatewayProviderCapability[] {
|
||||
if (candidate.source !== "preset") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return candidate.protocols.map((type) => ({
|
||||
baseUrl: probe.normalizedBaseUrl || candidate.baseUrl,
|
||||
return uniqueProtocols(candidate.declaredProtocols ?? []).map((type) => ({
|
||||
baseUrl: providerProbeCandidateBaseUrlForProtocol(candidate.baseUrl, type),
|
||||
source: "preset" as const,
|
||||
type
|
||||
}));
|
||||
}
|
||||
|
||||
function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderProtocol): string {
|
||||
try {
|
||||
return providerBaseUrlForProtocol(parseProviderBaseUrl(baseUrl), protocol);
|
||||
} catch {
|
||||
return baseUrl.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProviderCapabilities(...groups: GatewayProviderCapability[][]): GatewayProviderCapability[] {
|
||||
const seen = new Set<string>();
|
||||
const capabilities: GatewayProviderCapability[] = [];
|
||||
|
|
@ -1076,18 +1085,7 @@ function isProtocolSupported(
|
|||
if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (protocol === "openai_responses") {
|
||||
return /model|max_output|max output|input|required/.test(normalized);
|
||||
}
|
||||
if (protocol === "openai_chat_completions" || protocol === "anthropic_messages") {
|
||||
return /model|max_tokens|messages|required/.test(normalized);
|
||||
}
|
||||
if (protocol === "gemini_generate_content") {
|
||||
return /contents|generatecontentrequest|generationconfig/.test(normalized);
|
||||
}
|
||||
if (protocol === "gemini_interactions") {
|
||||
return /model|input|required|interaction|generation_config|generationconfig|system_instruction/.test(normalized);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
|
||||
isTraySupportedPlatform,
|
||||
isRoutingRewriteDraftRowValid,
|
||||
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels,
|
||||
LayoutGroup, mergeModelDisplayNames, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels,
|
||||
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
|
||||
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
|
||||
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
|
||||
providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
|
||||
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
|
||||
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId,
|
||||
routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
|
||||
|
|
@ -1368,8 +1368,6 @@ function App() {
|
|||
setProviderProbeError(translateAppErrorMessage(copy, credentials));
|
||||
return false;
|
||||
}
|
||||
const fallbackProtocol = probe?.detectedProtocol ?? providerDraft.protocol;
|
||||
const fallbackBaseUrl = probe?.normalizedBaseUrl || providerDraft.baseUrl;
|
||||
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
|
||||
const selectedProtocols = providerDraft.selectedProtocols.length > 0
|
||||
? providerDraft.selectedProtocols.filter((protocol) => !probe || selectableProtocols.includes(protocol))
|
||||
|
|
@ -1379,25 +1377,19 @@ function App() {
|
|||
return false;
|
||||
}
|
||||
|
||||
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol];
|
||||
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [probe?.detectedProtocol ?? providerDraft.protocol];
|
||||
const fallbackProtocol = protocolsToSave.includes(providerDraft.protocol)
|
||||
? providerDraft.protocol
|
||||
: protocolsToSave[0] ?? probe?.detectedProtocol ?? providerDraft.protocol;
|
||||
const fallbackBaseUrl = providerGlobalBaseUrlForProbe(providerDraft.baseUrl, probe, protocolsToSave);
|
||||
const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models);
|
||||
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
|
||||
const selectedProtocolSet = new Set(protocolsToSave);
|
||||
const capabilityCandidates = mergeProviderCapabilities(
|
||||
presetCapabilitiesFromDraft(providerDraft),
|
||||
probe?.capabilities ?? [],
|
||||
protocolsToSave.map((type) => ({
|
||||
baseUrl: fallbackBaseUrl,
|
||||
source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const),
|
||||
type
|
||||
}))
|
||||
);
|
||||
const capabilities = capabilityCandidates.filter((capability) => selectedProtocolSet.has(capability.type));
|
||||
const capabilities = providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft));
|
||||
const primaryCapability =
|
||||
capabilities.find((capability) => capability.type === fallbackProtocol) ??
|
||||
capabilities[0];
|
||||
const protocol = primaryCapability?.type ?? fallbackProtocol;
|
||||
const baseUrl = primaryCapability?.baseUrl ?? fallbackBaseUrl;
|
||||
const baseUrl = fallbackBaseUrl;
|
||||
|
||||
const keySafetyIssue = providerApiKeySafetyIssue({
|
||||
apiKey: providerDraft.apiKey,
|
||||
|
|
@ -1443,7 +1435,7 @@ function App() {
|
|||
}
|
||||
|
||||
const provider: GatewayProviderConfig = {
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl),
|
||||
api_key: providerDraft.apiKey.trim(),
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
account: accountConfig,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
||||
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
|
||||
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
|
||||
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
|
||||
ProviderConnectivityCheckReport, providerCapabilityBaseUrlForProtocol, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
|
||||
providerDisplayIcon, providerGlobalBaseUrlForProbe, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
|
||||
resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions,
|
||||
translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo,
|
||||
useRef, useState, X, isPlainRecord
|
||||
|
|
@ -111,24 +111,25 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
|
|||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
<AnimatePresence initial={false}>
|
||||
{visibleProviders.map(({ provider, index }) => {
|
||||
const itemKey = providerListItemKey(provider, index);
|
||||
const expanded = expandedProviders.has(itemKey);
|
||||
{visibleProviders.map(({ provider, index }) => {
|
||||
const itemKey = providerListItemKey(provider, index);
|
||||
const expanded = expandedProviders.has(itemKey);
|
||||
const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? [];
|
||||
return (
|
||||
<AnimatedListItem key={itemKey}>
|
||||
<div
|
||||
className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
|
||||
onClick={() => toggleProvider(provider, index)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggleProvider(provider, index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
const providerIconUrl = providerDisplayIcon(provider);
|
||||
return (
|
||||
<AnimatedListItem key={itemKey}>
|
||||
<div
|
||||
className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
|
||||
onClick={() => toggleProvider(provider, index)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggleProvider(provider, index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
|
|
@ -143,6 +144,7 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
|
|||
>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<ProviderPresetIcon className="h-8 w-8 rounded-md" iconUrl={providerIconUrl} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{provider.name || t("Unnamed")}</div>
|
||||
</div>
|
||||
|
|
@ -1364,8 +1366,11 @@ export function AddProviderForm({
|
|||
const customEndpoint = draft.presetId === customProviderPresetId;
|
||||
const importMode = Boolean(importProvider);
|
||||
const showBaseUrl = customEndpoint || mode === "edit";
|
||||
const detectedProtocol = probe?.detectedProtocol ?? draft.protocol;
|
||||
const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl;
|
||||
const selectedDisplayProtocols = uniqueProviderProtocols(draft.selectedProtocols);
|
||||
const detectedProtocol = selectedDisplayProtocols.length === 1
|
||||
? selectedDisplayProtocols[0]
|
||||
: probe?.detectedProtocol ?? draft.protocol;
|
||||
const detectedBaseUrl = providerCapabilityBaseUrlForProtocol(draft.baseUrl, detectedProtocol, probe);
|
||||
const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl);
|
||||
const localAgentImport = draft.providerPlugins.length > 0;
|
||||
const providerPresetOptions = [
|
||||
|
|
@ -1695,17 +1700,6 @@ export function AddProviderForm({
|
|||
{advancedOpen ? (
|
||||
<AnimatedDisclosure className="sm:col-span-2" key="provider-advanced">
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-2">
|
||||
{selectedPreset && !customEndpoint && mode === "add" ? (
|
||||
<Field className="sm:col-span-2" label={t("API endpoint")}>
|
||||
<Input value={draft.baseUrl} onChange={(event) => onChange({ baseUrl: event.target.value }, true)} />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label={t("Detected compatibility")}>
|
||||
<Input readOnly value={translatedProviderProtocolLabel(detectedProtocol, t)} />
|
||||
</Field>
|
||||
<Field label={t("Detected endpoint")}>
|
||||
<Input readOnly value={detectedBaseUrl} />
|
||||
</Field>
|
||||
<ProviderCredentialSettings
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
|
|
@ -1721,7 +1715,8 @@ export function AddProviderForm({
|
|||
{protocolProbeRows.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{protocolProbeRows.map((item) => {
|
||||
const selectable = item.supported && selectableProtocols.includes(item.protocol);
|
||||
const available = item.supported || selectableProtocols.includes(item.protocol);
|
||||
const selectable = available && selectableProtocols.includes(item.protocol);
|
||||
const checked = selectable && draft.selectedProtocols.includes(item.protocol);
|
||||
const itemKey = `${item.protocol}-${item.endpoint}`;
|
||||
return (
|
||||
|
|
@ -1742,8 +1737,8 @@ export function AddProviderForm({
|
|||
}}
|
||||
/>
|
||||
<span className="truncate font-medium">{translatedProviderProtocolLabel(item.protocol, t)}</span>
|
||||
<span className={cn("inline-flex min-w-0 items-center justify-end gap-1.5", item.supported ? "text-emerald-600 dark:text-emerald-300" : "text-muted-foreground")}>
|
||||
<span className="truncate">{item.supported ? t("Available") : t("Unavailable")}</span>
|
||||
<span className={cn("inline-flex min-w-0 items-center justify-end gap-1.5", available ? "text-emerald-600 dark:text-emerald-300" : "text-muted-foreground")}>
|
||||
<span className="truncate">{available ? t("Available") : t("Unavailable")}</span>
|
||||
<button
|
||||
aria-label={t("Protocol detection details")}
|
||||
aria-pressed={protocolProbeDetails?.key === itemKey}
|
||||
|
|
@ -2087,6 +2082,7 @@ function ProviderUsageSettings({
|
|||
const [testError, setTestError] = useState("");
|
||||
const [newApiUserId, setNewApiUserId] = useState("");
|
||||
const modeOptions = translateOptions(providerAccountModeOptions, t);
|
||||
const globalBaseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, draft.selectedProtocols);
|
||||
const showNewApiUserBalanceTemplate = probe?.detectedProvider === "new-api" ||
|
||||
draft.accountConnectorsText.includes("new-api-key-usage") ||
|
||||
draft.accountConnectorsText.includes("new-api-user-self");
|
||||
|
|
@ -2108,7 +2104,7 @@ function ProviderUsageSettings({
|
|||
}
|
||||
const safetyIssue = providerAccountConnectorApiKeySafetyIssue(connector, {
|
||||
apiKey: draft.apiKey,
|
||||
baseUrl: (probe?.normalizedBaseUrl || draft.baseUrl).trim(),
|
||||
baseUrl: globalBaseUrl,
|
||||
providerName: draft.name.trim(),
|
||||
providerPresetId: draft.presetId
|
||||
});
|
||||
|
|
@ -2143,7 +2139,7 @@ function ProviderUsageSettings({
|
|||
onChange({
|
||||
accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
|
||||
draft.accountConnectorsText,
|
||||
probe?.normalizedBaseUrl || draft.baseUrl,
|
||||
globalBaseUrl,
|
||||
newApiUserId
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -772,6 +772,16 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s
|
|||
return presetIcon || payload.icon?.trim() || "";
|
||||
}
|
||||
|
||||
export function providerDisplayIcon(provider: GatewayProviderConfig): string {
|
||||
const icon = provider.icon?.trim();
|
||||
if (icon) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
const preset = findProviderPresetByBaseUrl(providerBaseUrl(provider));
|
||||
return preset ? providerPresetIconUrls[preset.id] ?? "" : "";
|
||||
}
|
||||
|
||||
export type ProviderDeepLinkCatalogModelsResolution = {
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
models: string[];
|
||||
|
|
@ -878,8 +888,8 @@ export function createProviderConfigFromDeepLink(
|
|||
providers: GatewayProviderConfig[],
|
||||
probe: GatewayProviderProbeResult | undefined
|
||||
): GatewayProviderConfig {
|
||||
const protocol = probe?.detectedProtocol ?? payload.protocol ?? "openai_chat_completions";
|
||||
const baseUrl = probe?.normalizedBaseUrl || payload.baseUrl;
|
||||
const protocol = payload.protocol ?? probe?.detectedProtocol ?? "openai_chat_completions";
|
||||
const baseUrl = providerGlobalBaseUrlForProbe(payload.baseUrl, probe, [protocol]);
|
||||
const apiKey = payload.apiKey?.trim() || "";
|
||||
const models = apiKey && probe?.models.length
|
||||
? mergeProviderModelLists(probe.models)
|
||||
|
|
@ -910,14 +920,11 @@ export function createProviderConfigFromDeepLink(
|
|||
throw new Error(accountKeySafetyIssue.message);
|
||||
}
|
||||
|
||||
const capabilities = mergeProviderCapabilities(
|
||||
probe?.capabilities ?? [],
|
||||
protocol && baseUrl ? [{ baseUrl, source: probe?.detectedProtocol ? "detected" : "preset", type: protocol }] : []
|
||||
);
|
||||
const capabilities = providerCapabilitiesForProtocols(payload.baseUrl, [protocol], probe);
|
||||
|
||||
return {
|
||||
account: cloneProviderAccountConfig(account),
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl),
|
||||
api_key: apiKey,
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
icon: payload.icon?.trim() || undefined,
|
||||
|
|
@ -1489,8 +1496,13 @@ export function providerUsageFieldPatch(target: ProviderUsageFieldTarget, path:
|
|||
|
||||
export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, probe: GatewayProviderProbeResult | undefined): string {
|
||||
const providerName = draft.name.trim();
|
||||
const baseUrl = (probe?.normalizedBaseUrl || draft.baseUrl).trim();
|
||||
const protocol = probe?.detectedProtocol ?? draft.protocol;
|
||||
const selectedProtocols = uniqueProviderProtocols(draft.selectedProtocols);
|
||||
const protocol = selectedProtocols.includes(draft.protocol)
|
||||
? draft.protocol
|
||||
: selectedProtocols.length === 1
|
||||
? selectedProtocols[0]
|
||||
: probe?.detectedProtocol ?? draft.protocol;
|
||||
const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols.length > 0 ? selectedProtocols : [protocol]);
|
||||
const models = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
|
||||
if (!providerName || !baseUrl) {
|
||||
return "Provider name and Base URL are required.";
|
||||
|
|
@ -1697,6 +1709,7 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC
|
|||
if (preset) {
|
||||
return preset.endpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
declaredProtocols: endpoint.protocols,
|
||||
protocols,
|
||||
source: "preset"
|
||||
}));
|
||||
|
|
@ -1786,7 +1799,10 @@ export function providerSelectableProtocolsFromProbe(probe: GatewayProviderProbe
|
|||
return [];
|
||||
}
|
||||
|
||||
return uniqueProviderProtocols(probe.protocols.filter((item) => item.supported).map((item) => item.protocol));
|
||||
return uniqueProviderProtocols([
|
||||
...probe.protocols.filter((item) => item.supported).map((item) => item.protocol),
|
||||
...(probe.capabilities ?? []).map((capability) => capability.type)
|
||||
]);
|
||||
}
|
||||
|
||||
export function selectedProviderProtocolsFromCapabilities(
|
||||
|
|
@ -1877,17 +1893,80 @@ export function mergeProviderCapabilities(...groups: GatewayProviderCapability[]
|
|||
return capabilities;
|
||||
}
|
||||
|
||||
export function providerGlobalBaseUrlForProbe(
|
||||
inputBaseUrl: string,
|
||||
_probe: GatewayProviderProbeResult | undefined,
|
||||
_protocols: GatewayProviderProtocol[] = []
|
||||
): string {
|
||||
return normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
|
||||
}
|
||||
|
||||
export function providerCapabilityBaseUrlForProtocol(
|
||||
inputBaseUrl: string,
|
||||
protocol: GatewayProviderProtocol,
|
||||
probe: GatewayProviderProbeResult | undefined
|
||||
): string {
|
||||
const detectedCapability = (probe?.capabilities ?? []).find((capability) =>
|
||||
capability.type === protocol && capability.baseUrl.trim()
|
||||
);
|
||||
if (detectedCapability) {
|
||||
return detectedCapability.baseUrl.trim();
|
||||
}
|
||||
|
||||
if (probe?.detectedProtocol === protocol && probe.normalizedBaseUrl.trim()) {
|
||||
return probe.normalizedBaseUrl.trim();
|
||||
}
|
||||
|
||||
return normalizeProviderBaseUrl(inputBaseUrl, protocol) || normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
|
||||
}
|
||||
|
||||
export function providerCapabilitiesForProtocols(
|
||||
inputBaseUrl: string,
|
||||
protocols: GatewayProviderProtocol[],
|
||||
probe: GatewayProviderProbeResult | undefined,
|
||||
presetCapabilities: GatewayProviderCapability[] = []
|
||||
): GatewayProviderCapability[] {
|
||||
const detectedCapabilities = probe?.capabilities ?? [];
|
||||
const selectedCapabilities = uniqueProviderProtocols(protocols)
|
||||
.map((type) => {
|
||||
const capability =
|
||||
detectedCapabilities.find((item) => item.type === type && item.baseUrl.trim()) ??
|
||||
presetCapabilities.find((item) => item.type === type && item.baseUrl.trim());
|
||||
if (capability) {
|
||||
return capability;
|
||||
}
|
||||
|
||||
const baseUrl = providerCapabilityBaseUrlForProtocol(inputBaseUrl, type, probe);
|
||||
return baseUrl
|
||||
? {
|
||||
baseUrl,
|
||||
source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const),
|
||||
type
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((item): item is GatewayProviderCapability => Boolean(item));
|
||||
|
||||
return mergeProviderCapabilities(selectedCapabilities);
|
||||
}
|
||||
|
||||
export function applyProviderProbeResult(draft: AddProviderDraft, probe: GatewayProviderProbeResult): AddProviderDraft {
|
||||
const protocol = probe.detectedProtocol ?? draft.protocol;
|
||||
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
|
||||
const detectedProtocol = probe.detectedProtocol ?? draft.protocol;
|
||||
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, detectedProtocol, draft.presetId);
|
||||
const protocol = selectedProtocols.includes(draft.protocol)
|
||||
? draft.protocol
|
||||
: selectedProtocols.includes(detectedProtocol)
|
||||
? detectedProtocol
|
||||
: selectedProtocols[0] ?? detectedProtocol;
|
||||
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
|
||||
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
|
||||
const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols);
|
||||
|
||||
if (probe.models.length === 0) {
|
||||
return {
|
||||
...draft,
|
||||
...accountDraft,
|
||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||
baseUrl,
|
||||
modelDisplayNames,
|
||||
protocol,
|
||||
selectedModels: mergeProviderModelLists(draft.selectedModels),
|
||||
|
|
@ -1910,7 +1989,7 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
|
|||
return {
|
||||
...draft,
|
||||
...accountDraft,
|
||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||
baseUrl,
|
||||
modelDisplayNames,
|
||||
protocol,
|
||||
modelsText: customModels.join("\n"),
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function formatUsdCost(value: number | undefined): string {
|
|||
return new Intl.NumberFormat(undefined, {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: normalized >= 100 ? 0 : 2,
|
||||
minimumFractionDigits: 2,
|
||||
minimumFractionDigits: normalized >= 100 ? 0 : 2,
|
||||
style: "currency"
|
||||
}).format(normalized);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -609,7 +609,7 @@ export function formatUsdCost(value: number | undefined): string {
|
|||
return new Intl.NumberFormat(undefined, {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: normalized >= 100 ? 0 : 2,
|
||||
minimumFractionDigits: 2,
|
||||
minimumFractionDigits: normalized >= 100 ? 0 : 2,
|
||||
style: "currency"
|
||||
}).format(normalized);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
providerIdentitySafetyIssueInList,
|
||||
providerPresetMatchesBaseUrl
|
||||
} from "../../packages/core/src/providers/presets/utils.ts";
|
||||
import {
|
||||
moonshotChinaProviderPreset,
|
||||
moonshotGlobalProviderPreset
|
||||
} from "../../packages/core/src/providers/presets/moonshot/index.ts";
|
||||
|
||||
const openAiPreset = {
|
||||
aliases: ["OpenAI", "ChatGPT"],
|
||||
|
|
@ -26,6 +30,7 @@ const anthropicPreset = {
|
|||
};
|
||||
|
||||
const presets = [openAiPreset, anthropicPreset];
|
||||
const moonshotPresets = [moonshotChinaProviderPreset, moonshotGlobalProviderPreset];
|
||||
|
||||
test("provider preset matching accepts endpoint subpaths but rejects different hosts", () => {
|
||||
const openRouterPreset = {
|
||||
|
|
@ -47,6 +52,11 @@ test("provider identity lookup normalizes aliases and punctuation", () => {
|
|||
assert.equal(findProviderPresetByIdentityInList(presets, "Claude Provider")?.id, "anthropic");
|
||||
});
|
||||
|
||||
test("provider identity lookup prefers exact Kimi regional names over shared aliases", () => {
|
||||
assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (Global)")?.id, "moonshot-global");
|
||||
assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (China)")?.id, "moonshot");
|
||||
});
|
||||
|
||||
test("provider identity safety allows loopback but warns on branded third-party endpoints", () => {
|
||||
assert.equal(
|
||||
providerIdentitySafetyIssueInList(presets, {
|
||||
|
|
@ -64,6 +74,38 @@ test("provider identity safety allows loopback but warns on branded third-party
|
|||
);
|
||||
});
|
||||
|
||||
test("provider identity safety accepts shared Kimi aliases on official regional hosts", () => {
|
||||
assert.equal(
|
||||
providerIdentitySafetyIssueInList(moonshotPresets, {
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
name: "Kimi API (Global)"
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
providerIdentitySafetyIssueInList(moonshotPresets, {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
name: "Kimi API (China)"
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
providerEndpointCanReceiveProviderApiKeyInList(moonshotPresets, {
|
||||
apiKey: "manifest-provider-api-key",
|
||||
endpoint: "https://api.moonshot.ai/v1/users/me/balance",
|
||||
providerName: "Kimi API (China)"
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
assert.match(
|
||||
providerIdentitySafetyIssueInList(moonshotPresets, {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
name: "Kimi API (Global)"
|
||||
})?.message ?? "",
|
||||
/Kimi API \(Global\)/
|
||||
);
|
||||
});
|
||||
|
||||
test("provider API key safety blocks official-looking keys on untrusted endpoints", () => {
|
||||
assert.match(
|
||||
providerApiKeySafetyIssueInList(presets, {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ test("protocol support probe keeps auth-only fallback for unhinted endpoints", (
|
|||
);
|
||||
});
|
||||
|
||||
test("protocol support probe treats Gemini contents validation as Gemini support only", () => {
|
||||
test("protocol support probe treats HTTP 400 validation as protocol support", () => {
|
||||
const message = "HTTP 400: * GenerateContentRequest.contents: contents is not specified";
|
||||
|
||||
assert.equal(
|
||||
|
|
@ -52,11 +52,11 @@ test("protocol support probe treats Gemini contents validation as Gemini support
|
|||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]),
|
||||
false
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("protocol support probe treats Gemini Interactions input validation as Interactions support", () => {
|
||||
test("protocol support probe treats HTTP 400 input validation as protocol support", () => {
|
||||
const message = "HTTP 400: Gemini Interactions request requires input.";
|
||||
|
||||
assert.equal(
|
||||
|
|
@ -65,6 +65,15 @@ test("protocol support probe treats Gemini Interactions input validation as Inte
|
|||
);
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(400, message, "gemini_generate_content", ["gemini_generate_content"]),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("protocol support probe still rejects HTTP 400 route misses", () => {
|
||||
const message = "HTTP 400: unknown route";
|
||||
|
||||
assert.equal(
|
||||
isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ClaudeCodeRouterPlugin } from "../../packages/core/src/gateway/claude-code-router-plugin.ts";
|
||||
import { prepareGatewayUpstreamAttemptForTest } from "../../packages/core/src/gateway/service.ts";
|
||||
import {
|
||||
fallbackRetryDelayAfterNetworkErrorForTest,
|
||||
fallbackRetryDelayAfterStatusForTest,
|
||||
prepareGatewayUpstreamAttemptForTest
|
||||
} from "../../packages/core/src/gateway/service.ts";
|
||||
|
||||
function createRouterPlugin(options = {}) {
|
||||
const agent = options.agent ?? "claude-code";
|
||||
|
|
@ -42,6 +46,18 @@ function createRouterPlugin(options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
test("fallback retry delay backs off retryable HTTP statuses", () => {
|
||||
assert.equal(fallbackRetryDelayAfterStatusForTest({ statusCode: 503 }), 1000);
|
||||
assert.equal(fallbackRetryDelayAfterStatusForTest({ failedAttemptIndex: 1, statusCode: 408 }), 2000);
|
||||
assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "3", statusCode: 429 }), 3000);
|
||||
assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "0", statusCode: 429 }), 1000);
|
||||
});
|
||||
|
||||
test("fallback retry delay backs off network errors", () => {
|
||||
assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(), 1000);
|
||||
assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(2), 4000);
|
||||
});
|
||||
|
||||
function createIssue1480UserConfig() {
|
||||
return {
|
||||
APIKEY: "gateway-key",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts";
|
||||
import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
|
||||
import { moonshotGlobalProviderPreset } from "../../packages/core/src/providers/presets/moonshot/index.ts";
|
||||
import { ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx";
|
||||
import {
|
||||
applyProviderProbeResult,
|
||||
createProviderConfigFromDeepLink,
|
||||
createProviderDraft,
|
||||
createProviderInstallLinkFromDraft,
|
||||
providerCapabilitiesForProtocols,
|
||||
providerCapabilityBaseUrlForProtocol,
|
||||
providerDisplayIcon,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
|
||||
providerGlobalBaseUrlForProbe,
|
||||
providerPresetIconUrls,
|
||||
providerProtocolOptions,
|
||||
providerProbeCandidates,
|
||||
providerSelectableProtocolsFromProbe,
|
||||
setProviderPresets
|
||||
} from "../../packages/ui/src/pages/home/shared/index.tsx";
|
||||
import { installBrowserGlobals } from "./fixtures.ts";
|
||||
|
||||
installBrowserGlobals();
|
||||
|
||||
test("Gemini preset keeps full protocol probing candidates", () => {
|
||||
setProviderPresets([geminiProviderPreset]);
|
||||
|
|
@ -22,6 +37,7 @@ test("Gemini preset keeps full protocol probing candidates", () => {
|
|||
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.deepEqual(candidates[0].protocols, providerProtocolOptions.map((option) => option.value));
|
||||
assert.deepEqual(candidates[0].declaredProtocols, ["gemini_generate_content", "gemini_interactions"]);
|
||||
});
|
||||
|
||||
test("provider probe result drops unavailable selected protocols", () => {
|
||||
|
|
@ -112,6 +128,301 @@ test("provider probe result applies detected New API key quota account connector
|
|||
assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available");
|
||||
});
|
||||
|
||||
test("provider probe keeps anthropic prefix on the protocol capability", () => {
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
baseUrl: "https://gateway.example",
|
||||
protocol: "anthropic_messages",
|
||||
selectedProtocols: ["anthropic_messages"]
|
||||
};
|
||||
const anthropicCapability = {
|
||||
baseUrl: "https://gateway.example/anthropic",
|
||||
endpoint: "https://gateway.example/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
};
|
||||
const probe = {
|
||||
capabilities: [anthropicCapability],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://gateway.example/anthropic",
|
||||
protocols: [
|
||||
{
|
||||
...anthropicCapability,
|
||||
message: "HTTP 400: model is required",
|
||||
status: 400,
|
||||
supported: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const next = applyProviderProbeResult(draft, probe);
|
||||
|
||||
assert.equal(next.baseUrl, "https://gateway.example");
|
||||
assert.equal(providerGlobalBaseUrlForProbe(draft.baseUrl, probe, ["anthropic_messages"]), "https://gateway.example");
|
||||
assert.equal(
|
||||
providerCapabilityBaseUrlForProtocol(draft.baseUrl, "anthropic_messages", probe),
|
||||
"https://gateway.example/anthropic"
|
||||
);
|
||||
assert.deepEqual(providerCapabilitiesForProtocols(draft.baseUrl, ["anthropic_messages"], probe), [anthropicCapability]);
|
||||
});
|
||||
|
||||
test("provider capabilities prefer detected anthropic endpoint over preset root", () => {
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://gateway.example/anthropic",
|
||||
endpoint: "https://gateway.example/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://gateway.example/anthropic",
|
||||
protocols: []
|
||||
};
|
||||
|
||||
const capabilities = providerCapabilitiesForProtocols(
|
||||
"https://gateway.example",
|
||||
["anthropic_messages", "openai_chat_completions"],
|
||||
probe,
|
||||
[
|
||||
{ baseUrl: "https://gateway.example", source: "preset" as const, type: "anthropic_messages" as const },
|
||||
{ baseUrl: "https://gateway.example/v1", source: "preset" as const, type: "openai_chat_completions" as const }
|
||||
]
|
||||
);
|
||||
|
||||
assert.deepEqual(capabilities.map((capability) => [capability.type, capability.baseUrl]), [
|
||||
["openai_chat_completions", "https://gateway.example/v1"],
|
||||
["anthropic_messages", "https://gateway.example/anthropic"]
|
||||
]);
|
||||
assert.equal(
|
||||
providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["anthropic_messages", "openai_chat_completions"]),
|
||||
"https://gateway.example"
|
||||
);
|
||||
});
|
||||
|
||||
test("provider global URL keeps Kimi Global OpenAI v1 when anthropic probe is detected", () => {
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
|
||||
protocols: []
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
providerGlobalBaseUrlForProbe("https://api.moonshot.ai/v1", probe, ["openai_chat_completions"]),
|
||||
"https://api.moonshot.ai/v1"
|
||||
);
|
||||
assert.deepEqual(
|
||||
providerCapabilitiesForProtocols("https://api.moonshot.ai/v1", ["openai_chat_completions"], probe)
|
||||
.map((capability) => [capability.type, capability.baseUrl]),
|
||||
[["openai_chat_completions", "https://api.moonshot.ai/v1"]]
|
||||
);
|
||||
});
|
||||
|
||||
test("provider global URL always follows the typed endpoint instead of probe endpoint", () => {
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
endpoint: "https://gateway.example/v1/chat/completions",
|
||||
source: "detected" as const,
|
||||
type: "openai_chat_completions" as const
|
||||
},
|
||||
{
|
||||
baseUrl: "https://gateway.example/anthropic",
|
||||
endpoint: "https://gateway.example/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "openai_chat_completions" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://gateway.example/v1",
|
||||
protocols: []
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["openai_chat_completions", "anthropic_messages"]),
|
||||
"https://gateway.example"
|
||||
);
|
||||
});
|
||||
|
||||
test("Kimi Global preset keeps OpenAI Chat when probe detects anthropic fallback", () => {
|
||||
setProviderPresets([moonshotGlobalProviderPreset]);
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
presetId: "moonshot-global",
|
||||
protocol: "openai_chat_completions",
|
||||
selectedProtocols: ["openai_chat_completions"]
|
||||
};
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
source: "preset" as const,
|
||||
type: "openai_chat_completions" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
|
||||
protocols: [
|
||||
{
|
||||
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
|
||||
message: "HTTP 400: model is required",
|
||||
protocol: "anthropic_messages" as const,
|
||||
status: 400,
|
||||
supported: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const next = applyProviderProbeResult(draft, probe);
|
||||
|
||||
assert.equal(next.baseUrl, "https://api.moonshot.ai/v1");
|
||||
assert.equal(next.protocol, "openai_chat_completions");
|
||||
assert.deepEqual(next.selectedProtocols, ["openai_chat_completions", "anthropic_messages"]);
|
||||
assert.deepEqual(providerSelectableProtocolsFromProbe(probe), ["openai_chat_completions", "anthropic_messages"]);
|
||||
|
||||
const installLink = createProviderInstallLinkFromDraft({
|
||||
...next,
|
||||
modelsText: "kimi-k2.7-code",
|
||||
name: "Kimi API (Global)"
|
||||
}, probe);
|
||||
const payload = providerInstallLinkPayload(installLink);
|
||||
assert.equal(payload.protocol, "openai_chat_completions");
|
||||
assert.equal(payload.baseUrl, "https://api.moonshot.ai/v1");
|
||||
});
|
||||
|
||||
test("Kimi Global deep link explicit OpenAI protocol wins over anthropic probe", () => {
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
|
||||
protocols: []
|
||||
};
|
||||
|
||||
const provider = createProviderConfigFromDeepLink({
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
models: ["kimi-k2.7-code"],
|
||||
name: "Kimi API (Global)",
|
||||
protocol: "openai_chat_completions"
|
||||
}, [], probe);
|
||||
|
||||
assert.equal(provider.type, "openai_chat_completions");
|
||||
assert.equal(provider.api_base_url, "https://api.moonshot.ai/v1");
|
||||
assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
|
||||
["openai_chat_completions", "https://api.moonshot.ai/v1"]
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider deep link config saves anthropic probe prefix as capability URL", () => {
|
||||
const probe = {
|
||||
capabilities: [
|
||||
{
|
||||
baseUrl: "https://gateway.example/anthropic",
|
||||
endpoint: "https://gateway.example/anthropic/v1/messages",
|
||||
source: "detected" as const,
|
||||
type: "anthropic_messages" as const
|
||||
}
|
||||
],
|
||||
detectedProtocol: "anthropic_messages" as const,
|
||||
models: [],
|
||||
normalizedBaseUrl: "https://gateway.example/anthropic",
|
||||
protocols: []
|
||||
};
|
||||
|
||||
const provider = createProviderConfigFromDeepLink({
|
||||
baseUrl: "https://gateway.example",
|
||||
models: ["claude-test"],
|
||||
name: "Gateway",
|
||||
protocol: "anthropic_messages"
|
||||
}, [], probe);
|
||||
|
||||
assert.equal(provider.api_base_url, "https://gateway.example");
|
||||
assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
|
||||
["anthropic_messages", "https://gateway.example/anthropic"]
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider display icon prefers custom icons and falls back to preset icons", () => {
|
||||
setProviderPresets([geminiProviderPreset]);
|
||||
|
||||
assert.equal(
|
||||
providerDisplayIcon({
|
||||
api_base_url: "https://custom.example/v1",
|
||||
icon: "https://custom.example/icon.png",
|
||||
models: [],
|
||||
name: "Custom Provider",
|
||||
type: "openai_chat_completions"
|
||||
}),
|
||||
"https://custom.example/icon.png"
|
||||
);
|
||||
assert.equal(
|
||||
providerDisplayIcon({
|
||||
api_base_url: "https://generativelanguage.googleapis.com",
|
||||
models: [],
|
||||
name: "Google Gemini",
|
||||
type: "gemini_generate_content"
|
||||
}),
|
||||
providerPresetIconUrls.gemini
|
||||
);
|
||||
});
|
||||
|
||||
test("ProvidersView renders configured provider icons in the list", () => {
|
||||
const iconUrl = "https://custom.example/icon.png";
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(ProvidersView, {
|
||||
accountSnapshots: [],
|
||||
addProvider: () => undefined,
|
||||
editProvider: () => undefined,
|
||||
notify: () => undefined,
|
||||
providers: [
|
||||
{
|
||||
index: 0,
|
||||
provider: {
|
||||
api_base_url: "https://custom.example/v1",
|
||||
icon: iconUrl,
|
||||
models: ["custom-model"],
|
||||
name: "Custom Provider",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
}
|
||||
],
|
||||
removeProvider: () => undefined
|
||||
})
|
||||
);
|
||||
|
||||
assert.match(html, /Custom Provider/);
|
||||
assert.match(html, /src="https:\/\/custom\.example\/icon\.png"/);
|
||||
});
|
||||
|
||||
test("New API user balance template adds configurable user self connector", () => {
|
||||
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
|
||||
const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
|
||||
|
|
@ -129,3 +440,12 @@ test("New API user balance template adds configurable user self connector", () =
|
|||
assert.equal(connectors[1].headers["New-Api-User"], "42");
|
||||
assert.equal(connectors[1].mapping.meters[0].id, "new_api_user_balance");
|
||||
});
|
||||
|
||||
function providerInstallLinkPayload(link) {
|
||||
const url = new URL(link);
|
||||
const payload = url.searchParams.get("payload");
|
||||
assert.ok(payload);
|
||||
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, "=").replace(/-/g, "+").replace(/_/g, "/");
|
||||
const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { formatLogTokenSummary } from "../../packages/ui/src/pages/home/shared/logs.ts";
|
||||
import { formatCompactNumber } from "../../packages/ui/src/pages/home/shared/usage.ts";
|
||||
import { formatCompactNumber, formatUsdCost as formatHomeUsdCost } from "../../packages/ui/src/pages/home/shared/usage.ts";
|
||||
import { formatUsdCost as formatTrayUsdCost } from "../../packages/ui/src/pages/tray/shared.tsx";
|
||||
import type { RequestLogEntry } from "../../packages/core/src/contracts/app.ts";
|
||||
|
||||
test("formatCompactNumber can be bound to the UI language locale", () => {
|
||||
|
|
@ -9,6 +10,13 @@ test("formatCompactNumber can be bound to the UI language locale", () => {
|
|||
assert.equal(formatCompactNumber(123456, "zh-CN"), "12.3万");
|
||||
});
|
||||
|
||||
test("formatUsdCost formats large values without conflicting fraction digits", () => {
|
||||
assert.doesNotThrow(() => formatHomeUsdCost(100));
|
||||
assert.doesNotThrow(() => formatTrayUsdCost(100));
|
||||
assert.doesNotMatch(formatHomeUsdCost(123.45), /[.,]45/);
|
||||
assert.doesNotMatch(formatTrayUsdCost(123.45), /[.,]45/);
|
||||
});
|
||||
|
||||
test("formatLogTokenSummary uses the provided locale for token counts", () => {
|
||||
const entry: RequestLogEntry = {
|
||||
cacheReadTokens: 0,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue