Add protocol selection to Claude and Codex provider forms referencing CCSwitch

This commit is contained in:
kate522 committed 2026-10-18 23:00:05 +08:00
1 parent bae24cc439
commit 16957decb2
14 files changed
+274 -25

No files matched your search

+28 -2
View File
@@ -46,7 +46,15 @@ type Models struct {
Extra map[string]string `json:"extra,omitempty"`
}
// Provider is one upstream Chat Completions endpoint.
// Supported upstream protocols (matching CCSwitch format identifiers).
const (
ProtocolAnthropic = "anthropic"
ProtocolOpenAIChat = "openai_chat"
ProtocolOpenAIResponses = "openai_responses"
ProtocolGeminiNative = "gemini_native"
)
// Provider is one upstream endpoint.
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -54,6 +62,8 @@ type Provider struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models Models `json:"models"`
Protocol string `json:"protocol,omitempty"`
APIFormat string `json:"api_format,omitempty"`
EnabledForClaude bool `json:"enabled_for_claude"`
EnabledForCodex bool `json:"enabled_for_codex"`
Target string `json:"target,omitempty"`
@@ -170,6 +180,13 @@ func (c *Config) normalize() {
}
for i := range c.Providers {
c.Providers[i].Models.normalize()
if c.Providers[i].Protocol == "" && c.Providers[i].APIFormat != "" {
c.Providers[i].Protocol = c.Providers[i].APIFormat
}
if c.Providers[i].Protocol == "" {
c.Providers[i].Protocol = ProtocolOpenAIChat
}
c.Providers[i].APIFormat = c.Providers[i].Protocol
switch c.Providers[i].Target {
case TargetClaude, TargetCodex:
default:
@@ -206,7 +223,7 @@ func (c *Config) clone() *Config {
return &out
}
func newProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
func newProvider(name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name is required")
@@ -231,6 +248,13 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
default:
return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex)
}
proto := ""
if len(protocol) > 0 {
proto = strings.TrimSpace(protocol[0])
}
if proto == "" {
proto = ProtocolOpenAIChat
}
return &Provider{
ID: newID("p_"),
Name: name,
@@ -238,6 +262,8 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Models: models,
Protocol: proto,
APIFormat: proto,
Target: target,
CreatedAt: time.Now().UTC(),
}, nil
+45
View File
@@ -337,3 +337,48 @@ func TestSetGatewayValidation(t *testing.T) {
t.Error("unknown language accepted")
}
}
func TestProviderProtocol(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
// 1. Default protocol when omitted should be openai_chat.
p1, err := store.AddProvider("Claude-Default", "", "https://api.example.com/v1", "sk-1", Models{}, TargetClaude)
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if p1.Protocol != ProtocolOpenAIChat || p1.APIFormat != ProtocolOpenAIChat {
t.Errorf("expected default protocol %q, got protocol=%q, api_format=%q", ProtocolOpenAIChat, p1.Protocol, p1.APIFormat)
}
// 2. Explicit protocol should be saved.
p2, err := store.AddProvider("Claude-Anthropic", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolAnthropic)
if err != nil {
t.Fatalf("AddProvider with protocol: %v", err)
}
if p2.Protocol != ProtocolAnthropic {
t.Errorf("expected protocol %q, got %q", ProtocolAnthropic, p2.Protocol)
}
// 3. Update protocol.
updated, err := store.UpdateProvider(p2.ID, "Claude-Anthropic-Updated", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolOpenAIResponses)
if err != nil {
t.Fatalf("UpdateProvider with protocol: %v", err)
}
if updated.Protocol != ProtocolOpenAIResponses {
t.Errorf("expected updated protocol %q, got %q", ProtocolOpenAIResponses, updated.Protocol)
}
// 4. Persistence round-trip.
reloaded, err := Load()
if err != nil {
t.Fatalf("Load reloaded: %v", err)
}
found := reloaded.FindProvider(p2.ID)
if found == nil || found.Protocol != ProtocolOpenAIResponses {
t.Errorf("expected persisted protocol %q, got %+v", ProtocolOpenAIResponses, found)
}
}
+8 -4
View File
@@ -104,10 +104,10 @@ func (s *Store) ActiveCodex() *Provider {
}
// AddProvider validates and appends a provider for the given target.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, err := newProvider(name, preset, baseURL, apiKey, models, target)
p, err := newProvider(name, preset, baseURL, apiKey, models, target, protocol...)
if err != nil {
return nil, err
}
@@ -121,7 +121,7 @@ func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models,
// UpdateProvider replaces a provider's editable fields. An empty apiKey
// keeps the stored key so edits never wipe it by accident; an empty
// target keeps the stored one.
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
@@ -141,7 +141,11 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
if target == "" {
target = existing.Target
}
updated, err := newProvider(name, preset, baseURL, apiKey, models, target)
proto := existing.Protocol
if len(protocol) > 0 && strings.TrimSpace(protocol[0]) != "" {
proto = strings.TrimSpace(protocol[0])
}
updated, err := newProvider(name, preset, baseURL, apiKey, models, target, proto)
if err != nil {
return nil, err
}
+20 -8
View File
@@ -17,12 +17,14 @@ import (
// providerInput is the provider payload accepted by the management API.
type providerInput struct {
Name string `json:"name"`
Preset string `json:"preset"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models config.Models `json:"models"`
Target string `json:"target"`
Name string `json:"name"`
Preset string `json:"preset"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models config.Models `json:"models"`
Target string `json:"target"`
Protocol string `json:"protocol"`
APIFormat string `json:"api_format"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -34,6 +36,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"name": p.Name,
"base_url": p.BaseURL,
"models": p.Models,
"protocol": p.Protocol,
}
}
var activeCodex any
@@ -43,6 +46,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"name": p.Name,
"base_url": p.BaseURL,
"models": p.Models,
"protocol": p.Protocol,
}
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -124,7 +128,11 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target)
proto := strings.TrimSpace(in.Protocol)
if proto == "" {
proto = strings.TrimSpace(in.APIFormat)
}
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@@ -139,7 +147,11 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target)
proto := strings.TrimSpace(in.Protocol)
if proto == "" {
proto = strings.TrimSpace(in.APIFormat)
}
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
+13 -9
View File
@@ -6,16 +6,18 @@ import "gitea.rutua.cn/kate522/CrossRelay/internal/config"
// Preset is a ready-made provider template. Model names reflect each
// vendor's catalog as of 2026-10 and can be edited per provider.
type Preset struct {
Key string `json:"key"`
BaseURL string `json:"base_url"`
Models config.Models `json:"models"`
Key string `json:"key"`
BaseURL string `json:"base_url"`
Protocol string `json:"protocol,omitempty"`
Models config.Models `json:"models"`
}
// All lists the built-in presets.
var All = []Preset{
{
Key: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Key: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{
Default: "deepseek-chat",
Sonnet: "deepseek-chat",
@@ -25,8 +27,9 @@ var All = []Preset{
},
},
{
Key: "moonshot",
BaseURL: "https://api.moonshot.cn/v1",
Key: "moonshot",
BaseURL: "https://api.moonshot.cn/v1",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{
Default: "kimi-k2-0905-preview",
Sonnet: "kimi-k2-0905-preview",
@@ -36,8 +39,9 @@ var All = []Preset{
},
},
{
Key: "zhipu",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Key: "zhipu",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{
Default: "glm-4.6",
Sonnet: "glm-4.6",
+46 -2
View File
@@ -14,6 +14,7 @@ import { useToast } from "@/components/toast";
export interface ProviderSubmitInput {
name: string;
preset: string;
protocol: string;
base_url: string;
api_key: string;
models: Models;
@@ -23,6 +24,7 @@ export interface ProviderSubmitInput {
interface FormState {
name: string;
preset: string;
protocol: string;
base_url: string;
api_key: string;
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
@@ -31,6 +33,7 @@ interface FormState {
const emptyForm: FormState = {
name: "",
preset: "",
protocol: "openai_chat",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
@@ -54,15 +57,31 @@ interface ProviderFormProps {
onCancel: () => void;
}
const CLAUDE_PROTOCOLS = [
{ value: "anthropic", labelKey: "providers.protocolAnthropicClaude" },
{ value: "openai_chat", labelKey: "providers.protocolOpenAIChatClaude" },
{ value: "openai_responses", labelKey: "providers.protocolOpenAIResponsesClaude" },
{ value: "gemini_native", labelKey: "providers.protocolGeminiNativeClaude" },
];
const CODEX_PROTOCOLS = [
{ value: "openai_responses", labelKey: "providers.protocolOpenAIResponsesCodex" },
{ value: "openai_chat", labelKey: "providers.protocolOpenAIChatCodex" },
{ value: "anthropic", labelKey: "providers.protocolAnthropicCodex" },
];
export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
const { t } = useTranslation();
const { show } = useToast();
const uid = useId();
const defaultProtocol = "openai_chat";
const [form, setForm] = useState<FormState>(() =>
editing
? {
name: editing.name,
preset: editing.preset ?? "",
protocol: editing.protocol ?? editing.api_format ?? defaultProtocol,
base_url: editing.base_url,
api_key: "",
models: {
@@ -73,7 +92,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
extra: toRows(editing.models.extra),
},
}
: { ...emptyForm },
: { ...emptyForm, protocol: defaultProtocol },
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [modelList, setModelList] = useState<{ id: string }[]>([]);
@@ -81,6 +100,12 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
const listId = (name: string) => `${uid}-${name}`;
const protocolOptions = target === "claude" ? CLAUDE_PROTOCOLS : CODEX_PROTOCOLS;
const hasCurrentProtocol = protocolOptions.some((p) => p.value === form.protocol);
const finalProtocolOptions = hasCurrentProtocol || !form.protocol
? protocolOptions
: [...protocolOptions, { value: form.protocol, labelKey: form.protocol }];
const fetchModels = async () => {
const baseUrl = form.base_url.trim();
const apiKey = form.api_key.trim() || editing?.api_key || "";
@@ -114,6 +139,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
onSubmit({
name: form.name.trim(),
preset: form.preset,
protocol: form.protocol,
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
models: {
@@ -153,6 +179,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
setForm({
...form,
preset: key,
protocol: preset?.protocol ?? form.protocol,
base_url: preset?.base_url ?? form.base_url,
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
@@ -168,6 +195,20 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.protocol")}</Label>
<Select
aria-label={t("providers.protocol")}
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
{finalProtocolOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.labelKey.startsWith("providers.") ? t(opt.labelKey) : opt.labelKey}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label>
<Input
@@ -177,7 +218,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
/>
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label>
<label className="flex flex-col gap-1">
<label className="col-span-2 flex flex-col gap-1">
<Label>
{t("providers.apiKey")}
{editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
@@ -191,6 +232,9 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label>
</div>
<p className="text-xs text-zinc-500">
{target === "claude" ? t("providers.protocolHintClaude") : t("providers.protocolHintCodex")}
</p>
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
+20
View File
@@ -65,6 +65,16 @@ export const zhCN = {
apiKey: "API Key",
preset: "预设",
presetCustom: "自定义",
protocol: "协议选择",
protocolHintClaude: "供应商原生支持的协议:Anthropic Messages 原生无需转换;其他协议经由网关转换。",
protocolHintCodex: "供应商原生支持的协议:Responses 原生无转换;Chat 与 Anthropic 经网关转换为 Responses。",
protocolAnthropicClaude: "Anthropic Messages (原生)",
protocolOpenAIChatClaude: "OpenAI Chat Completions (需转换)",
protocolOpenAIResponsesClaude: "OpenAI Responses API (需转换)",
protocolGeminiNativeClaude: "Gemini Native generateContent (需转换)",
protocolOpenAIResponsesCodex: "OpenAI Responses API (原生)",
protocolOpenAIChatCodex: "OpenAI Chat Completions (需转换)",
protocolAnthropicCodex: "Anthropic Messages (需转换)",
models: "模型映射",
defaultModel: "默认模型",
sonnetModel: "Sonnet 模型",
@@ -198,6 +208,16 @@ export const enUS = {
apiKey: "API key",
preset: "Preset",
presetCustom: "Custom",
protocol: "Protocol",
protocolHintClaude: "Native protocol supported by provider: Anthropic Messages is native without conversion; other protocols are converted by the gateway.",
protocolHintCodex: "Native protocol supported by provider: Responses is native without conversion; Chat and Anthropic are converted to Responses by the gateway.",
protocolAnthropicClaude: "Anthropic Messages (Native)",
protocolOpenAIChatClaude: "OpenAI Chat Completions (Routing required)",
protocolOpenAIResponsesClaude: "OpenAI Responses API (Routing required)",
protocolGeminiNativeClaude: "Gemini Native generateContent (Routing required)",
protocolOpenAIResponsesCodex: "OpenAI Responses API (Native)",
protocolOpenAIChatCodex: "OpenAI Chat Completions (Routing required)",
protocolAnthropicCodex: "Anthropic Messages (Routing required)",
models: "Model mapping",
defaultModel: "Default model",
sonnetModel: "Sonnet model",
+2
View File
@@ -48,6 +48,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export interface ProviderInput {
name: string;
preset?: string;
protocol?: string;
api_format?: string;
base_url: string;
api_key: string;
models: Models;
+20
View File
@@ -6,6 +6,19 @@ export interface Models {
extra?: Record<string, string>;
}
export type ClaudeProtocol =
| "anthropic"
| "openai_chat"
| "openai_responses"
| "gemini_native";
export type CodexProtocol =
| "openai_responses"
| "openai_chat"
| "anthropic";
export type ProviderProtocol = ClaudeProtocol | CodexProtocol;
export interface Provider {
id: string;
name: string;
@@ -13,6 +26,8 @@ export interface Provider {
base_url: string;
api_key: string;
models: Models;
protocol?: string;
api_format?: string;
enabled_for_claude: boolean;
enabled_for_codex: boolean;
target: "claude" | "codex";
@@ -31,6 +46,7 @@ export interface ActiveProvider {
name: string;
base_url: string;
models: Models;
protocol?: string;
}
export interface Status {
@@ -91,6 +107,7 @@ export interface Injection {
export interface Preset {
key: string;
base_url: string;
protocol?: string;
models: Models;
}
@@ -100,6 +117,7 @@ export const PRESETS: Preset[] = [
{
key: "deepseek",
base_url: "https://api.deepseek.com/v1",
protocol: "openai_chat",
models: withCodexExtras({
default: "deepseek-chat",
sonnet: "deepseek-chat",
@@ -110,6 +128,7 @@ export const PRESETS: Preset[] = [
{
key: "moonshot",
base_url: "https://api.moonshot.cn/v1",
protocol: "openai_chat",
models: withCodexExtras(
{
default: "kimi-k2-0905-preview",
@@ -123,6 +142,7 @@ export const PRESETS: Preset[] = [
{
key: "zhipu",
base_url: "https://open.bigmodel.cn/api/paas/v4",
protocol: "openai_chat",
models: withCodexExtras(
{
default: "glm-4.6",
+15
View File
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatProtocolBadge(protocol?: string): string {
switch (protocol) {
case "anthropic":
return "Anthropic";
case "openai_chat":
return "Chat";
case "openai_responses":
return "Responses";
case "gemini_native":
return "Gemini";
default:
return protocol ?? "";
}
}
+24
View File
@@ -139,4 +139,28 @@ describe("Codex page", () => {
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
});
it("renders protocol options and submits selected protocol for Codex", async () => {
const user = userEvent.setup();
renderWithProviders(<Codex />);
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
const select = screen.getByLabelText(/协议选择|Protocol/);
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Responses API/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Chat Completions/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Anthropic Messages/ })).toBeInTheDocument();
await user.selectOptions(select, "openai_responses");
await user.type(screen.getByLabelText(/名称|Name/), "OpenAI-Direct");
await user.type(screen.getByLabelText(/Base URL/), "https://api.openai.com/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-oa");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "OpenAI-Direct",
protocol: "openai_responses",
target: "codex",
});
});
});
+4
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
import { formatProtocolBadge } from "@/lib/utils";
export default function Codex() {
const { t } = useTranslation();
@@ -151,6 +152,9 @@ export default function Codex() {
<span className="text-sm font-medium">{provider.name}</span>
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
{provider.preset && <Badge>{provider.preset}</Badge>}
{provider.protocol && (
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
)}
</div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
</div>
+25
View File
@@ -143,4 +143,29 @@ describe("Providers page", () => {
const input = createProvider.mock.calls[0][0];
expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" });
});
it("renders protocol options and submits selected protocol for Claude", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
const select = screen.getByLabelText(/协议选择|Protocol/);
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Anthropic Messages/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Chat Completions/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Responses API/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Gemini Native/ })).toBeInTheDocument();
await user.selectOptions(select, "anthropic");
await user.type(screen.getByLabelText(/名称|Name/), "Claude-Native");
await user.type(screen.getByLabelText(/Base URL/), "https://api.anthropic.com/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-ant");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "Claude-Native",
protocol: "anthropic",
target: "claude",
});
});
});
+4
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
import { formatProtocolBadge } from "@/lib/utils";
export default function Providers() {
const { t } = useTranslation();
@@ -107,6 +108,9 @@ export default function Providers() {
<Badge tone="success">{t("providers.activated")}</Badge>
)}
{provider.preset && <Badge>{provider.preset}</Badge>}
{provider.protocol && (
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
)}
</div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
</div>