Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion .design/openai-endpoint-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,74 @@ PR #976 引入此设计。涉及行为变更的两个点:

---

## 10. 不在本文档范围
## 10. Auto 模式(运行时协议自适应)

### 10.1 问题

第三方聚合 provider 在同一个 OpenAI-style API base 下托管多种模型,有的只支持 Chat、有的只支持 Responses。`OpenAIEndpointMode` 是 provider 级别的,无法 per-model 区分,且模型列表不可穷尽。

### 10.2 与 AdaptiveProbe(§2.1)的本质区别

| | AdaptiveProbe(已废弃)| EndpointModeAuto |
|---|---|---|
| 探测方式 | 独立 probe 请求(烧 token、被限流) | 用户真实请求 |
| 失败处理 | 缓存失败 → 误判固化 | **只缓存成功,失败不缓存** |
| 冷启动 | 阻塞 10s | 零阻塞,首请求直接发 |
| 级联风险 | probe 失败 → 整个 provider 不可用 | 仅影响当前请求,自动 fallback |
| 实现位置 | 运行时缓存层(§2.1 的根因) | handler 层,resolver 保持纯函数 |

### 10.3 行为

```
EndpointModeAuto = "auto"
```

1. **Rule override 最高优先级**:`openai_endpoint_override` 设了 chat/responses 就直接用,跳过 auto。
2. **查成功缓存**:`provider_uuid:model_name → protocol`,命中则直接用缓存协议。
3. **缓存未命中**:用 incoming protocol 作为首次尝试。
4. **首次尝试失败**:排除不可重试的错误(401/403/429/内容相关),其余都做协议 fallback。
5. **fallback 成功**:缓存 `provider_uuid:model_name → alternate_protocol`(24h TTL)。
6. **fallback 失败**:返回错误,不缓存。

### 10.4 错误分类(排除法)

不重试的错误(换协议也没用):
- 401 / 403:认证问题
- 429:限流
- `context_length_exceeded`、`content_policy`、`invalid_api_key`、`model_not_found`:内容/模型问题

其余所有错误(404、500、未知错误)→ 允许 fallback。

### 10.5 实现层次

```
┌─────────────────────────────────────────────────────────────┐
│ Layer 2 Rule flag — openai_endpoint_override │
│ 仍然最高优先级,设了就跳过 auto │
├─────────────────────────────────────────────────────────────┤
│ Layer 1 Provider mode — EndpointModeAuto │
│ handler 层管理缓存 + fallback │
│ resolver 保持纯函数(auto → mirror incoming) │
├─────────────────────────────────────────────────────────────┤
│ Cache EndpointCache(in-memory, per provider+model) │
│ 只写入成功结果,24h TTL,惰性淘汰 │
└─────────────────────────────────────────────────────────────┘
```

Auto 逻辑在 handler 层(`openai_chat.go`、`openai_responses.go`),使用 `firstChunkGate` 缓冲首次尝试的响应。Gate 与 `dispatchWithPriorityFailover` 共享(通过 `dispatchWithPriorityFailoverGated` 接受外部 gate),避免嵌套。

### 10.6 关键文件

- `ai/provider.go` — `EndpointModeAuto` 常量
- `internal/server/endpoint_cache.go` — 成功缓存
- `internal/server/endpoint_auto.go` — 错误分类 + `dispatchWithAutoFallback`
- `internal/server/failover_dispatch.go` — `dispatchWithPriorityFailoverGated`
- `internal/server/openai_chat.go` — Chat handler 的 auto 分支
- `internal/server/openai_responses.go` — Responses handler 的 auto 分支

---

## 11. 不在本文档范围

- Anthropic / Google provider 的路由(走各自原生 endpoint,不进 OpenAI resolver)
- Smart routing / load balance 选哪个 service(在 endpoint 选择之前)
Expand Down
21 changes: 17 additions & 4 deletions ai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ type OpenAIEndpointMode string
const (
EndpointModeUnknown OpenAIEndpointMode = ""

// EndpointModeChat (the default) means the provider only exposes
// EndpointModeChat means the provider only exposes
// /chat/completions. An incoming Responses request will be downgraded
// to Chat if its features allow; otherwise the request is rejected.
EndpointModeChat OpenAIEndpointMode = "chat"
Expand All @@ -236,18 +236,31 @@ const (
// client's incoming API so native semantics (reasoning blocks,
// previous_response_id continuity, etc.) survive the round trip.
EndpointModeBoth OpenAIEndpointMode = "both"

// EndpointModeAuto is for providers where the gateway tries the
// incoming protocol first; on failure it falls back to the alternate
// protocol and caches successful results per model. This is also the
// default behavior when no mode is explicitly set (zero value).
EndpointModeAuto OpenAIEndpointMode = "auto"
)

// IsAutoEndpointMode reports whether the mode represents auto-detection
// behavior. Both the explicit "auto" value and the zero value (no mode
// set) are treated as auto.
func IsAutoEndpointMode(mode OpenAIEndpointMode) bool {
return mode == EndpointModeAuto || mode == EndpointModeUnknown
}

// OpenAIEndpointModeForIssuer returns the OpenAIEndpointMode that an OAuth
// provider should carry given its issuer. Currently only Codex needs a
// non-default mode; other issuers fall through to EndpointModeChat (zero
// value). Centralized so the OAuth web handler and the CLI flow agree on
// non-default mode; other issuers use the zero value (auto).
// Centralized so the OAuth web handler and the CLI flow agree on
// the same mapping and future issuer-specific defaults land in one place.
func OpenAIEndpointModeForIssuer(issuer Issuer) OpenAIEndpointMode {
if issuer == IssuerCodex {
return EndpointModeResponses
}
return EndpointModeChat
return EndpointModeUnknown
}

// IsVirtual reports whether this provider routes to the in-process vmodel
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/components/GlobalExperimentalFeatures.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {useFeatureFlags} from '@/contexts/FeatureFlagsContext';
import { Psychology as IconBrain, Shield as IconShield, SettingsApplications } from '@/components/icons';
import { Psychology as IconBrain, Shield as IconShield, SettingsApplications, Autorenew } from '@/components/icons';
import {Alert, Box, Chip, Tooltip, Typography,} from '@mui/material';
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
Expand All @@ -19,6 +19,7 @@ const GlobalExperimentalFeatures: React.FC = () => {
const [features, setFeatures] = useState<Record<string, boolean>>({});
const [guardrailsEnabled, setGuardrailsEnabled] = useState(false);
const [mcpEnabled, setMCPEnabled] = useState(false);
const [autoEndpointEnabled, setAutoEndpointEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const {refresh} = useFeatureFlags();

Expand All @@ -43,6 +44,10 @@ const GlobalExperimentalFeatures: React.FC = () => {
const mcpResult = await api.getScenarioFlag('_global', 'mcp');
setMCPEnabled(mcpResult?.data?.value || false);

// Load Auto Endpoint flag
const autoEndpointResult = await api.getScenarioFlag('_global', 'auto_endpoint');
setAutoEndpointEnabled(autoEndpointResult?.data?.value || false);

} catch (error) {
console.error('Failed to load global experimental features:', error);
} finally {
Expand Down Expand Up @@ -104,6 +109,24 @@ const GlobalExperimentalFeatures: React.FC = () => {
});
};

const toggleAutoEndpoint = () => {
const newValue = !autoEndpointEnabled;
api.setScenarioFlag('_global', 'auto_endpoint', newValue)
.then((result) => {
if (result.success) {
setAutoEndpointEnabled(newValue);
refresh();
} else {
console.error('Failed to set Auto Endpoint:', result);
loadFeatures();
}
})
.catch((err) => {
console.error('Failed to set Auto Endpoint:', err);
loadFeatures();
});
};

useEffect(() => {
loadFeatures();
}, []);
Expand Down Expand Up @@ -218,6 +241,35 @@ const GlobalExperimentalFeatures: React.FC = () => {
</Alert>
)}

{/* Auto Endpoint Section */}
<Box sx={{ display: 'flex', alignItems: 'center', py: 2, gap: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 180 }}>
<Autorenew sx={{ fontSize: '1rem', color: 'text.secondary' }} />
<Typography variant="subtitle2" sx={{ color: 'text.secondary' }}>
{t('system.experimentalFeatures.autoEndpoint')}
</Typography>
</Box>

<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<Tooltip title={t('system.experimentalFeatures.enableAutoEndpoint') + (autoEndpointEnabled ? ` (${t('system.experimentalFeatures.enabled')})` : ` (${t('system.experimentalFeatures.disabled')})`)} arrow>
<Chip
label={`${t('system.experimentalFeatures.autoEndpoint')} · ${autoEndpointEnabled ? t('common.on') : t('common.off')}`}
onClick={toggleAutoEndpoint}
size="small"
sx={{ ...chipStyle(autoEndpointEnabled), cursor: 'pointer', pointerEvents: 'auto' }}
/>
</Tooltip>
</Box>
</Box>

{autoEndpointEnabled && (
<Alert severity="info" sx={{ mt: 1 }}>
<Typography variant="body2">
{t('system.experimentalFeatures.autoEndpointEnabledInfo')}
</Typography>
</Alert>
)}

</Box>
);
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ProviderFormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface EnhancedProviderFormData {
apiBaseOpenAI?: string;
apiBaseAnthropic?: string;
createDualProvider?: boolean;
openaiEndpointMode?: string;
/** If set, prefer this exact provider ID when resolving the template.
* Avoids mismatches when multiple providers share the same base URL. */
selectedProviderId?: string;
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/contexts/FeatureFlagsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface FeatureFlagsContextType {
skillIde: boolean;
enableGuardrails: boolean;
enableMCP: boolean;
enableAutoEndpoint: boolean;
loading: boolean;
refresh: () => void;
}
Expand All @@ -32,20 +33,23 @@ export const FeatureFlagsProvider: React.FC<FeatureFlagsProviderProps> = ({ chil
const [skillIde, setSkillIde] = useState(false);
const [enableGuardrails, setEnableGuardrails] = useState(false);
const [enableMCP, setEnableMCP] = useState(false);
const [enableAutoEndpoint, setEnableAutoEndpoint] = useState(false);
const [loading, setLoading] = useState(true);

const loadFlags = async () => {
try {
const [skillUserResult, skillIdeResult, guardrailsResult, mcpResult] = await Promise.all([
const [skillUserResult, skillIdeResult, guardrailsResult, mcpResult, autoEndpointResult] = await Promise.all([
api.getScenarioFlag('_global', 'skill_user'),
api.getScenarioFlag('_global', 'skill_ide'),
api.getScenarioFlag('_global', 'guardrails'),
api.getScenarioFlag('_global', 'mcp'),
api.getScenarioFlag('_global', 'auto_endpoint'),
]);
setSkillUser(skillUserResult?.data?.value || false);
setSkillIde(skillIdeResult?.data?.value || false);
setEnableGuardrails(guardrailsResult?.data?.value || false);
setEnableMCP(mcpResult?.data?.value || false);
setEnableAutoEndpoint(autoEndpointResult?.data?.value || false);
} catch (error) {
// Silently fail - flags will default to false
// Don't log to console to avoid noise during initial auth
Expand All @@ -67,7 +71,7 @@ export const FeatureFlagsProvider: React.FC<FeatureFlagsProviderProps> = ({ chil
};

return (
<FeatureFlagsContext.Provider value={{ skillUser, skillIde, enableGuardrails, enableMCP, loading, refresh }}>
<FeatureFlagsContext.Provider value={{ skillUser, skillIde, enableGuardrails, enableMCP, enableAutoEndpoint, loading, refresh }}>
{children}
</FeatureFlagsContext.Provider>
);
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,10 @@ export default {
"enabled": "enabled",
"disabled": "disabled - Click to enable",
"guardrailsEnabledInfo": "Guardrails is enabled. A \"Guardrails\" page is available in the sidebar for rule management.",
"mcpEnabledInfo": "MCP Tools is enabled. An \"MCP Tools\" page is available under System in the sidebar for configuration."
"mcpEnabledInfo": "MCP Tools is enabled. An \"MCP Tools\" page is available under System in the sidebar for configuration.",
"autoEndpoint": "Auto Endpoint",
"enableAutoEndpoint": "Enable Auto Endpoint Detection - automatically detect whether each model supports Chat Completions or Responses API",
"autoEndpointEnabledInfo": "Auto Endpoint Detection is enabled. For OpenAI-compatible providers, the system will automatically try the preferred protocol and fall back to the alternate on failure, caching successful results per model."
},
"about": {
"title": "About",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,10 @@ export default {
"enabled": "已启用",
"disabled": "已禁用 - 点击启用",
"guardrailsEnabledInfo": "Guardrails 已启用。侧边栏中提供了「Guardrails」页面用于规则管理。",
"mcpEnabledInfo": "MCP Tools 已启用。侧边栏 System 下方提供了「MCP Tools」页面进行配置。"
"mcpEnabledInfo": "MCP Tools 已启用。侧边栏 System 下方提供了「MCP Tools」页面进行配置。",
"autoEndpoint": "自动端点检测",
"enableAutoEndpoint": "启用自动端点检测 - 自动识别每个模型支持 Chat Completions 还是 Responses API",
"autoEndpointEnabledInfo": "自动端点检测已启用。对于 OpenAI 兼容的 Provider,系统将自动尝试首选协议,失败时回退到备选协议,并按模型缓存成功结果。"
},
"about": {
"title": "关于",
Expand Down
28 changes: 27 additions & 1 deletion internal/client/error.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package client

import "fmt"
import (
"fmt"
"strings"
)

// ErrModelsEndpointNotSupported is returned when the provider does not support the models endpoint
type ErrModelsEndpointNotSupported struct {
Expand Down Expand Up @@ -31,3 +34,26 @@ type ErrKimiNotSupported struct {
func (e *ErrKimiNotSupported) Error() string {
return fmt.Sprintf("Kimi Code does not support %s: %s", e.Operation, e.Reason)
}

// IsNonRetryableForProtocolSwitch reports whether err represents a condition
// where switching the OpenAI endpoint protocol (Chat ↔ Responses) would not
// help. Returns true for nil errors (nothing to retry), auth failures,
// rate-limiting, and content/model errors.
func IsNonRetryableForProtocolSwitch(err error) bool {
if err == nil {
return true
}
s := strings.ToLower(err.Error())

for _, kw := range []string{
"401", "403", "unauthorized", "forbidden",
"429", "rate limit", "ratelimit", "1302",
"context_length", "content_policy", "content_filter",
"invalid_api_key", "model_not_found", "model not found",
} {
if strings.Contains(s, kw) {
return true
}
}
return false
}
44 changes: 44 additions & 0 deletions internal/client/error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package client

import (
"errors"
"testing"
)

func TestIsNonRetryableForProtocolSwitch(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, true},
{"auth 401", errors.New("status 401 Unauthorized"), true},
{"auth 403", errors.New("403 Forbidden"), true},
{"rate limit 429", errors.New("status 429 Too Many Requests"), true},
{"rate limit text", errors.New("rate limit exceeded"), true},
{"rate limit 1302", errors.New("error code 1302"), true},
{"context length", errors.New("context_length_exceeded"), true},
{"content policy", errors.New("content_policy_violation"), true},
{"invalid api key", errors.New("invalid_api_key"), true},
{"model not found", errors.New("model_not_found"), true},
{"model not found spaces", errors.New("model not found"), true},
{"content filter", errors.New("content_filter triggered"), true},

// Retryable cases
{"404 not found", errors.New("status 404 Not Found"), false},
{"500 internal", errors.New("status 500 Internal Server Error"), false},
{"502 bad gateway", errors.New("502 Bad Gateway"), false},
{"connection refused", errors.New("connection refused"), false},
{"unknown error", errors.New("something went wrong"), false},
{"empty error", errors.New(""), false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsNonRetryableForProtocolSwitch(tt.err)
if got != tt.want {
t.Errorf("IsNonRetryableForProtocolSwitch(%q) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
Loading