mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-11 12:13:09 +08:00
feat(provider): support chat completions for openai providers
This commit is contained in:
@@ -108,6 +108,8 @@ clawgo provider login codex --manual
|
|||||||
- 额度或限流失败时自动切到 OAuth 账号池
|
- 额度或限流失败时自动切到 OAuth 账号池
|
||||||
- 仍保留多账号轮换和后台刷新
|
- 仍保留多账号轮换和后台刷新
|
||||||
|
|
||||||
|
如果某个 OpenAI 兼容服务商只支持 `POST /v1/chat/completions`,可以在对应 provider 配置里设置 `responses.api: "chat_completions"`;默认值是 `responses`。
|
||||||
|
|
||||||
### 4. 启动
|
### 4. 启动
|
||||||
|
|
||||||
交互模式:
|
交互模式:
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ If you have both an `API key` and OAuth accounts for the same upstream, prefer c
|
|||||||
- the provider runtime panel shows current candidate ordering, the most recent successful credential, and recent hit/error history
|
- the provider runtime panel shows current candidate ordering, the most recent successful credential, and recent hit/error history
|
||||||
- to persist runtime history across restarts, configure `runtime_persist`, `runtime_history_file`, and `runtime_history_max` on the provider
|
- to persist runtime history across restarts, configure `runtime_persist`, `runtime_history_file`, and `runtime_history_max` on the provider
|
||||||
|
|
||||||
|
If an OpenAI-compatible provider only supports `POST /v1/chat/completions`, set `responses.api: "chat_completions"` on that provider. The default remains `responses`.
|
||||||
|
|
||||||
### 4. Start
|
### 4. Start
|
||||||
|
|
||||||
Interactive mode:
|
Interactive mode:
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
@@ -208,6 +209,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
@@ -280,6 +284,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
@@ -306,6 +311,7 @@
|
|||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"api": "responses",
|
||||||
"web_search_enabled": false,
|
"web_search_enabled": false,
|
||||||
"web_search_context_size": "",
|
"web_search_context_size": "",
|
||||||
"file_search_vector_store_ids": [],
|
"file_search_vector_store_ids": [],
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ type ProviderOAuthConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProviderResponsesConfig struct {
|
type ProviderResponsesConfig struct {
|
||||||
|
API string `json:"api,omitempty"`
|
||||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||||
WebSearchContextSize string `json:"web_search_context_size"`
|
WebSearchContextSize string `json:"web_search_context_size"`
|
||||||
FileSearchVectorStoreIDs []string `json:"file_search_vector_store_ids"`
|
FileSearchVectorStoreIDs []string `json:"file_search_vector_store_ids"`
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ func TestNormalizedViewProjectsCoreAndRuntime(t *testing.T) {
|
|||||||
MaxTokens: 12288,
|
MaxTokens: 12288,
|
||||||
Temperature: 0.35,
|
Temperature: 0.35,
|
||||||
TimeoutSec: 90,
|
TimeoutSec: 90,
|
||||||
|
Responses: ProviderResponsesConfig{
|
||||||
|
API: "chat_completions",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
cfg.Agents.Subagents["coder"] = SubagentConfig{
|
cfg.Agents.Subagents["coder"] = SubagentConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -40,4 +43,7 @@ func TestNormalizedViewProjectsCoreAndRuntime(t *testing.T) {
|
|||||||
if got := view.Runtime.Providers["openai"].Temperature; got != 0.35 {
|
if got := view.Runtime.Providers["openai"].Temperature; got != 0.35 {
|
||||||
t.Fatalf("expected provider temperature in normalized runtime view, got %v", got)
|
t.Fatalf("expected provider temperature in normalized runtime view, got %v", got)
|
||||||
}
|
}
|
||||||
|
if got := view.Runtime.Providers["openai"].Responses.API; got != "chat_completions" {
|
||||||
|
t.Fatalf("expected provider responses.api in normalized runtime view, got %q", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,6 +515,13 @@ func validateProviderConfig(path string, p ProviderConfig) []error {
|
|||||||
if p.OAuth.CooldownSec < 0 {
|
if p.OAuth.CooldownSec < 0 {
|
||||||
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
|
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
|
||||||
}
|
}
|
||||||
|
if p.Responses.API != "" {
|
||||||
|
switch strings.TrimSpace(p.Responses.API) {
|
||||||
|
case "responses", "chat_completions":
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Errorf("%s.responses.api must be one of: responses, chat_completions", path))
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.Responses.WebSearchContextSize != "" {
|
if p.Responses.WebSearchContextSize != "" {
|
||||||
switch p.Responses.WebSearchContextSize {
|
switch p.Responses.WebSearchContextSize {
|
||||||
case "low", "medium", "high":
|
case "low", "medium", "high":
|
||||||
|
|||||||
@@ -247,3 +247,27 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
|
|||||||
t.Fatalf("expected oauth.provider validation error, got %v", errs)
|
t.Fatalf("expected oauth.provider validation error, got %v", errs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateProviderResponsesAPIRejectsUnknownValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
pc := cfg.Models.Providers["openai"]
|
||||||
|
pc.Responses.API = "legacy"
|
||||||
|
cfg.Models.Providers["openai"] = pc
|
||||||
|
|
||||||
|
errs := Validate(cfg)
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatalf("expected validation errors")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, err := range errs {
|
||||||
|
if strings.Contains(err.Error(), "models.providers.openai.responses.api") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected responses.api validation error, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type HTTPProvider struct {
|
|||||||
apiBase string
|
apiBase string
|
||||||
defaultModel string
|
defaultModel string
|
||||||
supportsResponsesCompact bool
|
supportsResponsesCompact bool
|
||||||
|
responsesAPI string
|
||||||
authMode string
|
authMode string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -48,6 +49,7 @@ func NewHTTPProvider(providerName, apiKey, apiBase, defaultModel string, support
|
|||||||
apiBase: normalizedBase,
|
apiBase: normalizedBase,
|
||||||
defaultModel: strings.TrimSpace(defaultModel),
|
defaultModel: strings.TrimSpace(defaultModel),
|
||||||
supportsResponsesCompact: supportsResponsesCompact,
|
supportsResponsesCompact: supportsResponsesCompact,
|
||||||
|
responsesAPI: "responses",
|
||||||
authMode: authMode,
|
authMode: authMode,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
httpClient: &http.Client{Timeout: timeout},
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
@@ -79,7 +81,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
|||||||
if !json.Valid(body) {
|
if !json.Valid(body) {
|
||||||
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body))
|
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body))
|
||||||
}
|
}
|
||||||
if p.useOpenAICompatChatUpstream() {
|
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
|
||||||
return parseOpenAICompatResponse(body)
|
return parseOpenAICompatResponse(body)
|
||||||
}
|
}
|
||||||
return parseResponsesAPIResponse(body)
|
return parseResponsesAPIResponse(body)
|
||||||
@@ -102,7 +104,7 @@ func (p *HTTPProvider) ChatStream(ctx context.Context, messages []Message, tools
|
|||||||
if !json.Valid(body) {
|
if !json.Valid(body) {
|
||||||
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", status, ctype, previewResponseBody(body))
|
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", status, ctype, previewResponseBody(body))
|
||||||
}
|
}
|
||||||
if p.useOpenAICompatChatUpstream() {
|
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
|
||||||
return parseOpenAICompatResponse(body)
|
return parseOpenAICompatResponse(body)
|
||||||
}
|
}
|
||||||
return parseResponsesAPIResponse(body)
|
return parseResponsesAPIResponse(body)
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ func (p *HTTPProvider) useOpenAICompatChatUpstream() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProvider) useConfiguredOpenAICompatChat() bool {
|
||||||
|
if p == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(p.responsesAPI)) {
|
||||||
|
case "chat_completions":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) compatBase() string {
|
func (p *HTTPProvider) compatBase() string {
|
||||||
switch p.oauthProvider() {
|
switch p.oauthProvider() {
|
||||||
case defaultQwenOAuthProvider:
|
case defaultQwenOAuthProvider:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -180,3 +181,37 @@ func TestBuildOpenAICompatChatRequestStripsKimiPrefixAndSuffix(t *testing.T) {
|
|||||||
t.Fatalf("reasoning_effort = %#v, want auto", got)
|
t.Fatalf("reasoning_effort = %#v, want auto", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPProviderChatUsesConfiguredChatCompletionsAPI(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello from chat"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewHTTPProvider("openai", "token", server.URL+"/v1", "gpt-5", false, "api_key", 5*time.Second, nil)
|
||||||
|
provider.responsesAPI = "chat_completions"
|
||||||
|
|
||||||
|
resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-5", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat error: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/chat/completions" {
|
||||||
|
t.Fatalf("path = %q, want /v1/chat/completions", gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["model"] != "gpt-5" {
|
||||||
|
t.Fatalf("model = %#v, want gpt-5", gotBody["model"])
|
||||||
|
}
|
||||||
|
if resp.Content != "hello from chat" {
|
||||||
|
t.Fatalf("content = %q, want hello from chat", resp.Content)
|
||||||
|
}
|
||||||
|
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
|
||||||
|
t.Fatalf("usage = %#v, want total_tokens=3", resp.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error)
|
|||||||
if oauthProvider == defaultIFlowOAuthProvider || strings.EqualFold(routeName, defaultIFlowOAuthProvider) {
|
if oauthProvider == defaultIFlowOAuthProvider || strings.EqualFold(routeName, defaultIFlowOAuthProvider) {
|
||||||
return NewIFlowProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil
|
return NewIFlowProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil
|
||||||
}
|
}
|
||||||
return NewHTTPProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil
|
provider := NewHTTPProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth)
|
||||||
|
if api := strings.TrimSpace(pc.Responses.API); api != "" {
|
||||||
|
provider.responsesAPI = api
|
||||||
|
}
|
||||||
|
return provider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProviderSupportsResponsesCompact(cfg *config.Config, name string) bool {
|
func ProviderSupportsResponsesCompact(cfg *config.Config, name string) bool {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (p *HTTPProvider) callResponses(ctx context.Context, messages []Message, to
|
|||||||
if prevID, ok := stringOption(options, "responses_previous_response_id"); ok && prevID != "" {
|
if prevID, ok := stringOption(options, "responses_previous_response_id"); ok && prevID != "" {
|
||||||
requestBody["previous_response_id"] = prevID
|
requestBody["previous_response_id"] = prevID
|
||||||
}
|
}
|
||||||
if p.useOpenAICompatChatUpstream() {
|
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
|
||||||
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
|
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
|
||||||
return p.postJSON(ctx, endpointFor(p.compatBase(), "/chat/completions"), chatBody)
|
return p.postJSON(ctx, endpointFor(p.compatBase(), "/chat/completions"), chatBody)
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ func (p *HTTPProvider) callResponsesStream(ctx context.Context, messages []Messa
|
|||||||
if streamOpts, ok := mapOption(options, "responses_stream_options"); ok && len(streamOpts) > 0 {
|
if streamOpts, ok := mapOption(options, "responses_stream_options"); ok && len(streamOpts) > 0 {
|
||||||
requestBody["stream_options"] = streamOpts
|
requestBody["stream_options"] = streamOpts
|
||||||
}
|
}
|
||||||
if p.useOpenAICompatChatUpstream() {
|
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
|
||||||
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
|
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
|
||||||
chatBody["stream"] = true
|
chatBody["stream"] = true
|
||||||
streamOptions := map[string]interface{}{"include_usage": true}
|
streamOptions := map[string]interface{}{"include_usage": true}
|
||||||
|
|||||||
Reference in New Issue
Block a user