2026-04-17 04:42:03 +00:00
|
|
|
package oauthprovider
|
2026-02-11 19:27:59 +00:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-02-16 03:46:02 +00:00
|
|
|
"errors"
|
2026-02-11 19:27:59 +00:00
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-02-11 19:39:19 +00:00
|
|
|
"github.com/openai/openai-go/v3"
|
|
|
|
|
"github.com/openai/openai-go/v3/option"
|
|
|
|
|
"github.com/openai/openai-go/v3/responses"
|
2026-02-18 19:48:23 +00:00
|
|
|
|
2026-02-11 19:27:59 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/auth"
|
2026-02-16 03:46:02 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-03-28 12:50:24 +00:00
|
|
|
orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common"
|
2026-02-11 19:27:59 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
const (
|
2026-03-12 08:10:29 +00:00
|
|
|
codexDefaultModel = "gpt-5.3-codex"
|
2026-02-18 19:48:23 +00:00
|
|
|
codexDefaultInstructions = "You are Codex, a coding assistant."
|
|
|
|
|
)
|
2026-02-16 03:46:02 +00:00
|
|
|
|
2026-02-11 19:27:59 +00:00
|
|
|
type CodexProvider struct {
|
2026-02-18 08:30:30 +00:00
|
|
|
client *openai.Client
|
|
|
|
|
accountID string
|
|
|
|
|
tokenSource func() (string, string, error)
|
|
|
|
|
enableWebSearch bool
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 04:48:16 +00:00
|
|
|
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
|
|
|
|
|
2026-02-11 19:27:59 +00:00
|
|
|
func NewCodexProvider(token, accountID string) *CodexProvider {
|
|
|
|
|
opts := []option.RequestOption{
|
|
|
|
|
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
|
|
|
|
option.WithAPIKey(token),
|
2026-02-16 03:46:02 +00:00
|
|
|
option.WithHeader("originator", "codex_cli_rs"),
|
|
|
|
|
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
if accountID != "" {
|
|
|
|
|
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
|
|
|
|
}
|
|
|
|
|
client := openai.NewClient(opts...)
|
|
|
|
|
return &CodexProvider{
|
2026-02-18 08:30:30 +00:00
|
|
|
client: &client,
|
|
|
|
|
accountID: accountID,
|
|
|
|
|
enableWebSearch: true,
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
func NewCodexProviderWithTokenSource(
|
|
|
|
|
token, accountID string, tokenSource func() (string, string, error),
|
|
|
|
|
) *CodexProvider {
|
2026-02-11 19:27:59 +00:00
|
|
|
p := NewCodexProvider(token, accountID)
|
|
|
|
|
p.tokenSource = tokenSource
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
func (p *CodexProvider) Chat(
|
|
|
|
|
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
|
|
|
|
) (*LLMResponse, error) {
|
2026-02-11 19:27:59 +00:00
|
|
|
var opts []option.RequestOption
|
2026-02-16 03:46:02 +00:00
|
|
|
accountID := p.accountID
|
|
|
|
|
resolvedModel, fallbackReason := resolveCodexModel(model)
|
|
|
|
|
if fallbackReason != "" {
|
2026-02-18 19:48:23 +00:00
|
|
|
logger.WarnCF(
|
|
|
|
|
"provider.codex",
|
|
|
|
|
"Requested model is not compatible with Codex backend, using fallback",
|
|
|
|
|
map[string]any{
|
|
|
|
|
"requested_model": model,
|
|
|
|
|
"resolved_model": resolvedModel,
|
|
|
|
|
"reason": fallbackReason,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-16 03:46:02 +00:00
|
|
|
}
|
2026-02-11 19:27:59 +00:00
|
|
|
if p.tokenSource != nil {
|
|
|
|
|
tok, accID, err := p.tokenSource()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
|
|
|
}
|
|
|
|
|
opts = append(opts, option.WithAPIKey(tok))
|
|
|
|
|
if accID != "" {
|
2026-02-16 03:46:02 +00:00
|
|
|
accountID = accID
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-16 03:46:02 +00:00
|
|
|
if accountID != "" {
|
|
|
|
|
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
|
|
|
|
} else {
|
2026-02-18 19:48:23 +00:00
|
|
|
logger.WarnCF(
|
|
|
|
|
"provider.codex",
|
|
|
|
|
"No account id found for Codex request; backend may reject with 400",
|
|
|
|
|
map[string]any{
|
|
|
|
|
"requested_model": model,
|
|
|
|
|
"resolved_model": resolvedModel,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-16 03:46:02 +00:00
|
|
|
}
|
2026-02-11 19:27:59 +00:00
|
|
|
|
2026-03-18 03:55:30 +00:00
|
|
|
// Respect tools.web.prefer_native: only inject native search when the agent
|
2026-03-28 12:50:24 +00:00
|
|
|
// loop passes options["native_search"]=true, so prefer_native=false means no injection.
|
2026-03-18 03:55:30 +00:00
|
|
|
useNativeSearch := p.enableWebSearch && (options["native_search"] == true)
|
|
|
|
|
params := buildCodexParams(messages, tools, resolvedModel, options, useNativeSearch)
|
2026-02-11 19:27:59 +00:00
|
|
|
|
2026-02-16 03:46:02 +00:00
|
|
|
stream := p.client.Responses.NewStreaming(ctx, params, opts...)
|
|
|
|
|
defer stream.Close()
|
|
|
|
|
|
|
|
|
|
var resp *responses.Response
|
|
|
|
|
for stream.Next() {
|
|
|
|
|
evt := stream.Current()
|
|
|
|
|
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
|
|
|
|
|
evtResp := evt.Response
|
|
|
|
|
if evtResp.ID != "" {
|
2026-02-25 10:08:35 +00:00
|
|
|
evtRespCopy := evtResp
|
|
|
|
|
resp = &evtRespCopy
|
2026-02-16 03:46:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
err := stream.Err()
|
2026-02-11 19:27:59 +00:00
|
|
|
if err != nil {
|
2026-02-18 19:48:23 +00:00
|
|
|
fields := map[string]any{
|
2026-02-16 03:46:02 +00:00
|
|
|
"requested_model": model,
|
|
|
|
|
"resolved_model": resolvedModel,
|
|
|
|
|
"messages_count": len(messages),
|
|
|
|
|
"tools_count": len(tools),
|
|
|
|
|
"account_id_present": accountID != "",
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
}
|
|
|
|
|
var apiErr *openai.Error
|
|
|
|
|
if errors.As(err, &apiErr) {
|
|
|
|
|
fields["status_code"] = apiErr.StatusCode
|
|
|
|
|
fields["api_type"] = apiErr.Type
|
|
|
|
|
fields["api_code"] = apiErr.Code
|
|
|
|
|
fields["api_param"] = apiErr.Param
|
|
|
|
|
fields["api_message"] = apiErr.Message
|
|
|
|
|
if apiErr.StatusCode == 400 {
|
|
|
|
|
fields["hint"] = "verify account id header and model compatibility for codex backend"
|
|
|
|
|
}
|
|
|
|
|
if apiErr.Response != nil {
|
|
|
|
|
fields["request_id"] = apiErr.Response.Header.Get("x-request-id")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
logger.ErrorCF("provider.codex", "Codex API call failed", fields)
|
2026-02-11 19:27:59 +00:00
|
|
|
return nil, fmt.Errorf("codex API call: %w", err)
|
|
|
|
|
}
|
2026-02-16 03:46:02 +00:00
|
|
|
if resp == nil {
|
2026-02-18 19:48:23 +00:00
|
|
|
fields := map[string]any{
|
2026-02-16 03:46:02 +00:00
|
|
|
"requested_model": model,
|
|
|
|
|
"resolved_model": resolvedModel,
|
|
|
|
|
"messages_count": len(messages),
|
|
|
|
|
"tools_count": len(tools),
|
|
|
|
|
"account_id_present": accountID != "",
|
|
|
|
|
}
|
|
|
|
|
logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
|
|
|
|
|
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
|
|
|
|
}
|
2026-02-11 19:27:59 +00:00
|
|
|
|
2026-03-28 12:50:24 +00:00
|
|
|
return orc.ParseResponseFromStruct(resp), nil
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *CodexProvider) GetDefaultModel() string {
|
2026-02-16 03:46:02 +00:00
|
|
|
return codexDefaultModel
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 03:55:30 +00:00
|
|
|
func (p *CodexProvider) SupportsNativeSearch() bool {
|
|
|
|
|
return p.enableWebSearch
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 03:46:02 +00:00
|
|
|
func resolveCodexModel(model string) (string, string) {
|
|
|
|
|
m := strings.ToLower(strings.TrimSpace(model))
|
|
|
|
|
if m == "" {
|
|
|
|
|
return codexDefaultModel, "empty model"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 08:35:07 +00:00
|
|
|
if after, ok := strings.CutPrefix(m, "openai/"); ok {
|
|
|
|
|
m = after
|
2026-02-16 03:46:02 +00:00
|
|
|
} else if strings.Contains(m, "/") {
|
|
|
|
|
return codexDefaultModel, "non-openai model namespace"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unsupportedPrefixes := []string{
|
|
|
|
|
"glm",
|
|
|
|
|
"claude",
|
|
|
|
|
"anthropic",
|
|
|
|
|
"gemini",
|
|
|
|
|
"google",
|
|
|
|
|
"moonshot",
|
|
|
|
|
"kimi",
|
|
|
|
|
"qwen",
|
|
|
|
|
"deepseek",
|
|
|
|
|
"llama",
|
|
|
|
|
"meta-llama",
|
|
|
|
|
"mistral",
|
|
|
|
|
"grok",
|
|
|
|
|
"xai",
|
|
|
|
|
"zhipu",
|
|
|
|
|
}
|
|
|
|
|
for _, prefix := range unsupportedPrefixes {
|
|
|
|
|
if strings.HasPrefix(m, prefix) {
|
|
|
|
|
return codexDefaultModel, "unsupported model prefix"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") {
|
|
|
|
|
return m, ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return codexDefaultModel, "unsupported model family"
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
func buildCodexParams(
|
|
|
|
|
messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool,
|
|
|
|
|
) responses.ResponseNewParams {
|
2026-03-28 12:50:24 +00:00
|
|
|
inputItems, instructions := orc.TranslateMessages(messages)
|
2026-02-11 19:27:59 +00:00
|
|
|
|
|
|
|
|
params := responses.ResponseNewParams{
|
|
|
|
|
Model: model,
|
|
|
|
|
Input: responses.ResponseNewParamsInputUnion{
|
|
|
|
|
OfInputItemList: inputItems,
|
|
|
|
|
},
|
2026-03-28 12:50:24 +00:00
|
|
|
Store: openai.Opt(false),
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if instructions != "" {
|
|
|
|
|
params.Instructions = openai.Opt(instructions)
|
2026-02-14 04:48:16 +00:00
|
|
|
} else {
|
|
|
|
|
// ChatGPT Codex backend requires instructions to be present.
|
|
|
|
|
params.Instructions = openai.Opt(defaultCodexInstructions)
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
|
|
|
|
// and reuse prefix KV cache across calls with the same key.
|
|
|
|
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
|
|
|
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
|
|
|
|
params.PromptCacheKey = openai.Opt(cacheKey)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 08:30:30 +00:00
|
|
|
if len(tools) > 0 || enableWebSearch {
|
2026-03-28 12:50:24 +00:00
|
|
|
params.Tools = orc.TranslateTools(tools, enableWebSearch)
|
2026-02-11 19:27:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return params
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 04:42:03 +00:00
|
|
|
func CreateCodexTokenSource() func() (string, string, error) {
|
2026-02-11 19:27:59 +00:00
|
|
|
return func() (string, string, error) {
|
|
|
|
|
cred, err := auth.GetCredential("openai")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if cred == nil {
|
|
|
|
|
return "", "", fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" {
|
|
|
|
|
oauthCfg := auth.OpenAIOAuthConfig()
|
|
|
|
|
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", fmt.Errorf("refreshing token: %w", err)
|
|
|
|
|
}
|
2026-02-16 03:46:02 +00:00
|
|
|
if refreshed.AccountID == "" {
|
|
|
|
|
refreshed.AccountID = cred.AccountID
|
|
|
|
|
}
|
2026-02-11 19:27:59 +00:00
|
|
|
if err := auth.SetCredential("openai", refreshed); err != nil {
|
|
|
|
|
return "", "", fmt.Errorf("saving refreshed token: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return refreshed.AccessToken, refreshed.AccountID, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cred.AccessToken, cred.AccountID, nil
|
|
|
|
|
}
|
|
|
|
|
}
|