-
Notifications
You must be signed in to change notification settings - Fork 163
feat(llm): support OpenAI Codex endpoints #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| // Package llm provides LLM client interfaces supporting multiple protocols. | ||
| // Supported protocols: Anthropic Messages API, OpenAI Chat Completions API. | ||
| // Supported protocols: Anthropic Messages API, OpenAI Chat Completions API, | ||
| // and OpenAI Responses API. | ||
| package llm | ||
|
|
||
| import ( | ||
|
|
@@ -197,7 +198,8 @@ type ClientConfig struct { | |
| // --- Factory --- | ||
|
|
||
| // NewLLMClient creates the appropriate client based on the resolved endpoint protocol. | ||
| // protocol: "anthropic" -> AnthropicClient, anything else -> OpenAIClient. | ||
| // protocol: "anthropic" -> AnthropicClient; OpenAI /responses URLs -> OpenAIResponsesClient; | ||
| // anything else -> OpenAIClient. | ||
| func NewLLMClient(ep ResolvedEndpoint) LLMClient { | ||
| cfg := ClientConfig{ | ||
| URL: ep.URL, | ||
|
|
@@ -208,6 +210,9 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient { | |
| if ep.Protocol == "anthropic" { | ||
| return NewAnthropicClient(cfg) | ||
| } | ||
| if isResponsesEndpoint(ep.URL) { | ||
| return NewOpenAIResponsesClient(cfg) | ||
| } | ||
| return NewOpenAIClient(cfg) | ||
| } | ||
|
|
||
|
|
@@ -270,7 +275,11 @@ func CountTokensForModel(text string, modelName string) int { | |
| func encodingForModel(modelName string) string { | ||
| lower := strings.ToLower(modelName) | ||
| switch { | ||
| case strings.Contains(lower, "o1") || strings.Contains(lower, "o3") || strings.Contains(lower, "o4"): | ||
| case strings.Contains(lower, "gpt-5") || | ||
| strings.Contains(lower, "codex") || | ||
| strings.Contains(lower, "o1") || | ||
| strings.Contains(lower, "o3") || | ||
| strings.Contains(lower, "o4"): | ||
| return "o200k_base" | ||
| default: | ||
| return "cl100k_base" | ||
|
|
@@ -307,6 +316,19 @@ func NewClient(cfg ClientConfig) *OpenAIClient { | |
| return NewOpenAIClient(cfg) | ||
| } | ||
|
|
||
| func isResponsesEndpoint(rawURL string) bool { | ||
| return strings.HasSuffix(strings.TrimRight(rawURL, "/"), "/responses") | ||
| } | ||
|
|
||
| func useMaxCompletionTokens(model string) bool { | ||
| lower := strings.ToLower(model) | ||
| return strings.Contains(lower, "gpt-5") || | ||
| strings.Contains(lower, "codex") || | ||
| strings.Contains(lower, "o1") || | ||
| strings.Contains(lower, "o3") || | ||
| strings.Contains(lower, "o4") | ||
| } | ||
|
Comment on lines
+323
to
+330
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DRY 违规: 这两个函数都维护着相同的模型名称匹配条件 ( 建议:将模型匹配逻辑提取为一个共享的判断函数,例如: func isAdvancedReasoningModel(modelName string) bool {
lower := strings.ToLower(modelName)
return strings.Contains(lower, "gpt-5") ||
strings.Contains(lower, "codex") ||
strings.Contains(lower, "o1") ||
strings.Contains(lower, "o3") ||
strings.Contains(lower, "o4")
}然后在 |
||
|
|
||
| // ChatRequest represents the payload for a chat completion call. | ||
| type ChatRequest struct { | ||
| Model string `json:"model"` | ||
|
|
@@ -370,15 +392,10 @@ func (c *OpenAIClient) StreamCompletionWithCtx(ctx context.Context, req ChatRequ | |
| } | ||
|
|
||
| return c.withRetryCtx(ctx, func() error { | ||
| body := make(map[string]any) | ||
| b, _ := json.Marshal(req) | ||
| json.Unmarshal(b, &body) | ||
| body["model"] = model | ||
| for k, v := range c.cfg.ExtraBody { | ||
| body[k] = v | ||
| payload, err := c.buildRequestPayload(model, req) | ||
| if err != nil { | ||
| return fmt.Errorf("marshal request body: %w", err) | ||
| } | ||
|
|
||
| payload, _ := json.Marshal(body) | ||
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) | ||
| if err != nil { | ||
| return fmt.Errorf("create request: %w", err) | ||
|
|
@@ -431,8 +448,7 @@ func (c *OpenAIClient) doRequestCtx(ctx context.Context, model string, req ChatR | |
| if model == "" { | ||
| model = c.cfg.Model | ||
| } | ||
| req.Model = model | ||
| payload, err := mergeExtraBody(req, c.cfg.ExtraBody) | ||
| payload, err := c.buildRequestPayload(model, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("marshal request body: %w", err) | ||
| } | ||
|
|
@@ -478,6 +494,26 @@ func (c *OpenAIClient) doRequestCtx(ctx context.Context, model string, req ChatR | |
| }, nil | ||
| } | ||
|
|
||
| func (c *OpenAIClient) buildRequestPayload(model string, req ChatRequest) ([]byte, error) { | ||
| req.Model = model | ||
| b, err := json.Marshal(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var body map[string]any | ||
| if err := json.Unmarshal(b, &body); err != nil { | ||
| return nil, err | ||
| } | ||
| if req.MaxTokens > 0 && useMaxCompletionTokens(model) { | ||
| delete(body, "max_tokens") | ||
| body["max_completion_tokens"] = req.MaxTokens | ||
| } | ||
| for k, v := range c.cfg.ExtraBody { | ||
| body[k] = v | ||
| } | ||
|
Comment on lines
+507
to
+513
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: 在此处先将 建议:在 ExtraBody 合并之后再做 for k, v := range c.cfg.ExtraBody {
body[k] = v
}
// 在 ExtraBody 合并之后再处理转换
if req.MaxTokens > 0 && useMaxCompletionTokens(model) {
delete(body, "max_tokens")
body["max_completion_tokens"] = req.MaxTokens
} |
||
| return json.Marshal(body) | ||
| } | ||
|
|
||
| // --- AnthropicClient --- | ||
|
|
||
| const anthropicVersion = "2023-06-01" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isResponsesEndpoint对含查询参数的 URL 匹配可能失败该函数使用
strings.HasSuffix检查 URL 是否以/responses结尾,但未考虑 URL 可能携带查询参数(如https://example.com/v1/responses?api-version=2024-01)或片段标识符的情况。这会导致本应路由到OpenAIResponsesClient的请求被错误地路由到OpenAIClient。建议:使用
net/url包先解析 URL,基于路径部分进行匹配: