Add protocol selection to Claude and Codex provider forms referencing CCSwitch
This commit is contained in:
1 parent
bae24cc439
commit
16957decb2
14 files changed
+274
-25
No files matched your search
@@ -46,7 +46,15 @@ type Models struct {
|
|||||||
Extra map[string]string `json:"extra,omitempty"`
|
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 {
|
type Provider struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -54,6 +62,8 @@ type Provider struct {
|
|||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
Models Models `json:"models"`
|
Models Models `json:"models"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
APIFormat string `json:"api_format,omitempty"`
|
||||||
EnabledForClaude bool `json:"enabled_for_claude"`
|
EnabledForClaude bool `json:"enabled_for_claude"`
|
||||||
EnabledForCodex bool `json:"enabled_for_codex"`
|
EnabledForCodex bool `json:"enabled_for_codex"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -170,6 +180,13 @@ func (c *Config) normalize() {
|
|||||||
}
|
}
|
||||||
for i := range c.Providers {
|
for i := range c.Providers {
|
||||||
c.Providers[i].Models.normalize()
|
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 {
|
switch c.Providers[i].Target {
|
||||||
case TargetClaude, TargetCodex:
|
case TargetClaude, TargetCodex:
|
||||||
default:
|
default:
|
||||||
@@ -206,7 +223,7 @@ func (c *Config) clone() *Config {
|
|||||||
return &out
|
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)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, errors.New("name is required")
|
return nil, errors.New("name is required")
|
||||||
@@ -231,6 +248,13 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex)
|
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{
|
return &Provider{
|
||||||
ID: newID("p_"),
|
ID: newID("p_"),
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -238,6 +262,8 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
|
|||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
Models: models,
|
Models: models,
|
||||||
|
Protocol: proto,
|
||||||
|
APIFormat: proto,
|
||||||
Target: target,
|
Target: target,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -337,3 +337,48 @@ func TestSetGatewayValidation(t *testing.T) {
|
|||||||
t.Error("unknown language accepted")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,10 +104,10 @@ func (s *Store) ActiveCodex() *Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddProvider validates and appends a provider for the given target.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// UpdateProvider replaces a provider's editable fields. An empty apiKey
|
||||||
// keeps the stored key so edits never wipe it by accident; an empty
|
// keeps the stored key so edits never wipe it by accident; an empty
|
||||||
// target keeps the stored one.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
idx := -1
|
idx := -1
|
||||||
@@ -141,7 +141,11 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
target = existing.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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import (
|
|||||||
|
|
||||||
// providerInput is the provider payload accepted by the management API.
|
// providerInput is the provider payload accepted by the management API.
|
||||||
type providerInput struct {
|
type providerInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Preset string `json:"preset"`
|
Preset string `json:"preset"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
Models config.Models `json:"models"`
|
Models config.Models `json:"models"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
APIFormat string `json:"api_format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
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,
|
"name": p.Name,
|
||||||
"base_url": p.BaseURL,
|
"base_url": p.BaseURL,
|
||||||
"models": p.Models,
|
"models": p.Models,
|
||||||
|
"protocol": p.Protocol,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var activeCodex any
|
var activeCodex any
|
||||||
@@ -43,6 +46,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": p.Name,
|
"name": p.Name,
|
||||||
"base_url": p.BaseURL,
|
"base_url": p.BaseURL,
|
||||||
"models": p.Models,
|
"models": p.Models,
|
||||||
|
"protocol": p.Protocol,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
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())
|
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -139,7 +147,11 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import "gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
|||||||
// Preset is a ready-made provider template. Model names reflect each
|
// Preset is a ready-made provider template. Model names reflect each
|
||||||
// vendor's catalog as of 2026-10 and can be edited per provider.
|
// vendor's catalog as of 2026-10 and can be edited per provider.
|
||||||
type Preset struct {
|
type Preset struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Models config.Models `json:"models"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
Models config.Models `json:"models"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// All lists the built-in presets.
|
// All lists the built-in presets.
|
||||||
var All = []Preset{
|
var All = []Preset{
|
||||||
{
|
{
|
||||||
Key: "deepseek",
|
Key: "deepseek",
|
||||||
BaseURL: "https://api.deepseek.com/v1",
|
BaseURL: "https://api.deepseek.com/v1",
|
||||||
|
Protocol: config.ProtocolOpenAIChat,
|
||||||
Models: config.Models{
|
Models: config.Models{
|
||||||
Default: "deepseek-chat",
|
Default: "deepseek-chat",
|
||||||
Sonnet: "deepseek-chat",
|
Sonnet: "deepseek-chat",
|
||||||
@@ -25,8 +27,9 @@ var All = []Preset{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "moonshot",
|
Key: "moonshot",
|
||||||
BaseURL: "https://api.moonshot.cn/v1",
|
BaseURL: "https://api.moonshot.cn/v1",
|
||||||
|
Protocol: config.ProtocolOpenAIChat,
|
||||||
Models: config.Models{
|
Models: config.Models{
|
||||||
Default: "kimi-k2-0905-preview",
|
Default: "kimi-k2-0905-preview",
|
||||||
Sonnet: "kimi-k2-0905-preview",
|
Sonnet: "kimi-k2-0905-preview",
|
||||||
@@ -36,8 +39,9 @@ var All = []Preset{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "zhipu",
|
Key: "zhipu",
|
||||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
Protocol: config.ProtocolOpenAIChat,
|
||||||
Models: config.Models{
|
Models: config.Models{
|
||||||
Default: "glm-4.6",
|
Default: "glm-4.6",
|
||||||
Sonnet: "glm-4.6",
|
Sonnet: "glm-4.6",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useToast } from "@/components/toast";
|
|||||||
export interface ProviderSubmitInput {
|
export interface ProviderSubmitInput {
|
||||||
name: string;
|
name: string;
|
||||||
preset: string;
|
preset: string;
|
||||||
|
protocol: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
@@ -23,6 +24,7 @@ export interface ProviderSubmitInput {
|
|||||||
interface FormState {
|
interface FormState {
|
||||||
name: string;
|
name: string;
|
||||||
preset: string;
|
preset: string;
|
||||||
|
protocol: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
||||||
@@ -31,6 +33,7 @@ interface FormState {
|
|||||||
const emptyForm: FormState = {
|
const emptyForm: FormState = {
|
||||||
name: "",
|
name: "",
|
||||||
preset: "",
|
preset: "",
|
||||||
|
protocol: "openai_chat",
|
||||||
base_url: "",
|
base_url: "",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
|
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
|
||||||
@@ -54,15 +57,31 @@ interface ProviderFormProps {
|
|||||||
onCancel: () => void;
|
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) {
|
export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const uid = useId();
|
const uid = useId();
|
||||||
|
const defaultProtocol = "openai_chat";
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(() =>
|
const [form, setForm] = useState<FormState>(() =>
|
||||||
editing
|
editing
|
||||||
? {
|
? {
|
||||||
name: editing.name,
|
name: editing.name,
|
||||||
preset: editing.preset ?? "",
|
preset: editing.preset ?? "",
|
||||||
|
protocol: editing.protocol ?? editing.api_format ?? defaultProtocol,
|
||||||
base_url: editing.base_url,
|
base_url: editing.base_url,
|
||||||
api_key: "",
|
api_key: "",
|
||||||
models: {
|
models: {
|
||||||
@@ -73,7 +92,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
|||||||
extra: toRows(editing.models.extra),
|
extra: toRows(editing.models.extra),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: { ...emptyForm },
|
: { ...emptyForm, protocol: defaultProtocol },
|
||||||
);
|
);
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||||
const [modelList, setModelList] = useState<{ id: 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 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 fetchModels = async () => {
|
||||||
const baseUrl = form.base_url.trim();
|
const baseUrl = form.base_url.trim();
|
||||||
const apiKey = form.api_key.trim() || editing?.api_key || "";
|
const apiKey = form.api_key.trim() || editing?.api_key || "";
|
||||||
@@ -114,6 +139,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
|||||||
onSubmit({
|
onSubmit({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
preset: form.preset,
|
preset: form.preset,
|
||||||
|
protocol: form.protocol,
|
||||||
base_url: form.base_url.trim(),
|
base_url: form.base_url.trim(),
|
||||||
api_key: form.api_key.trim(),
|
api_key: form.api_key.trim(),
|
||||||
models: {
|
models: {
|
||||||
@@ -153,6 +179,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
|||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
preset: key,
|
preset: key,
|
||||||
|
protocol: preset?.protocol ?? form.protocol,
|
||||||
base_url: preset?.base_url ?? form.base_url,
|
base_url: preset?.base_url ?? form.base_url,
|
||||||
models: preset
|
models: preset
|
||||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||||
@@ -168,6 +195,20 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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 className="flex flex-col gap-1">
|
||||||
<Label>{t("providers.baseUrl")}</Label>
|
<Label>{t("providers.baseUrl")}</Label>
|
||||||
<Input
|
<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>}
|
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="col-span-2 flex flex-col gap-1">
|
||||||
<Label>
|
<Label>
|
||||||
{t("providers.apiKey")}
|
{t("providers.apiKey")}
|
||||||
{editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
|
{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>}
|
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{target === "claude" ? t("providers.protocolHintClaude") : t("providers.protocolHintCodex")}
|
||||||
|
</p>
|
||||||
|
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export const zhCN = {
|
|||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
preset: "预设",
|
preset: "预设",
|
||||||
presetCustom: "自定义",
|
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: "模型映射",
|
models: "模型映射",
|
||||||
defaultModel: "默认模型",
|
defaultModel: "默认模型",
|
||||||
sonnetModel: "Sonnet 模型",
|
sonnetModel: "Sonnet 模型",
|
||||||
@@ -198,6 +208,16 @@ export const enUS = {
|
|||||||
apiKey: "API key",
|
apiKey: "API key",
|
||||||
preset: "Preset",
|
preset: "Preset",
|
||||||
presetCustom: "Custom",
|
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",
|
models: "Model mapping",
|
||||||
defaultModel: "Default model",
|
defaultModel: "Default model",
|
||||||
sonnetModel: "Sonnet model",
|
sonnetModel: "Sonnet model",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export interface ProviderInput {
|
export interface ProviderInput {
|
||||||
name: string;
|
name: string;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
|
protocol?: string;
|
||||||
|
api_format?: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ export interface Models {
|
|||||||
extra?: Record<string, string>;
|
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 {
|
export interface Provider {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,6 +26,8 @@ export interface Provider {
|
|||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
|
protocol?: string;
|
||||||
|
api_format?: string;
|
||||||
enabled_for_claude: boolean;
|
enabled_for_claude: boolean;
|
||||||
enabled_for_codex: boolean;
|
enabled_for_codex: boolean;
|
||||||
target: "claude" | "codex";
|
target: "claude" | "codex";
|
||||||
@@ -31,6 +46,7 @@ export interface ActiveProvider {
|
|||||||
name: string;
|
name: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
|
protocol?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
@@ -91,6 +107,7 @@ export interface Injection {
|
|||||||
export interface Preset {
|
export interface Preset {
|
||||||
key: string;
|
key: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
|
protocol?: string;
|
||||||
models: Models;
|
models: Models;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +117,7 @@ export const PRESETS: Preset[] = [
|
|||||||
{
|
{
|
||||||
key: "deepseek",
|
key: "deepseek",
|
||||||
base_url: "https://api.deepseek.com/v1",
|
base_url: "https://api.deepseek.com/v1",
|
||||||
|
protocol: "openai_chat",
|
||||||
models: withCodexExtras({
|
models: withCodexExtras({
|
||||||
default: "deepseek-chat",
|
default: "deepseek-chat",
|
||||||
sonnet: "deepseek-chat",
|
sonnet: "deepseek-chat",
|
||||||
@@ -110,6 +128,7 @@ export const PRESETS: Preset[] = [
|
|||||||
{
|
{
|
||||||
key: "moonshot",
|
key: "moonshot",
|
||||||
base_url: "https://api.moonshot.cn/v1",
|
base_url: "https://api.moonshot.cn/v1",
|
||||||
|
protocol: "openai_chat",
|
||||||
models: withCodexExtras(
|
models: withCodexExtras(
|
||||||
{
|
{
|
||||||
default: "kimi-k2-0905-preview",
|
default: "kimi-k2-0905-preview",
|
||||||
@@ -123,6 +142,7 @@ export const PRESETS: Preset[] = [
|
|||||||
{
|
{
|
||||||
key: "zhipu",
|
key: "zhipu",
|
||||||
base_url: "https://open.bigmodel.cn/api/paas/v4",
|
base_url: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
protocol: "openai_chat",
|
||||||
models: withCodexExtras(
|
models: withCodexExtras(
|
||||||
{
|
{
|
||||||
default: "glm-4.6",
|
default: "glm-4.6",
|
||||||
|
|||||||
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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 ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,4 +139,28 @@ describe("Codex page", () => {
|
|||||||
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
|
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
|
||||||
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||||
|
import { formatProtocolBadge } from "@/lib/utils";
|
||||||
|
|
||||||
export default function Codex() {
|
export default function Codex() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -151,6 +152,9 @@ export default function Codex() {
|
|||||||
<span className="text-sm font-medium">{provider.name}</span>
|
<span className="text-sm font-medium">{provider.name}</span>
|
||||||
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
|
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
|
||||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||||
|
{provider.protocol && (
|
||||||
|
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,4 +143,29 @@ describe("Providers page", () => {
|
|||||||
const input = createProvider.mock.calls[0][0];
|
const input = createProvider.mock.calls[0][0];
|
||||||
expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" });
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||||
|
import { formatProtocolBadge } from "@/lib/utils";
|
||||||
|
|
||||||
export default function Providers() {
|
export default function Providers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -107,6 +108,9 @@ export default function Providers() {
|
|||||||
<Badge tone="success">{t("providers.activated")}</Badge>
|
<Badge tone="success">{t("providers.activated")}</Badge>
|
||||||
)}
|
)}
|
||||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||||
|
{provider.protocol && (
|
||||||
|
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user