Commit 8ec0f0a2 by t0ng7u

Merge branch 'alpha' into refactor/model-pricing

parents 1baad070 7eccf425
...@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) { ...@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeCoze apiType = constant.APITypeCoze
case constant.ChannelTypeJimeng: case constant.ChannelTypeJimeng:
apiType = constant.APITypeJimeng apiType = constant.APITypeJimeng
case constant.ChannelTypeClaudeCode:
apiType = constant.APITypeClaudeCode
} }
if apiType == -1 { if apiType == -1 {
return constant.APITypeOpenAI, false return constant.APITypeOpenAI, false
......
...@@ -31,5 +31,6 @@ const ( ...@@ -31,5 +31,6 @@ const (
APITypeXai APITypeXai
APITypeCoze APITypeCoze
APITypeJimeng APITypeJimeng
APITypeClaudeCode
APITypeDummy // this one is only for count, do not add any channel after this APITypeDummy // this one is only for count, do not add any channel after this
) )
...@@ -50,6 +50,7 @@ const ( ...@@ -50,6 +50,7 @@ const (
ChannelTypeKling = 50 ChannelTypeKling = 50
ChannelTypeJimeng = 51 ChannelTypeJimeng = 51
ChannelTypeVidu = 52 ChannelTypeVidu = 52
ChannelTypeClaudeCode = 53
ChannelTypeDummy // this one is only for count, do not add any channel after this ChannelTypeDummy // this one is only for count, do not add any channel after this
) )
...@@ -108,4 +109,5 @@ var ChannelBaseURLs = []string{ ...@@ -108,4 +109,5 @@ var ChannelBaseURLs = []string{
"https://api.klingai.com", //50 "https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51 "https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52 "https://api.vidu.cn", //52
"https://api.anthropic.com", //53
} }
...@@ -332,9 +332,12 @@ func TestChannel(c *gin.Context) { ...@@ -332,9 +332,12 @@ func TestChannel(c *gin.Context) {
} }
channel, err := model.CacheGetChannel(channelId) channel, err := model.CacheGetChannel(channelId)
if err != nil { if err != nil {
channel, err = model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
}
//defer func() { //defer func() {
// if channel.ChannelInfo.IsMultiKey { // if channel.ChannelInfo.IsMultiKey {
// go func() { _ = channel.SaveChannelInfo() }() // go func() { _ = channel.SaveChannelInfo() }()
......
package controller
import (
"net/http"
"one-api/common"
"one-api/service"
"github.com/gin-gonic/gin"
)
// ExchangeCodeRequest 授权码交换请求
type ExchangeCodeRequest struct {
AuthorizationCode string `json:"authorization_code" binding:"required"`
CodeVerifier string `json:"code_verifier" binding:"required"`
State string `json:"state" binding:"required"`
}
// GenerateClaudeOAuthURL 生成Claude OAuth授权URL
func GenerateClaudeOAuthURL(c *gin.Context) {
params, err := service.GenerateOAuthParams()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "生成OAuth授权URL失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "生成OAuth授权URL成功",
"data": params,
})
}
// ExchangeClaudeOAuthCode 交换Claude OAuth授权码
func ExchangeClaudeOAuthCode(c *gin.Context) {
var req ExchangeCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
})
return
}
// 解析授权码
cleanedCode, err := service.ParseAuthorizationCode(req.AuthorizationCode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// 交换token
tokenResult, err := service.ExchangeCode(cleanedCode, req.CodeVerifier, req.State, nil)
if err != nil {
common.SysError("Claude OAuth token exchange failed: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "授权码交换失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "授权码交换成功",
"data": tokenResult,
})
}
...@@ -2,6 +2,7 @@ package dto ...@@ -2,6 +2,7 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"fmt"
"one-api/common" "one-api/common"
"one-api/types" "one-api/types"
) )
...@@ -284,13 +285,8 @@ func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { ...@@ -284,13 +285,8 @@ func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
return mediaContent return mediaContent
} }
type ClaudeError struct {
Type string `json:"type,omitempty"`
Message string `json:"message,omitempty"`
}
type ClaudeErrorWithStatusCode struct { type ClaudeErrorWithStatusCode struct {
Error ClaudeError `json:"error"` Error types.ClaudeError `json:"error"`
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
LocalError bool LocalError bool
} }
...@@ -303,7 +299,7 @@ type ClaudeResponse struct { ...@@ -303,7 +299,7 @@ type ClaudeResponse struct {
Completion string `json:"completion,omitempty"` Completion string `json:"completion,omitempty"`
StopReason string `json:"stop_reason,omitempty"` StopReason string `json:"stop_reason,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Error *types.ClaudeError `json:"error,omitempty"` Error any `json:"error,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"` Usage *ClaudeUsage `json:"usage,omitempty"`
Index *int `json:"index,omitempty"` Index *int `json:"index,omitempty"`
ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"` ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"`
...@@ -324,6 +320,42 @@ func (c *ClaudeResponse) GetIndex() int { ...@@ -324,6 +320,42 @@ func (c *ClaudeResponse) GetIndex() int {
return *c.Index return *c.Index
} }
// GetClaudeError 从动态错误类型中提取ClaudeError结构
func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
if c.Error == nil {
return nil
}
switch err := c.Error.(type) {
case types.ClaudeError:
return &err
case *types.ClaudeError:
return err
case map[string]interface{}:
// 处理从JSON解析来的map结构
claudeErr := &types.ClaudeError{}
if errType, ok := err["type"].(string); ok {
claudeErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
claudeErr.Message = errMsg
}
return claudeErr
case string:
// 处理简单字符串错误
return &types.ClaudeError{
Type: "error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.ClaudeError{
Type: "unknown_error",
Message: fmt.Sprintf("%v", err),
}
}
}
type ClaudeUsage struct { type ClaudeUsage struct {
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
......
...@@ -2,12 +2,18 @@ package dto ...@@ -2,12 +2,18 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"fmt"
"one-api/types" "one-api/types"
) )
type SimpleResponse struct { type SimpleResponse struct {
Usage `json:"usage"` Usage `json:"usage"`
Error *OpenAIError `json:"error"` Error any `json:"error"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(s.Error)
} }
type TextResponse struct { type TextResponse struct {
...@@ -31,10 +37,15 @@ type OpenAITextResponse struct { ...@@ -31,10 +37,15 @@ type OpenAITextResponse struct {
Object string `json:"object"` Object string `json:"object"`
Created any `json:"created"` Created any `json:"created"`
Choices []OpenAITextResponseChoice `json:"choices"` Choices []OpenAITextResponseChoice `json:"choices"`
Error *types.OpenAIError `json:"error,omitempty"` Error any `json:"error,omitempty"`
Usage `json:"usage"` Usage `json:"usage"`
} }
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type OpenAIEmbeddingResponseItem struct { type OpenAIEmbeddingResponseItem struct {
Object string `json:"object"` Object string `json:"object"`
Index int `json:"index"` Index int `json:"index"`
...@@ -217,7 +228,7 @@ type OpenAIResponsesResponse struct { ...@@ -217,7 +228,7 @@ type OpenAIResponsesResponse struct {
Object string `json:"object"` Object string `json:"object"`
CreatedAt int `json:"created_at"` CreatedAt int `json:"created_at"`
Status string `json:"status"` Status string `json:"status"`
Error *types.OpenAIError `json:"error,omitempty"` Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"` IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions string `json:"instructions"` Instructions string `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"` MaxOutputTokens int `json:"max_output_tokens"`
...@@ -237,6 +248,11 @@ type OpenAIResponsesResponse struct { ...@@ -237,6 +248,11 @@ type OpenAIResponsesResponse struct {
Metadata json.RawMessage `json:"metadata"` Metadata json.RawMessage `json:"metadata"`
} }
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type IncompleteDetails struct { type IncompleteDetails struct {
Reasoning string `json:"reasoning"` Reasoning string `json:"reasoning"`
} }
...@@ -276,3 +292,45 @@ type ResponsesStreamResponse struct { ...@@ -276,3 +292,45 @@ type ResponsesStreamResponse struct {
Delta string `json:"delta,omitempty"` Delta string `json:"delta,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"` Item *ResponsesOutput `json:"item,omitempty"`
} }
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func GetOpenAIError(errorField any) *types.OpenAIError {
if errorField == nil {
return nil
}
switch err := errorField.(type) {
case types.OpenAIError:
return &err
case *types.OpenAIError:
return err
case map[string]interface{}:
// 处理从JSON解析来的map结构
openaiErr := &types.OpenAIError{}
if errType, ok := err["type"].(string); ok {
openaiErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
openaiErr.Message = errMsg
}
if errParam, ok := err["param"].(string); ok {
openaiErr.Param = errParam
}
if errCode, ok := err["code"]; ok {
openaiErr.Code = errCode
}
return openaiErr
case string:
// 处理简单字符串错误
return &types.OpenAIError{
Type: "error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.OpenAIError{
Type: "unknown_error",
Message: fmt.Sprintf("%v", err),
}
}
}
...@@ -87,6 +87,7 @@ require ( ...@@ -87,6 +87,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.12.0 // indirect golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
......
...@@ -231,6 +231,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v ...@@ -231,6 +231,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
......
...@@ -86,6 +86,9 @@ func main() { ...@@ -86,6 +86,9 @@ func main() {
// 数据看板 // 数据看板
go model.UpdateQuotaData() go model.UpdateQuotaData()
// Start Claude Code token refresh scheduler
service.StartClaudeTokenRefreshScheduler()
if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY"))
if err != nil { if err != nil {
......
...@@ -239,6 +239,20 @@ func CacheUpdateChannelStatus(id int, status int) { ...@@ -239,6 +239,20 @@ func CacheUpdateChannelStatus(id int, status int) {
if channel, ok := channelsIDM[id]; ok { if channel, ok := channelsIDM[id]; ok {
channel.Status = status channel.Status = status
} }
if status != common.ChannelStatusEnabled {
// delete the channel from group2model2channels
for group, model2channels := range group2model2channels {
for model, channels := range model2channels {
for i, channelId := range channels {
if channelId == id {
// remove the channel from the slice
group2model2channels[group][model] = append(channels[:i], channels[i+1:]...)
break
}
}
}
}
}
} }
func CacheUpdateChannel(channel *Channel) { func CacheUpdateChannel(channel *Channel) {
......
...@@ -612,8 +612,8 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud ...@@ -612,8 +612,8 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
common.SysError("error unmarshalling stream response: " + err.Error()) common.SysError("error unmarshalling stream response: " + err.Error())
return types.NewError(err, types.ErrorCodeBadResponseBody) return types.NewError(err, types.ErrorCodeBadResponseBody)
} }
if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) return types.WithClaudeError(*claudeError, http.StatusInternalServerError)
} }
if info.RelayFormat == relaycommon.RelayFormatClaude { if info.RelayFormat == relaycommon.RelayFormatClaude {
FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo)
...@@ -704,8 +704,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud ...@@ -704,8 +704,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody) return types.NewError(err, types.ErrorCodeBadResponseBody)
} }
if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) return types.WithClaudeError(*claudeError, http.StatusInternalServerError)
} }
if requestMode == RequestModeCompletion { if requestMode == RequestModeCompletion {
completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName)
......
package claude_code
import (
"errors"
"fmt"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
const (
RequestModeCompletion = 1
RequestModeMessage = 2
DefaultSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
)
type Adaptor struct {
RequestMode int
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
// Use configured system prompt if available, otherwise use default
if info.ChannelSetting.SystemPrompt != "" {
request.System = info.ChannelSetting.SystemPrompt
} else {
request.System = DefaultSystemPrompt
}
return request, nil
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") {
a.RequestMode = RequestModeCompletion
} else {
a.RequestMode = RequestModeMessage
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if a.RequestMode == RequestModeMessage {
return fmt.Sprintf("%s/v1/messages", info.BaseUrl), nil
} else {
return fmt.Sprintf("%s/v1/complete", info.BaseUrl), nil
}
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
// Parse accesstoken|refreshtoken format and use only the access token
accessToken := info.ApiKey
if strings.Contains(info.ApiKey, "|") {
parts := strings.Split(info.ApiKey, "|")
if len(parts) >= 1 {
accessToken = parts[0]
}
}
// Claude Code specific headers - force override
req.Set("Authorization", "Bearer "+accessToken)
// 只有在没有设置的情况下才设置 anthropic-version
if req.Get("anthropic-version") == "" {
req.Set("anthropic-version", "2023-06-01")
}
req.Set("content-type", "application/json")
// 只有在 user-agent 不包含 claude-cli 时才设置
userAgent := req.Get("user-agent")
if userAgent == "" || !strings.Contains(strings.ToLower(userAgent), "claude-cli") {
req.Set("user-agent", "claude-cli/1.0.61 (external, cli)")
}
// 只有在 anthropic-beta 不包含 claude-code 时才设置
anthropicBeta := req.Get("anthropic-beta")
if anthropicBeta == "" || !strings.Contains(strings.ToLower(anthropicBeta), "claude-code") {
req.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14")
}
// if Anthropic-Dangerous-Direct-Browser-Access
anthropicDangerousDirectBrowserAccess := req.Get("anthropic-dangerous-direct-browser-access")
if anthropicDangerousDirectBrowserAccess == "" {
req.Set("anthropic-dangerous-direct-browser-access", "true")
}
return nil
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
if a.RequestMode == RequestModeCompletion {
return claude.RequestOpenAI2ClaudeComplete(*request), nil
} else {
claudeRequest, err := claude.RequestOpenAI2ClaudeMessage(*request)
if err != nil {
return nil, err
}
// Use configured system prompt if available, otherwise use default
if info.ChannelSetting.SystemPrompt != "" {
claudeRequest.System = info.ChannelSetting.SystemPrompt
} else {
claudeRequest.System = DefaultSystemPrompt
}
return claudeRequest, nil
}
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, nil
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = claude.ClaudeStreamHandler(c, resp, info, a.RequestMode)
} else {
err, usage = claude.ClaudeHandler(c, resp, a.RequestMode, info)
}
return
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}
package claude_code
var ModelList = []string{
"claude-3-5-haiku-20241022",
"claude-3-5-sonnet-20241022",
"claude-3-7-sonnet-20250219",
"claude-3-7-sonnet-20250219-thinking",
"claude-sonnet-4-20250514",
"claude-sonnet-4-20250514-thinking",
"claude-opus-4-20250514",
"claude-opus-4-20250514-thinking",
}
var ChannelName = "claude_code"
package claude_code
// Claude Code uses the same DTO structures as Claude since it's based on the same API
// This file is kept for consistency with the channel structure pattern
\ No newline at end of file
package gemini package gemini
import "encoding/json" import (
"encoding/json"
"one-api/common"
)
type GeminiChatRequest struct { type GeminiChatRequest struct {
Contents []GeminiChatContent `json:"contents"` Contents []GeminiChatContent `json:"contents"`
...@@ -32,7 +35,7 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { ...@@ -32,7 +35,7 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
MimeTypeSnake string `json:"mime_type"` MimeTypeSnake string `json:"mime_type"`
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := common.Unmarshal(data, &aux); err != nil {
return err return err
} }
...@@ -93,7 +96,7 @@ func (p *GeminiPart) UnmarshalJSON(data []byte) error { ...@@ -93,7 +96,7 @@ func (p *GeminiPart) UnmarshalJSON(data []byte) error {
InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := common.Unmarshal(data, &aux); err != nil {
return err return err
} }
......
...@@ -184,8 +184,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo ...@@ -184,8 +184,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if err != nil { if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
} }
if simpleResponse.Error != nil && simpleResponse.Error.Type != "" { if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode) return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
} }
forceFormat := false forceFormat := false
......
...@@ -28,8 +28,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http ...@@ -28,8 +28,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
if err != nil { if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
} }
if responsesResponse.Error != nil { if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode) return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
} }
// 写入新的 response body // 写入新的 response body
......
...@@ -9,6 +9,7 @@ import ( ...@@ -9,6 +9,7 @@ import (
"one-api/relay/channel/baidu" "one-api/relay/channel/baidu"
"one-api/relay/channel/baidu_v2" "one-api/relay/channel/baidu_v2"
"one-api/relay/channel/claude" "one-api/relay/channel/claude"
"one-api/relay/channel/claude_code"
"one-api/relay/channel/cloudflare" "one-api/relay/channel/cloudflare"
"one-api/relay/channel/cohere" "one-api/relay/channel/cohere"
"one-api/relay/channel/coze" "one-api/relay/channel/coze"
...@@ -98,6 +99,8 @@ func GetAdaptor(apiType int) channel.Adaptor { ...@@ -98,6 +99,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &coze.Adaptor{} return &coze.Adaptor{}
case constant.APITypeJimeng: case constant.APITypeJimeng:
return &jimeng.Adaptor{} return &jimeng.Adaptor{}
case constant.APITypeClaudeCode:
return &claude_code.Adaptor{}
} }
return nil return nil
} }
......
...@@ -120,6 +120,9 @@ func SetApiRouter(router *gin.Engine) { ...@@ -120,6 +120,9 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.GET("/tag/models", controller.GetTagModels)
channelRoute.POST("/copy/:id", controller.CopyChannel) channelRoute.POST("/copy/:id", controller.CopyChannel)
// Claude OAuth路由
channelRoute.GET("/claude/oauth/url", controller.GenerateClaudeOAuthURL)
channelRoute.POST("/claude/oauth/exchange", controller.ExchangeClaudeOAuthCode)
} }
tokenRoute := apiRouter.Group("/token") tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth()) tokenRoute.Use(middleware.UserAuth())
......
package service
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/oauth2"
)
const (
// Default OAuth configuration values
DefaultAuthorizeURL = "https://claude.ai/oauth/authorize"
DefaultTokenURL = "https://console.anthropic.com/v1/oauth/token"
DefaultClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
DefaultRedirectURI = "https://console.anthropic.com/oauth/code/callback"
DefaultScopes = "user:inference"
)
// getOAuthValues returns OAuth configuration values from environment variables or defaults
func getOAuthValues() (authorizeURL, tokenURL, clientID, redirectURI, scopes string) {
authorizeURL = os.Getenv("CLAUDE_AUTHORIZE_URL")
if authorizeURL == "" {
authorizeURL = DefaultAuthorizeURL
}
tokenURL = os.Getenv("CLAUDE_TOKEN_URL")
if tokenURL == "" {
tokenURL = DefaultTokenURL
}
clientID = os.Getenv("CLAUDE_CLIENT_ID")
if clientID == "" {
clientID = DefaultClientID
}
redirectURI = os.Getenv("CLAUDE_REDIRECT_URI")
if redirectURI == "" {
redirectURI = DefaultRedirectURI
}
scopes = os.Getenv("CLAUDE_SCOPES")
if scopes == "" {
scopes = DefaultScopes
}
return
}
type OAuth2Credentials struct {
AuthURL string `json:"auth_url"`
CodeVerifier string `json:"code_verifier"`
State string `json:"state"`
CodeChallenge string `json:"code_challenge"`
}
// GetClaudeOAuthConfig returns the Claude OAuth2 configuration
func GetClaudeOAuthConfig() *oauth2.Config {
authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues()
return &oauth2.Config{
ClientID: clientID,
RedirectURL: redirectURI,
Scopes: strings.Split(scopes, " "),
Endpoint: oauth2.Endpoint{
AuthURL: authorizeURL,
TokenURL: tokenURL,
},
}
}
// getOAuthConfig is kept for backward compatibility
func getOAuthConfig() *oauth2.Config {
return GetClaudeOAuthConfig()
}
// GenerateOAuthParams generates OAuth authorization URL and related parameters
func GenerateOAuthParams() (*OAuth2Credentials, error) {
config := getOAuthConfig()
// Generate PKCE parameters
codeVerifier := oauth2.GenerateVerifier()
state := oauth2.GenerateVerifier() // Reuse generator as state
// Generate authorization URL
authURL := config.AuthCodeURL(state,
oauth2.S256ChallengeOption(codeVerifier),
oauth2.SetAuthURLParam("code", "true"), // Claude-specific parameter
)
return &OAuth2Credentials{
AuthURL: authURL,
CodeVerifier: codeVerifier,
State: state,
CodeChallenge: oauth2.S256ChallengeFromVerifier(codeVerifier),
}, nil
}
// ExchangeCode
func ExchangeCode(authorizationCode, codeVerifier, state string, client *http.Client) (*oauth2.Token, error) {
config := getOAuthConfig()
if strings.Contains(authorizationCode, "#") {
parts := strings.Split(authorizationCode, "#")
if len(parts) > 0 {
authorizationCode = parts[0]
}
}
ctx := context.Background()
if client != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
}
token, err := config.Exchange(ctx, authorizationCode,
oauth2.VerifierOption(codeVerifier),
oauth2.SetAuthURLParam("state", state),
)
if err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
return token, nil
}
func ParseAuthorizationCode(input string) (string, error) {
if input == "" {
return "", fmt.Errorf("please provide a valid authorization code")
}
// URLs are not allowed
if strings.Contains(input, "http") || strings.Contains(input, "https") {
return "", fmt.Errorf("authorization code cannot contain URLs")
}
return input, nil
}
// GetClaudeHTTPClient returns a configured HTTP client for Claude OAuth operations
func GetClaudeHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
}
}
// RefreshClaudeToken refreshes a Claude OAuth token using the refresh token
func RefreshClaudeToken(accessToken, refreshToken string) (*oauth2.Token, error) {
config := GetClaudeOAuthConfig()
// Create token from current values
currentToken := &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
}
ctx := context.Background()
if client := GetClaudeHTTPClient(); client != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
}
// Refresh the token
newToken, err := config.TokenSource(ctx, currentToken).Token()
if err != nil {
return nil, fmt.Errorf("failed to refresh Claude token: %w", err)
}
return newToken, nil
}
package service
import (
"fmt"
"one-api/common"
"one-api/constant"
"one-api/model"
"strings"
"time"
"github.com/bytedance/gopkg/util/gopool"
)
// StartClaudeTokenRefreshScheduler starts the scheduled token refresh for Claude Code channels
func StartClaudeTokenRefreshScheduler() {
ticker := time.NewTicker(5 * time.Minute)
gopool.Go(func() {
defer ticker.Stop()
for range ticker.C {
RefreshClaudeCodeTokens()
}
})
common.SysLog("Claude Code token refresh scheduler started (5 minute interval)")
}
// RefreshClaudeCodeTokens refreshes tokens for all active Claude Code channels
func RefreshClaudeCodeTokens() {
var channels []model.Channel
// Get all active Claude Code channels
err := model.DB.Where("type = ? AND status = ?", constant.ChannelTypeClaudeCode, common.ChannelStatusEnabled).Find(&channels).Error
if err != nil {
common.SysError("Failed to get Claude Code channels: " + err.Error())
return
}
refreshCount := 0
for _, channel := range channels {
if refreshTokenForChannel(&channel) {
refreshCount++
}
}
if refreshCount > 0 {
common.SysLog(fmt.Sprintf("Successfully refreshed %d Claude Code channel tokens", refreshCount))
}
}
// refreshTokenForChannel attempts to refresh token for a single channel
func refreshTokenForChannel(channel *model.Channel) bool {
// Parse key in format: accesstoken|refreshtoken
if channel.Key == "" || !strings.Contains(channel.Key, "|") {
common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id))
return false
}
parts := strings.Split(channel.Key, "|")
if len(parts) < 2 {
common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id))
return false
}
accessToken := parts[0]
refreshToken := parts[1]
if refreshToken == "" {
common.SysError(fmt.Sprintf("Channel %d has empty refresh token", channel.Id))
return false
}
// Check if token needs refresh (refresh 30 minutes before expiry)
// if !shouldRefreshToken(accessToken) {
// return false
// }
// Use shared refresh function
newToken, err := RefreshClaudeToken(accessToken, refreshToken)
if err != nil {
common.SysError(fmt.Sprintf("Failed to refresh token for channel %d: %s", channel.Id, err.Error()))
return false
}
// Update channel with new tokens
newKey := fmt.Sprintf("%s|%s", newToken.AccessToken, newToken.RefreshToken)
err = model.DB.Model(channel).Update("key", newKey).Error
if err != nil {
common.SysError(fmt.Sprintf("Failed to update channel %d with new token: %s", channel.Id, err.Error()))
return false
}
common.SysLog(fmt.Sprintf("Successfully refreshed token for Claude Code channel %d (%s)", channel.Id, channel.Name))
return true
}
...@@ -188,28 +188,6 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re ...@@ -188,28 +188,6 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
return &openAIRequest, nil return &openAIRequest, nil
} }
func OpenAIErrorToClaudeError(openAIError *dto.OpenAIErrorWithStatusCode) *dto.ClaudeErrorWithStatusCode {
claudeError := dto.ClaudeError{
Type: "new_api_error",
Message: openAIError.Error.Message,
}
return &dto.ClaudeErrorWithStatusCode{
Error: claudeError,
StatusCode: openAIError.StatusCode,
}
}
func ClaudeErrorToOpenAIError(claudeError *dto.ClaudeErrorWithStatusCode) *dto.OpenAIErrorWithStatusCode {
openAIError := dto.OpenAIError{
Message: claudeError.Error.Message,
Type: "new_api_error",
}
return &dto.OpenAIErrorWithStatusCode{
Error: openAIError,
StatusCode: claudeError.StatusCode,
}
}
func generateStopBlock(index int) *dto.ClaudeResponse { func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{ return &dto.ClaudeResponse{
Type: "content_block_stop", Type: "content_block_stop",
......
...@@ -62,7 +62,7 @@ func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeError ...@@ -62,7 +62,7 @@ func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeError
text = "请求上游地址失败" text = "请求上游地址失败"
} }
} }
claudeError := dto.ClaudeError{ claudeError := types.ClaudeError{
Message: text, Message: text,
Type: "new_api_error", Type: "new_api_error",
} }
......
...@@ -13,6 +13,9 @@ var AutomaticDisableKeywords = []string{ ...@@ -13,6 +13,9 @@ var AutomaticDisableKeywords = []string{
"The security token included in the request is invalid", "The security token included in the request is invalid",
"Operation not allowed", "Operation not allowed",
"Your account is not authorized", "Your account is not authorized",
// Claude Code
"Invalid bearer token",
"OAuth authentication is currently not allowed for this endpoint",
} }
func AutomaticDisableKeywordsToString() string { func AutomaticDisableKeywordsToString() string {
......
...@@ -16,8 +16,8 @@ type OpenAIError struct { ...@@ -16,8 +16,8 @@ type OpenAIError struct {
} }
type ClaudeError struct { type ClaudeError struct {
Message string `json:"message,omitempty"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Message string `json:"message,omitempty"`
} }
type ErrorType string type ErrorType string
......
...@@ -159,6 +159,14 @@ export const CHANNEL_OPTIONS = [ ...@@ -159,6 +159,14 @@ export const CHANNEL_OPTIONS = [
color: 'purple', color: 'purple',
label: 'Vidu', label: 'Vidu',
}, },
{
value: 53,
color: 'indigo',
label: 'Claude Code',
},
]; ];
export const MODEL_TABLE_PAGE_SIZE = 10; export const MODEL_TABLE_PAGE_SIZE = 10;
// Claude Code 相关常量
export const CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
...@@ -367,6 +367,7 @@ export function getChannelIcon(channelType) { ...@@ -367,6 +367,7 @@ export function getChannelIcon(channelType) {
return <Ollama size={iconSize} />; return <Ollama size={iconSize} />;
case 14: // Anthropic Claude case 14: // Anthropic Claude
case 33: // AWS Claude case 33: // AWS Claude
case 53: // Claude Code
return <Claude.Color size={iconSize} />; return <Claude.Color size={iconSize} />;
case 41: // Vertex AI case 41: // Vertex AI
return <Gemini.Color size={iconSize} />; return <Gemini.Color size={iconSize} />;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment