fix: enable ChatGPT subscription (OAuth) in picoclaw
## Summary - Always use chatgpt.com/backend-api/codex for OAuth/auth_method=token providers - Handle response.output_text.delta streaming for Codex backend (fixes empty responses) - Add PICOCLAW_CODEX_HTTP_DEBUG env var for opt-in HTTP request/response logging - Simplify: remove ShouldUseCodexBackend (always returned true) ## Problem - ChatGPT Plus subscriptions use OAuth tokens, which only work on chatgpt.com backend - The Codex backend returns streaming text in response.output_text.delta events, but picoclaw was only parsing response.completed.output (which can be empty) - This caused empty responses even when the model was generating text
This commit is contained in:
parent
39dec35408
commit
d9e643f472
3 changed files with 83 additions and 4 deletions
|
|
@ -75,6 +75,7 @@ func createClaudeAuthProvider() (LLMProvider, error) {
|
|||
}
|
||||
|
||||
// createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.
|
||||
// ChatGPT subscription OAuth tokens use the Codex backend for all OpenAI models.
|
||||
func createCodexAuthProvider() (LLMProvider, error) {
|
||||
cred, err := getCredential("openai")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import (
|
|||
const (
|
||||
codexDefaultModel = "gpt-5.3-codex"
|
||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||
|
||||
codexAPIURL = "https://chatgpt.com/backend-api/codex"
|
||||
)
|
||||
|
||||
type CodexProvider struct {
|
||||
|
|
@ -30,11 +32,15 @@ type CodexProvider struct {
|
|||
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
||||
|
||||
func NewCodexProvider(token, accountID string) *CodexProvider {
|
||||
return NewCodexProviderWithOptions(token, accountID)
|
||||
}
|
||||
|
||||
func NewCodexProviderWithOptions(token, accountID string) *CodexProvider {
|
||||
opts := []option.RequestOption{
|
||||
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
||||
option.WithBaseURL(codexAPIURL),
|
||||
option.WithAPIKey(token),
|
||||
option.WithHeader("originator", "codex_cli_rs"),
|
||||
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
||||
option.WithHeader("originator", "codex_cli_rs"),
|
||||
}
|
||||
if accountID != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
|
|
@ -55,6 +61,14 @@ func NewCodexProviderWithTokenSource(
|
|||
return p
|
||||
}
|
||||
|
||||
func NewCodexProviderWithTokenSourceAndOptions(
|
||||
token, accountID string, tokenSource func() (string, string, error),
|
||||
) *CodexProvider {
|
||||
p := NewCodexProviderWithOptions(token, accountID)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *CodexProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
|
|
@ -104,9 +118,13 @@ func (p *CodexProvider) Chat(
|
|||
defer stream.Close()
|
||||
|
||||
var resp *responses.Response
|
||||
var streamedText strings.Builder
|
||||
for stream.Next() {
|
||||
evt := stream.Current()
|
||||
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
|
||||
switch evt.Type {
|
||||
case "response.output_text.delta":
|
||||
streamedText.WriteString(evt.Delta)
|
||||
case "response.completed", "response.failed", "response.incomplete":
|
||||
evtResp := evt.Response
|
||||
if evtResp.ID != "" {
|
||||
evtRespCopy := evtResp
|
||||
|
|
@ -153,7 +171,14 @@ func (p *CodexProvider) Chat(
|
|||
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
||||
}
|
||||
|
||||
return orc.ParseResponseFromStruct(resp), nil
|
||||
parsed := orc.ParseResponseFromStruct(resp)
|
||||
if parsed.Content == "" && streamedText.Len() > 0 {
|
||||
parsed.Content = streamedText.String()
|
||||
if parsed.FinishReason == "" {
|
||||
parsed.FinishReason = "stop"
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (p *CodexProvider) GetDefaultModel() string {
|
||||
|
|
|
|||
|
|
@ -432,6 +432,48 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_UsesOutputTextDeltas(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []any{},
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 4,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 5,
|
||||
"input_tokens_details": map[string]any{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeTextDeltaSSE(w, "hel")
|
||||
writeTextDeltaSSE(w, "lo")
|
||||
writeCompletedSSE(w, resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("test-token", "acc-123")
|
||||
provider.enableWebSearch = false
|
||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "Hello"}}, nil, "gpt-5.4", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "hello" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "hello")
|
||||
}
|
||||
if resp.Usage.TotalTokens != 5 {
|
||||
t.Errorf("TotalTokens = %d, want 5", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
|
|
@ -647,3 +689,14 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
|
|||
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
func writeTextDeltaSSE(w http.ResponseWriter, delta string) {
|
||||
event := map[string]any{
|
||||
"type": "response.output_text.delta",
|
||||
"delta": delta,
|
||||
}
|
||||
b, _ := json.Marshal(event)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "event: response.output_text.delta\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue