diff --git a/README.md b/README.md index cb0c4f8..012ccab 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ scay First time, scay walks you through setup right there: -1. **`/auth`** — pick a provider and paste your API key (skip this for Ollama; - keys are stored with `0600` permissions under `~/.config/scay`). +1. **`/auth`** — pick a provider and authenticate. OpenAI accepts an API key or + an experimental ChatGPT subscription login; Ollama needs neither. Credentials + are stored with `0600` permissions under `~/.config/scay`. 2. **`/model`** — pick the provider, model, and thinking effort. With Ollama, the menu lists whatever models you actually have installed. 3. **Type what you want.** Anything that isn't a `/command` goes to the model. @@ -67,7 +68,7 @@ Slash commands, inside the session: | Command | What it does | |---|---| | `/model` | Pick provider, model, and thinking effort (applies live) | -| `/auth [provider]` | Store an API key, or the host for Ollama | +| `/auth [provider]` | Authenticate; OpenAI offers API key or experimental ChatGPT login | | `/mcp` | Manage MCP servers — `add`, `list`, `remove`, `get`, `tools` | | `/compact` | Squash a long history into a summary, freeing context | | `/clear` | Start the conversation over | @@ -86,17 +87,18 @@ scay update # self-update to the latest release scay version # print the version ``` -## Providers & API keys +## Providers & authentication -| Provider | Key via `/auth` or env var | +| Provider | Authentication | |---|---| | Anthropic | `ANTHROPIC_API_KEY` | -| OpenAI | `OPENAI_API_KEY` | +| OpenAI | `OPENAI_API_KEY`, or experimental ChatGPT subscription login via `/auth openai` | | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | | Ollama (local) | no key — just `ollama serve` (`OLLAMA_HOST` to point elsewhere) | Environment variables override the config file, so CI and one-off shells work -without touching your saved setup. Responses stream on all four backends, and +without touching your saved setup. ChatGPT login relies on the Codex protocol +and is explicitly experimental. Responses stream on all four backends, and transient API failures retry automatically with backoff. ## MCP servers (optional) diff --git a/ROADMAP.md b/ROADMAP.md index 4d611b8..8961774 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # scay roadmap -> **Current state: post-v0.1.0 debloat refactor (2026-07-04), unreleased.** The separate +> **Current state: OpenAI Responses migration + experimental ChatGPT login (2026-07-14), unreleased.** The separate > TUI (`cmd/scay-tui` nested Charm module) is **gone** — one module, one binary, nothing > else to compile; the octopus mascot survives in the CLI banner. The command surface > collapsed into the interactive session: `scay` opens it (even unconfigured — it guides @@ -10,7 +10,9 @@ > new `scay update` (self-update from GitHub releases); `scay -p` one-shot mode was > removed. gofmt/vet/build/test clean; REPL flows (onboarding, /help, /model via local > Ollama, /mcp list, live turn with tool use) verified by driving the binary. **Next:** -> tag v0.2.0. **Still deferred (needs real keys):** live checks vs +> tag v0.2.0. OpenAI now uses Responses API; `/auth openai` offers either a Platform +> API key or an experimental ChatGPT/Codex browser login with renewable credentials. +> **Still deferred (needs real credentials):** live checks vs > Anthropic/OpenAI/Gemini; `scay update` needs a **public** published release to be > exercised end-to-end (the repo is currently private, so the unauthenticated GitHub > API 404s — verified the error path handles this cleanly). @@ -37,7 +39,7 @@ Legend: `[x]` done · `[~]` in progress · `[ ]` todo - [ ] **Live check (deferred post-v0.1.0, needs a real key):** `export ANTHROPIC_API_KEY=…`, `scay model` → Claude, then `scay -p "…"` on a real task ## M2 — Multi-provider -- [x] OpenAI adapter (`/v1/chat/completions` — chosen over `/v1/responses` for simplicity) +- [x] Initial OpenAI Chat Completions adapter (superseded by the M8 Responses API migration) - [x] Gemini adapter (`:generateContent`) - [x] Ollama adapter (`/api/chat`, `stream:false`) - [x] id-less tool-call matching (Gemini/Ollama, by name) + verbatim reasoning replay — mock-tested @@ -69,6 +71,14 @@ Legend: `[x]` done · `[~]` in progress · `[ ]` todo ## Post-v0.1.0 - [x] `scay model` fetches installed models live from Ollama (`GET /api/tags`, 3s timeout, resolved host incl. `OLLAMA_HOST`); curated list is now only the unreachable-server fallback. Mock-tested + verified against local Ollama and a dead port. +## M8 — OpenAI Responses + experimental ChatGPT login (2026-07-14) +- [x] OpenAI adapter migrated from Chat Completions to Responses API; messages, function calls, tool results, reasoning items, streaming completion, and usage are normalized through the existing provider types +- [x] `/auth openai` offers Platform API key or experimental ChatGPT subscription login; OAuth uses Authorization Code + PKCE with a localhost callback, tokens stay in the existing `0600` config file, and `OPENAI_API_KEY` still overrides saved credentials +- [x] Renew access tokens before expiry and retry one request after a 401; the ChatGPT/Codex backend gets its account ID and required `store:false`; changing OpenAI sign-in mode clears history because opaque reasoning replay is backend-specific +- [x] Mock tests: Responses request/response and streaming, Codex account header, one-refresh 401 recovery, credential resolution, JWT-derived account/expiry +- [x] **Live check (ChatGPT Plus/Pro):** browser login, a multi-turn text exchange, and `write_file` → `read_file` tool use through the Codex backend. Required `store:false`, omission of `max_output_tokens`, stream-item function-call handling, and assistant-message replay were discovered and covered by tests. +- [ ] **Live checks (needs a real account):** API-key tool-use task through Responses; ChatGPT Plus/Pro token refresh and a new session after token rotation. The ChatGPT route depends on the Codex protocol and remains experimental until OpenAI publishes a third-party OAuth contract. + ## M7 — Debloat: one binary, everything in-session (2026-07-04) - [x] Removed `cmd/scay-tui` (the nested Charm Go module) and all references (CI, release script, gitignore, docs); the block-art octopus moved into the CLI banner - [x] Collapsed the command surface: `auth`/`model`/`mcp` subcommands and `-p` one-shot removed; everything runs inside the interactive session as `/auth`, `/model`, `/mcp`, `/compact`, `/clear`, `/help`, `/exit` diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index a841d2b..163c4a8 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -21,9 +21,10 @@ Every dependency and abstraction must earn its weight. Concretely: 1. **Providers → hand-rolled clients.** One thin `net/http` + `encoding/json` client per provider (~150–400 LOC each). Zero deps, full control; we own the JSON and track API drift ourselves. -2. **Login → API key now, OAuth later.** API-key auth for v1, but credentials sit - behind a `Credential` interface so OAuth can be added per-provider without a - config schema break. +2. **Login → API key plus experimental ChatGPT login.** OpenAI accepts a Platform + API key or an experimental browser OAuth flow compatible with Codex. Credentials + sit behind a renewable `Credential` interface; the ChatGPT route is isolated + because OpenAI does not yet publish third-party OAuth client registration. 3. **Model selection → curated list + free-form.** A short built-in list of current models per provider, plus "type any model id". Effort from a unified ladder. 4. **Key storage → `0600` file + env.** Stored under `os.UserConfigDir()` @@ -57,16 +58,17 @@ loop never sees provider quirks; each adapter re-places them for its own API. | Provider | Endpoint | System prompt | Stream | Tool-call id | Effort knob | |-----------|---------------------------------------|------------------------|-----------|---------------------|--------------------------------------| -| OpenAI | `POST /v1/chat/completions` | a message | SSE (delta) | `tool_call_id` | `reasoning_effort` | +| OpenAI | `POST /v1/responses` (API key) or Codex backend (ChatGPT) | `instructions` | SSE (response events) | `call_id` | `reasoning.effort` | | Anthropic | `POST /v1/messages` | top-level `system` | SSE (block) | `toolu_…` | `output_config.effort` + `thinking` | | Gemini | `…/models/{m}:generateContent` | top-level `systemInstruction` | SSE (chunk) | **none — match by name** | `thinkingConfig` | | Ollama | `POST /api/chat` | a `system` message | NDJSON | **none — match by name** | `think: false\|"low".."max"` | -> **OpenAI endpoint choice:** scay targets **Chat Completions**, not the Responses -> API. It's the simplest surface to hand-roll — stable tool-call ids, a plain -> top-level `reasoning_effort`, no reasoning-item round-trip — which fits the -> debloat goal. The same client can drive Gemini's and Ollama's OpenAI-compatible -> endpoints via a `BaseURL` override if ever needed. +> **OpenAI endpoint choice:** scay targets the **Responses API**. API-key requests +> go to the public Platform endpoint; experimental ChatGPT sign-in goes to the +> Codex backend with an access token and `ChatGPT-Account-ID`. Responses has more +> round-trip plumbing than Chat Completions, but its opaque reasoning and function +> call items map cleanly onto `Block.Reasoning` and keep both auth methods behind +> one adapter. Five divergences the **adapters** absorb (never the loop): @@ -97,9 +99,10 @@ replays next request — is identical on both paths. That matters most for: reassembled into a `{type, thinking, signature}` block for verbatim replay; `input_json_delta` fragments concatenate into tool-call args; `redacted_thinking` arrives whole in `content_block_start` and is kept raw. -- **OpenAI** (`stream: true` + `stream_options.include_usage`, SSE, `[DONE]` - terminator): tool-call fragments are keyed by `index` and concatenated; the final - usage chunk has no choices. +- **OpenAI** (`stream: true`, Responses SSE): `response.output_text.delta` and + reasoning deltas render immediately; `response.completed` carries the complete + output-item list and usage. Function calls and reasoning items are replayed + verbatim on the next request. - **Gemini** (`:streamGenerateContent?alt=sse`): each frame is a chunk of parts. Parts carrying a `thoughtSignature` (and all thought/functionCall parts) are kept raw for replay; unsigned plain-text fragments are merged into one block. @@ -162,8 +165,12 @@ matches), printed unified diffs, an approval gate on write/edit/bash (bypassed b ## Config & auth `config.json` under `os.UserConfigDir()/scay`, written `0600`. Env vars override the -file. Credentials sit behind a `Credential` interface (`apiKeyCredential` today) so -OAuth is an additive change later. +file. OpenAI supports `api_key` and experimental `chatgpt` credentials; the latter +stores access token, rotating refresh token, account ID, and expiry in that protected +file. `Credential` resolves a current token at request time, refreshes once after a +401, and persists the rotated token atomically. The browser login uses Authorization +Code + PKCE on a localhost callback; it has no third-party client-registration +guarantee from OpenAI and is deliberately marked experimental. ## MCP diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 85355f0..6a5da02 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -2,18 +2,22 @@ package cli import ( "bufio" + "context" "fmt" + "os/exec" + "runtime" "slices" "strings" + "time" "github.com/robitec97/scay/internal/config" "github.com/robitec97/scay/internal/provider" ) // authCommand handles /auth: pick a provider (or name it: `/auth openai`), -// then store its API key (or host for Ollama). Key input hides the echo when -// the terminal allows; keys are stored 0600. OAuth can slot in later behind -// the same config schema. +// then store its API key (or host for Ollama). OpenAI additionally offers the +// experimental ChatGPT/Codex browser login. Key input hides the echo when the +// terminal allows; credentials are stored 0600. func authCommand(in *bufio.Reader, args []string) error { cfg, err := config.Load() if err != nil { @@ -47,6 +51,32 @@ func authCommand(in *bufio.Reader, args []string) error { host = "http://localhost:11434" } pc.Host = host + } else if pid == "openai" { + fmt.Println("OpenAI sign-in method:") + fmt.Println(" 1) API key (OpenAI Platform billing)") + fmt.Println(" 2) ChatGPT subscription (experimental)") + idx := readIndex(in, "> ", 2) + if idx < 0 { + return fmt.Errorf("cancelled") + } + if idx == 1 { + fmt.Println("Opening ChatGPT sign-in in your browser…") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := config.LoginOpenAIChatGPT(ctx, openBrowser); err != nil { + return err + } + path, _ := config.Path() + fmt.Printf("Saved OpenAI ChatGPT credentials to %s.\n", path) + return nil + } + key := readSecret(in, "Paste your openai API key: ") + if key == "" { + return fmt.Errorf("no key entered") + } + pc.AuthType = "api_key" + pc.APIKey = key + pc.OAuth = nil } else { key := readSecret(in, fmt.Sprintf("Paste your %s API key: ", pid)) if key == "" { @@ -66,3 +96,22 @@ func authCommand(in *bufio.Reader, args []string) error { fmt.Printf("Saved %s credentials to %s.\n", pid, path) return nil } + +// openBrowser starts the platform's URL launcher. A printed URL still lets the +// user proceed when a minimal terminal environment has no desktop integration. +func openBrowser(rawURL string) error { + fmt.Printf("If the browser does not open, visit:\n%s\n", rawURL) + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + cmd = exec.Command("xdg-open", rawURL) + } + if err := cmd.Start(); err != nil { + fmt.Printf("Could not open a browser automatically: %v\n", err) + } + return nil +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 849bd0a..40f8c79 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -64,8 +64,9 @@ Usage: Everything else happens inside the session — type /help there: /model /auth /mcp /compact /clear /exit -API keys are stored via /auth (0600 under your OS config dir, e.g. -~/.config/scay) or come from environment variables (ANTHROPIC_API_KEY, +API keys and the experimental OpenAI ChatGPT login are stored via /auth (0600 +under your OS config dir, e.g. ~/.config/scay). Environment variables +(ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, OLLAMA_HOST), which override the config file. `) diff --git a/internal/cli/run.go b/internal/cli/run.go index c08bb67..f7a0fa1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -23,11 +23,12 @@ var errNotConfigured = errors.New("not configured") // repl is one interactive session: the reader, the (re)buildable agent, and // the conversation. The agent is nil until a model is configured. type repl struct { - in *bufio.Reader - yolo bool - ag *agent.Agent - sess *agent.Session - cleanup func() // closes the current agent's MCP sessions + in *bufio.Reader + yolo bool + ag *agent.Agent + sess *agent.Session + cleanup func() // closes the current agent's MCP sessions + identity string // provider plus auth mode; reasoning replay is backend-specific } // runREPL starts an interactive session. With yolo, every tool action is @@ -131,7 +132,7 @@ func (r *repl) command(line string) bool { func printHelp() { fmt.Print(`Commands: /model pick provider, model, and thinking effort - /auth [provider] log in: store an API key (anthropic|openai|google|ollama) + /auth [provider] log in (OpenAI: API key or experimental ChatGPT subscription) /mcp manage MCP servers (run /mcp for subcommands) /compact replace history with a model-written summary (frees context) /clear clear history @@ -158,10 +159,9 @@ func (r *repl) reload() { // but is cleared when the provider changes: replayed reasoning blocks are // provider-specific opaque blobs another backend would reject. func (r *repl) rebuild() error { - oldProvider := "" + oldIdentity := r.identity var oldMsgs []provider.Message if r.ag != nil { - oldProvider = r.ag.Provider.Name() oldMsgs = r.sess.Messages } @@ -176,18 +176,34 @@ func (r *repl) rebuild() error { } r.cleanup() r.ag, r.sess, r.cleanup = ag, sess, cleanup + r.identity = configuredProviderIdentity() - if oldProvider == ag.Provider.Name() { + if oldIdentity == r.identity { r.sess.Messages = oldMsgs } else if len(oldMsgs) > 0 { - fmt.Println("Provider changed — history cleared.") + fmt.Println("Provider or OpenAI sign-in method changed — history cleared.") } return nil } func (r *repl) printStatus() { wd, _ := os.Getwd() - fmt.Printf(" %s / %s (effort %s) in %s\n", r.ag.Provider.Name(), r.ag.Model, r.ag.Effort, wd) + name := r.ag.Provider.Name() + if r.identity == "openai:chatgpt" { + name += " / ChatGPT experimental" + } + fmt.Printf(" %s / %s (effort %s) in %s\n", name, r.ag.Model, r.ag.Effort, wd) +} + +func configuredProviderIdentity() string { + cfg, err := config.Load() + if err != nil || cfg.Provider == "" { + return "" + } + if cfg.Provider == "openai" { + return "openai:" + cfg.Resolve("openai").Cred.Kind() + } + return cfg.Provider } // buildAgent constructs an Agent + Session from saved config for the current @@ -203,11 +219,22 @@ func buildAgent(progress agent.Progress, approve tools.Approver) (*agent.Agent, return nil, nil, nop, errNotConfigured } res := cfg.Resolve(cfg.Provider) - token, tokErr := res.Cred.Token() + token, tokErr := res.Cred.Token(context.Background(), false) if tokErr != nil && cfg.Provider != "ollama" { - return nil, nil, nop, fmt.Errorf("no API key for %s — use /auth %s or set the env var", cfg.Provider, cfg.Provider) + return nil, nil, nop, fmt.Errorf("no credentials for %s — use /auth %s or set the env var", cfg.Provider, cfg.Provider) + } + opts := provider.Options{APIKey: token, BaseURL: res.Host} + if cfg.Provider == "openai" { + opts.ChatGPT = res.Cred.Kind() == "chatgpt" + opts.OpenAIAuth = func(ctx context.Context, retry bool) (provider.OpenAIAuth, error) { + token, err := res.Cred.Token(ctx, retry) + if err != nil { + return provider.OpenAIAuth{}, err + } + return provider.OpenAIAuth{Token: token, AccountID: res.Cred.AccountID()}, nil + } } - p, err := provider.New(cfg.Provider, provider.Options{APIKey: token, BaseURL: res.Host}) + p, err := provider.New(cfg.Provider, opts) if err != nil { return nil, nil, nop, err } diff --git a/internal/config/config.go b/internal/config/config.go index 180919c..e02732e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,17 +2,19 @@ // effective credentials for each provider (config file overlaid with env vars). // // The file lives at os.UserConfigDir()/scay/config.json and is written 0600. -// Credentials sit behind the Credential interface so OAuth can be added later -// without changing call sites or breaking the file schema. +// Credentials sit behind the Credential interface so API keys and the +// experimental renewable ChatGPT credential share the same call sites. package config import ( + "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" + "time" ) // ErrNoCredential is returned by a Credential that has no usable secret. @@ -58,32 +60,45 @@ func (s MCPServer) Target() string { } // ProviderConfig holds one provider's stored settings. AuthType discriminates -// the credential kind so an OAuth variant can be added later. +// the credential kind. type ProviderConfig struct { - AuthType string `json:"auth_type,omitempty"` // "api_key" is the only kind today - APIKey string `json:"api_key,omitempty"` - Host string `json:"host,omitempty"` // base URL override (e.g. Ollama) + AuthType string `json:"auth_type,omitempty"` // "api_key" | "chatgpt" + APIKey string `json:"api_key,omitempty"` + OAuth *OAuthCredential `json:"oauth,omitempty"` + Host string `json:"host,omitempty"` // base URL override (e.g. Ollama) } -// Credential yields the secret used to authenticate a provider request. The -// api_key credential returns a static key; a future OAuth credential would -// refresh and return a short-lived token. The provider adapter decides which -// HTTP header carries the secret. +// OAuthCredential is the persisted state for the experimental ChatGPT/Codex +// login. Treat every field as a password: config.json is therefore 0600. +type OAuthCredential struct { + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + AccountID string `json:"account_id,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// Credential yields the secret used to authenticate a provider request. API-key +// credentials are static; the experimental ChatGPT credential refreshes a +// short-lived token. The provider adapter decides which HTTP header carries it. type Credential interface { - Token() (string, error) + Token(context.Context, bool) (string, error) Valid() bool + Kind() string + AccountID() string } type apiKeyCredential struct{ key string } -func (c apiKeyCredential) Token() (string, error) { +func (c apiKeyCredential) Token(context.Context, bool) (string, error) { if c.key == "" { return "", ErrNoCredential } return c.key, nil } -func (c apiKeyCredential) Valid() bool { return c.key != "" } +func (c apiKeyCredential) Valid() bool { return c.key != "" } +func (c apiKeyCredential) Kind() string { return "api_key" } +func (c apiKeyCredential) AccountID() string { return "" } // Resolved is the effective, ready-to-use auth for one provider after env-var // overrides are applied on top of the stored config. @@ -107,6 +122,7 @@ func (f *File) Resolve(providerID string) Resolved { case "openai": if v := os.Getenv("OPENAI_API_KEY"); v != "" { key = v + return Resolved{Cred: apiKeyCredential{key: key}, Host: host} } case "google": if v := os.Getenv("GEMINI_API_KEY"); v != "" { @@ -123,6 +139,9 @@ func (f *File) Resolve(providerID string) Resolved { } } + if providerID == "openai" && pc.AuthType == "chatgpt" && pc.OAuth != nil { + return Resolved{Cred: &chatGPTCredential{file: f, providerID: providerID}, Host: host} + } return Resolved{Cred: apiKeyCredential{key: key}, Host: host} } @@ -168,7 +187,8 @@ func Load() (*File, error) { return &f, nil } -// Save writes the config file with 0600 perms, creating the directory (0700). +// Save atomically replaces the config file with 0600 perms, creating the +// directory (0700). Atomic replacement matters when a refresh token rotates. func Save(f *File) error { dir, err := Dir() if err != nil { @@ -181,7 +201,24 @@ func Save(f *File) error { if err != nil { return err } - return os.WriteFile(filepath.Join(dir, "config.json"), append(data, '\n'), 0o600) + tmp, err := os.CreateTemp(dir, ".config.json-") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, filepath.Join(dir, "config.json")) } // --- project-level MCP config (.mcp.json in the working directory) --- diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eb0c203..4b4e8cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,11 +1,77 @@ package config import ( + "context" + "encoding/base64" + "encoding/json" "os" "path/filepath" "testing" ) +func TestResolveOpenAIChatGPTCredential(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + cfg := &File{Providers: map[string]ProviderConfig{ + "openai": {AuthType: "chatgpt", OAuth: &OAuthCredential{AccessToken: "access", AccountID: "workspace_1"}}, + }} + cred := cfg.Resolve("openai").Cred + if cred.Kind() != "chatgpt" || cred.AccountID() != "workspace_1" { + t.Fatalf("wrong credential: kind=%s account=%s", cred.Kind(), cred.AccountID()) + } + token, err := cred.Token(context.Background(), false) + if err != nil || token != "access" { + t.Fatalf("Token() = %q, %v", token, err) + } +} + +func TestJWTClaims(t *testing.T) { + payload, err := json.Marshal(map[string]any{"chatgpt_account_id": "workspace_1", "exp": float64(1_900_000_000)}) + if err != nil { + t.Fatal(err) + } + token := "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + if got := jwtAccountID(token); got != "workspace_1" { + t.Errorf("account=%q", got) + } + if got := jwtExpiry(token).Unix(); got != 1_900_000_000 { + t.Errorf("expiry=%d", got) + } +} + +func TestSaveReplacesConfigWithPrivatePermissions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + first := &File{Provider: "openai", Providers: map[string]ProviderConfig{ + "openai": {AuthType: "chatgpt", OAuth: &OAuthCredential{AccessToken: "old"}}, + }} + if err := Save(first); err != nil { + t.Fatal(err) + } + second := &File{Provider: "openai", Providers: map[string]ProviderConfig{ + "openai": {AuthType: "chatgpt", OAuth: &OAuthCredential{AccessToken: "new"}}, + }} + if err := Save(second); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if token := got.Providers["openai"].OAuth.AccessToken; token != "new" { + t.Fatalf("saved token = %q, want new", token) + } + path, err := Path() + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("config mode = %04o, want 0600", mode) + } +} + func TestMergedMCPServers(t *testing.T) { dir := t.TempDir() // Project file defines "fs" and overrides "search". diff --git a/internal/config/openai_oauth.go b/internal/config/openai_oauth.go new file mode 100644 index 0000000..4dcca13 --- /dev/null +++ b/internal/config/openai_oauth.go @@ -0,0 +1,306 @@ +package config + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// The Codex CLI is a public OAuth client (PKCE, no client secret). OpenAI does +// not currently publish a separate client registration flow for third-party +// CLIs, so this integration is intentionally experimental. +const ( + chatGPTAuthIssuer = "https://auth.openai.com" + chatGPTClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + chatGPTRedirect = "http://localhost:1455/auth/callback" +) + +// LoginOpenAIChatGPT completes the browser login used by the Codex CLI and +// saves the resulting renewable credential in scay's normal 0600 config file. +// openURL may return an error when the platform cannot launch a browser; the +// caller can still print the URL for the user to open manually. +func LoginOpenAIChatGPT(ctx context.Context, openURL func(string) error) error { + cfg, err := Load() + if err != nil { + return err + } + oauth, err := loginChatGPT(ctx, openURL) + if err != nil { + return err + } + pc := cfg.Providers["openai"] + pc.AuthType = "chatgpt" + pc.APIKey = "" + pc.OAuth = &oauth + cfg.Providers["openai"] = pc + if cfg.Provider == "" { + cfg.Provider = "openai" + } + return Save(cfg) +} + +type chatGPTCredential struct { + file *File + providerID string + mu sync.Mutex +} + +func (c *chatGPTCredential) Kind() string { return "chatgpt" } + +func (c *chatGPTCredential) AccountID() string { + c.mu.Lock() + defer c.mu.Unlock() + pc := c.file.Providers[c.providerID] + if pc.OAuth == nil { + return "" + } + return pc.OAuth.AccountID +} + +func (c *chatGPTCredential) Valid() bool { + c.mu.Lock() + defer c.mu.Unlock() + pc := c.file.Providers[c.providerID] + return pc.OAuth != nil && pc.OAuth.AccessToken != "" +} + +// Token refreshes shortly before expiry, or unconditionally after a 401 when +// force is true. The mutex prevents concurrent turns from consuming the same +// rotating refresh token. +func (c *chatGPTCredential) Token(ctx context.Context, force bool) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + pc := c.file.Providers[c.providerID] + if pc.OAuth == nil || pc.OAuth.AccessToken == "" { + return "", ErrNoCredential + } + if !force && (pc.OAuth.ExpiresAt.IsZero() || time.Until(pc.OAuth.ExpiresAt) > time.Minute) { + return pc.OAuth.AccessToken, nil + } + if pc.OAuth.RefreshToken == "" { + return "", fmt.Errorf("ChatGPT login expired — run /auth openai again") + } + next, err := refreshChatGPTToken(ctx, pc.OAuth.RefreshToken) + if err != nil { + return "", err + } + if next.AccessToken != "" { + pc.OAuth.AccessToken = next.AccessToken + } + if next.RefreshToken != "" { + pc.OAuth.RefreshToken = next.RefreshToken + } + if !next.ExpiresAt.IsZero() { + pc.OAuth.ExpiresAt = next.ExpiresAt + } + pc.OAuth.AccountID = firstNonEmpty(next.AccountID, pc.OAuth.AccountID) + c.file.Providers[c.providerID] = pc + if err := Save(c.file); err != nil { + return "", err + } + return pc.OAuth.AccessToken, nil +} + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` +} + +func (r oauthTokenResponse) credential() OAuthCredential { + expires := time.Time{} + if r.ExpiresIn > 0 { + expires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second) + } else { + expires = jwtExpiry(r.AccessToken) + } + return OAuthCredential{ + AccessToken: r.AccessToken, RefreshToken: r.RefreshToken, + AccountID: jwtAccountID(r.IDToken), ExpiresAt: expires, + } +} + +func refreshChatGPTToken(ctx context.Context, refreshToken string) (OAuthCredential, error) { + values := url.Values{ + "client_id": {chatGPTClientID}, + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatGPTAuthIssuer+"/oauth/token", strings.NewReader(values.Encode())) + if err != nil { + return OAuthCredential{}, err + } + req.Header.Set("content-type", "application/x-www-form-urlencoded") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return OAuthCredential{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return OAuthCredential{}, fmt.Errorf("refresh ChatGPT login: %s", resp.Status) + } + var tokens oauthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + return OAuthCredential{}, err + } + if tokens.AccessToken == "" { + return OAuthCredential{}, fmt.Errorf("refresh ChatGPT login: no access token returned") + } + return tokens.credential(), nil +} + +func loginChatGPT(ctx context.Context, openURL func(string) error) (OAuthCredential, error) { + listener, err := net.Listen("tcp", "127.0.0.1:1455") + if err != nil { + return OAuthCredential{}, fmt.Errorf("start ChatGPT login callback: %w", err) + } + defer listener.Close() + + state, err := randomURLValue(32) + if err != nil { + return OAuthCredential{}, err + } + verifier, err := randomURLValue(48) + if err != nil { + return OAuthCredential{}, err + } + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + + type callback struct{ code, state, failure string } + got := make(chan callback, 1) + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + select { + case got <- callback{code: q.Get("code"), state: q.Get("state"), failure: q.Get("error_description")}: + default: + } + w.Header().Set("content-type", "text/html; charset=utf-8") + fmt.Fprint(w, "

Sign-in complete. You can return to scay.

") + }) + server := &http.Server{Handler: mux} + go server.Serve(listener) + defer server.Shutdown(context.Background()) + + q := url.Values{ + "response_type": {"code"}, + "client_id": {chatGPTClientID}, + "redirect_uri": {chatGPTRedirect}, + "scope": {"openid profile email offline_access api.connectors.read api.connectors.invoke"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + "state": {state}, + "originator": {"codex_cli"}, + } + authorizeURL := chatGPTAuthIssuer + "/oauth/authorize?" + q.Encode() + if err := openURL(authorizeURL); err != nil { + return OAuthCredential{}, err + } + + select { + case <-ctx.Done(): + return OAuthCredential{}, ctx.Err() + case cb := <-got: + if cb.state != state { + return OAuthCredential{}, fmt.Errorf("ChatGPT login callback state mismatch") + } + if cb.failure != "" { + return OAuthCredential{}, fmt.Errorf("ChatGPT sign-in failed: %s", cb.failure) + } + if cb.code == "" { + return OAuthCredential{}, fmt.Errorf("ChatGPT sign-in returned no authorization code") + } + return exchangeChatGPTCode(ctx, cb.code, verifier) + } +} + +func exchangeChatGPTCode(ctx context.Context, code, verifier string) (OAuthCredential, error) { + values := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {chatGPTRedirect}, + "client_id": {chatGPTClientID}, + "code_verifier": {verifier}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatGPTAuthIssuer+"/oauth/token", strings.NewReader(values.Encode())) + if err != nil { + return OAuthCredential{}, err + } + req.Header.Set("content-type", "application/x-www-form-urlencoded") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return OAuthCredential{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return OAuthCredential{}, fmt.Errorf("exchange ChatGPT login: %s", resp.Status) + } + var tokens oauthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + return OAuthCredential{}, err + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + return OAuthCredential{}, fmt.Errorf("exchange ChatGPT login: incomplete token response") + } + return tokens.credential(), nil +} + +func randomURLValue(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func jwtClaims(token string) map[string]any { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil + } + b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + var claims map[string]any + if json.Unmarshal(b, &claims) != nil { + return nil + } + return claims +} + +func jwtExpiry(token string) time.Time { + if exp, ok := jwtClaims(token)["exp"].(float64); ok && exp > 0 { + return time.Unix(int64(exp), 0) + } + return time.Time{} +} + +func jwtAccountID(token string) string { + if id, ok := jwtClaims(token)["chatgpt_account_id"].(string); ok { + return id + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 612aade..92a3e66 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -13,21 +13,27 @@ import ( "time" ) -// OpenAI adapter over the Chat Completions API. We use /v1/chat/completions -// (not /v1/responses) deliberately: it is the simplest surface to hand-roll — -// stable tool-call ids, a plain top-level reasoning_effort, and no reasoning -// round-trip plumbing. Because Ollama and Gemini also expose OpenAI-compatible -// chat endpoints, a BaseURL override lets this client target them too. -const openaiBase = "https://api.openai.com/v1" +// OpenAI adapter over Responses API. API keys use the public Platform endpoint; +// ChatGPT login uses the Codex backend and adds the account header. Keeping the +// two routes here lets the agent loop stay entirely provider-agnostic. +const ( + openaiBase = "https://api.openai.com/v1" + chatGPTCodexBase = "https://chatgpt.com/backend-api/codex" +) type openai struct { apiKey string + auth func(context.Context, bool) (OpenAIAuth, error) + chatGPT bool endpoint string http *http.Client } func newOpenAI(opts Options) *openai { base := openaiBase + if opts.ChatGPT { + base = chatGPTCodexBase + } if opts.BaseURL != "" { base = strings.TrimRight(opts.BaseURL, "/") } @@ -35,63 +41,60 @@ func newOpenAI(opts Options) *openai { if hc == nil { hc = &http.Client{Timeout: 10 * time.Minute} } - return &openai{apiKey: opts.APIKey, endpoint: base + "/chat/completions", http: hc} + return &openai{apiKey: opts.APIKey, auth: opts.OpenAIAuth, chatGPT: opts.ChatGPT, endpoint: base + "/responses", http: hc} } func (o *openai) Name() string { return "openai" } -type oaiReq struct { - Model string `json:"model"` - Messages []oaiMsg `json:"messages"` - Tools []oaiTool `json:"tools,omitempty"` - MaxTokens int `json:"max_completion_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *oaiStreamOpts `json:"stream_options,omitempty"` +type oaiResponsesRequest struct { + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Input []json.RawMessage `json:"input"` + Tools []oaiResponseTool `json:"tools,omitempty"` + Reasoning *oaiReasoning `json:"reasoning,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Store *bool `json:"store,omitempty"` + Stream bool `json:"stream,omitempty"` } -type oaiStreamOpts struct { - IncludeUsage bool `json:"include_usage"` -} - -type oaiTool struct { - Type string `json:"type"` // always "function" - Function oaiFunc `json:"function"` -} - -type oaiFunc struct { +type oaiResponseTool struct { + Type string `json:"type"` Name string `json:"name"` - Description string `json:"description"` + Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters"` } -type oaiMsg struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type oaiToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` // a JSON *string*, not an object - } `json:"function"` +type oaiReasoning struct { + Effort string `json:"effort"` } -func (o *openai) newHTTPRequest(ctx context.Context, r Request, stream bool) (*http.Request, error) { - body := oaiReq{ +func (o *openai) newHTTPRequest(ctx context.Context, r Request, stream, refresh bool) (*http.Request, error) { + auth := OpenAIAuth{Token: o.apiKey} + if o.auth != nil { + var err error + auth, err = o.auth(ctx, refresh) + if err != nil { + return nil, err + } + } + if auth.Token == "" { + return nil, errors.New("openai: no credential") + } + body := oaiResponsesRequest{ Model: r.Model, - Messages: oaiMessages(r.System, r.Messages), - Tools: oaiTools(r.Tools), - MaxTokens: r.MaxTokens, - ReasoningEffort: oaiEffort(r.Effort), + Instructions: r.System, + Input: oaiResponseInput(r.Messages), + Tools: oaiResponseTools(r.Tools), + Reasoning: &oaiReasoning{Effort: oaiEffort(r.Effort)}, + MaxOutputTokens: r.MaxTokens, Stream: stream, } - if stream { - body.StreamOptions = &oaiStreamOpts{IncludeUsage: true} + // The ChatGPT/Codex backend rejects stored Responses. Keep the public API + // behavior unchanged, but send only its supported request fields here. + if o.chatGPT { + store := false + body.Store = &store + body.MaxOutputTokens = 0 } payload, err := json.Marshal(body) if err != nil { @@ -102,19 +105,37 @@ func (o *openai) newHTTPRequest(ctx context.Context, r Request, stream bool) (*h return nil, err } req.Header.Set("content-type", "application/json") - req.Header.Set("authorization", "Bearer "+o.apiKey) + req.Header.Set("authorization", "Bearer "+auth.Token) + if o.chatGPT && auth.AccountID != "" { + req.Header.Set("ChatGPT-Account-ID", auth.AccountID) + } return req, nil } -// Stream runs the request over SSE ("data:" chunks terminated by "[DONE]"). -// Tool-call fragments are keyed by index and concatenated; the final "done" -// event carries the same Response a Chat call would have produced. -func (o *openai) Stream(ctx context.Context, r Request) (iter.Seq[Event], error) { - req, err := o.newHTTPRequest(ctx, r, true) - if err != nil { - return nil, err +// do performs an OpenAI request and lets an OAuth credential refresh exactly +// once after a 401. API-key callers never take the refresh path. +func (o *openai) do(ctx context.Context, r Request, stream bool) (*http.Response, error) { + for refreshed := false; ; { + req, err := o.newHTTPRequest(ctx, r, stream, refreshed) + if err != nil { + return nil, err + } + hc := o.http + if stream { + hc = streamClient(hc) + } + resp, err := doRetry(hc, req) + if err != nil || resp == nil || resp.StatusCode != http.StatusUnauthorized || refreshed || o.auth == nil { + return resp, err + } + io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + resp.Body.Close() + refreshed = true } - resp, err := doRetry(streamClient(o.http), req) +} + +func (o *openai) Stream(ctx context.Context, r Request) (iter.Seq[Event], error) { + resp, err := o.do(ctx, r, true) if err != nil { return nil, err } @@ -126,92 +147,72 @@ func (o *openai) Stream(ctx context.Context, r Request) (iter.Seq[Event], error) return func(yield func(Event) bool) { defer resp.Body.Close() - - type callAccum struct { - id, name string - args strings.Builder - } - var ( - content strings.Builder - calls []*callAccum // ordered by the API's tool-call index - finish string - usage Usage - done bool - ) + done := false + var streamedCalls []Block emit := func(e Event) error { if !yield(e) { return errStopped } return nil } - - err := readSSE(resp.Body, func(_, data string) error { + err := readSSE(resp.Body, func(event, data string) error { if data == "[DONE]" { - done = true - var blocks []Block - if content.Len() > 0 { - blocks = append(blocks, Block{Kind: BlockText, Text: content.String()}) - } - for _, c := range calls { - call := &ToolCall{ID: c.id, Name: c.name, Args: rawOrEmpty(json.RawMessage(c.args.String()))} - blocks = append(blocks, Block{Kind: BlockToolCall, ToolCall: call}) - if err := emit(Event{Kind: "tool_call", ToolCall: call}); err != nil { - return err - } - } - return emit(Event{Kind: "done", Response: &Response{ - Blocks: blocks, StopReason: finish, Usage: usage, - }}) - } - var ch struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - ToolCalls []struct { - Index int `json:"index"` - ID string `json:"id"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - } `json:"usage"` - } - if err := json.Unmarshal([]byte(data), &ch); err != nil { - return nil // tolerate unknown frames + return nil } - if ch.Usage != nil { // final usage chunk (include_usage) has no choices - usage = Usage{InputTokens: ch.Usage.PromptTokens, OutputTokens: ch.Usage.CompletionTokens} + var frame struct { + Type string `json:"type"` + Delta string `json:"delta"` + Item json.RawMessage `json:"item"` + Response json.RawMessage `json:"response"` + Error *struct { + Message string `json:"message"` + } `json:"error"` } - if len(ch.Choices) == 0 { - return nil + if err := json.Unmarshal([]byte(data), &frame); err != nil { + return nil // tolerate newly-added frame types } - c := ch.Choices[0] - if c.FinishReason != "" { - finish = c.FinishReason + kind := frame.Type + if kind == "" { + kind = event } - for _, tc := range c.Delta.ToolCalls { - for len(calls) <= tc.Index { - calls = append(calls, &callAccum{}) + switch kind { + case "response.output_text.delta": + if frame.Delta != "" { + return emit(Event{Kind: "text", Text: frame.Delta}) } - acc := calls[tc.Index] - if tc.ID != "" { - acc.id = tc.ID + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": + if frame.Delta != "" { + return emit(Event{Kind: "thinking", Text: frame.Delta}) } - if tc.Function.Name != "" { - acc.name = tc.Function.Name + case "response.output_item.done": + if call, ok := parseOAIFunctionCall(frame.Item); ok { + streamedCalls = append(streamedCalls, call) } - acc.args.WriteString(tc.Function.Arguments) - } - if c.Delta.Content != "" { - content.WriteString(c.Delta.Content) - return emit(Event{Kind: "text", Text: c.Delta.Content}) + case "response.completed": + result, err := parseOAIResponse(frame.Response) + if err != nil { + return err + } + // The Codex backend sends completed function calls as separate stream + // items but leaves response.output empty. The public endpoint normally + // repeats them in the final response; use the stream items as fallback. + if len(result.ToolCalls()) == 0 && len(streamedCalls) > 0 { + result.Blocks = append(result.Blocks, streamedCalls...) + result.StopReason = "tool_calls" + } + for _, call := range result.ToolCalls() { + c := call + if err := emit(Event{Kind: "tool_call", ToolCall: &c}); err != nil { + return err + } + } + done = true + return emit(Event{Kind: "done", Response: result}) + case "response.failed", "error": + if frame.Error != nil && frame.Error.Message != "" { + return fmt.Errorf("openai: %s", frame.Error.Message) + } + return errors.New("openai: response failed") } return nil }) @@ -223,17 +224,13 @@ func (o *openai) Stream(ctx context.Context, r Request) (iter.Seq[Event], error) return } if !done { - yield(Event{Kind: "error", Err: fmt.Errorf("openai: stream ended before [DONE]")}) + yield(Event{Kind: "error", Err: errors.New("openai: stream ended before response.completed")}) } }, nil } func (o *openai) Chat(ctx context.Context, r Request) (*Response, error) { - req, err := o.newHTTPRequest(ctx, r, false) - if err != nil { - return nil, err - } - resp, err := doRetry(o.http, req) + resp, err := o.do(ctx, r, false) if err != nil { return nil, err } @@ -245,86 +242,125 @@ func (o *openai) Chat(ctx context.Context, r Request) (*Response, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("openai: %s: %s", resp.Status, clip(raw, 500)) } + return parseOAIResponse(raw) +} - var or struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ToolCalls []oaiToolCall `json:"tool_calls"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` +func parseOAIResponse(raw []byte) (*Response, error) { + var wire struct { + Output []json.RawMessage `json:"output"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` } `json:"usage"` } - if err := json.Unmarshal(raw, &or); err != nil { + if err := json.Unmarshal(raw, &wire); err != nil { return nil, fmt.Errorf("openai: decode response: %w", err) } - if len(or.Choices) == 0 { - return nil, fmt.Errorf("openai: empty choices") - } - - msg := or.Choices[0].Message var blocks []Block - if msg.Content != "" { - blocks = append(blocks, Block{Kind: BlockText, Text: msg.Content}) + for _, item := range wire.Output { + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(item, &header); err != nil { + continue + } + switch header.Type { + case "message": + var message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(item, &message) == nil { + var text strings.Builder + for _, content := range message.Content { + if content.Type == "output_text" && content.Text != "" { + text.WriteString(content.Text) + } + } + if text.Len() > 0 { + // A Responses message is an output item with fields such as id and + // phase. Keep it verbatim for the next turn, just like reasoning + // and function-call items, rather than reconstructing it as input. + blocks = append(blocks, Block{Kind: BlockText, Text: text.String(), Reasoning: item}) + } + } + case "reasoning": + var reasoning struct { + Summary []struct { + Text string `json:"text"` + } `json:"summary"` + } + _ = json.Unmarshal(item, &reasoning) + var summary strings.Builder + for _, part := range reasoning.Summary { + summary.WriteString(part.Text) + } + blocks = append(blocks, Block{Kind: BlockThinking, Text: summary.String(), Reasoning: item}) + case "function_call": + if call, ok := parseOAIFunctionCall(item); ok { + blocks = append(blocks, call) + } + } } - for _, tc := range msg.ToolCalls { - args := json.RawMessage(tc.Function.Arguments) - blocks = append(blocks, Block{Kind: BlockToolCall, ToolCall: &ToolCall{ - ID: tc.ID, Name: tc.Function.Name, Args: rawOrEmpty(args), - }}) + stop := "stop" + if len((&Response{Blocks: blocks}).ToolCalls()) > 0 { + stop = "tool_calls" } - return &Response{ - Blocks: blocks, - StopReason: or.Choices[0].FinishReason, - Usage: Usage{InputTokens: or.Usage.PromptTokens, OutputTokens: or.Usage.CompletionTokens}, - }, nil + return &Response{Blocks: blocks, StopReason: stop, Usage: Usage{ + InputTokens: wire.Usage.InputTokens, OutputTokens: wire.Usage.OutputTokens, + }}, nil } -func oaiMessages(system string, msgs []Message) []oaiMsg { - var out []oaiMsg - if system != "" { - out = append(out, oaiMsg{Role: "system", Content: system}) +func parseOAIFunctionCall(item json.RawMessage) (Block, bool) { + var call struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + } + if json.Unmarshal(item, &call) != nil || call.Type != "function_call" || call.Name == "" { + return Block{}, false } + return Block{Kind: BlockToolCall, ToolCall: &ToolCall{ + ID: call.CallID, Name: call.Name, Args: rawOrEmpty(json.RawMessage(call.Arguments)), + }, Reasoning: item}, true +} + +func oaiResponseInput(msgs []Message) []json.RawMessage { + out := make([]json.RawMessage, 0, len(msgs)) + appendJSON := func(v any) { b, _ := json.Marshal(v); out = append(out, b) } for _, m := range msgs { switch m.Role { case RoleUser: - out = append(out, oaiMsg{Role: "user", Content: m.Text}) + appendJSON(map[string]any{"role": "user", "content": []map[string]string{{"type": "input_text", "text": m.Text}}}) case RoleAssistant: - am := oaiMsg{Role: "assistant"} for _, b := range m.Blocks { - switch b.Kind { - case BlockText: - am.Content += b.Text - case BlockToolCall: - if b.ToolCall != nil { - tc := oaiToolCall{ID: b.ToolCall.ID, Type: "function"} - tc.Function.Name = b.ToolCall.Name - tc.Function.Arguments = string(rawOrEmpty(b.ToolCall.Args)) - am.ToolCalls = append(am.ToolCalls, tc) - } + if len(b.Reasoning) > 0 { + out = append(out, b.Reasoning) // opaque reasoning/function-call replay + continue + } + if b.Kind == BlockText && b.Text != "" { + appendJSON(map[string]any{"role": "assistant", "content": []map[string]string{{"type": "input_text", "text": b.Text}}}) + } else if b.Kind == BlockToolCall && b.ToolCall != nil { + appendJSON(map[string]any{"type": "function_call", "call_id": b.ToolCall.ID, "name": b.ToolCall.Name, "arguments": string(rawOrEmpty(b.ToolCall.Args))}) } } - out = append(out, am) case RoleTool: - out = append(out, oaiMsg{Role: "tool", ToolCallID: m.ToolCallID, Content: m.Text}) + appendJSON(map[string]any{"type": "function_call_output", "call_id": m.ToolCallID, "output": m.Text}) } } return out } -func oaiTools(tools []Tool) []oaiTool { +func oaiResponseTools(tools []Tool) []oaiResponseTool { if len(tools) == 0 { return nil } - out := make([]oaiTool, len(tools)) + out := make([]oaiResponseTool, len(tools)) for i, t := range tools { - out[i] = oaiTool{Type: "function", Function: oaiFunc{ - Name: t.Name, Description: t.Description, Parameters: t.Schema, - }} + out[i] = oaiResponseTool{Type: "function", Name: t.Name, Description: t.Description, Parameters: rawOrEmpty(t.Schema)} } return out } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b12fd8e..aff39b9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -162,6 +162,17 @@ type Options struct { APIKey string BaseURL string // optional override; used for Ollama host and testing HTTP *http.Client // optional; adapters supply a sane default when nil + + // OpenAIAuth supplies a current bearer token. retry is true only after an + // OpenAI 401, allowing OAuth implementations to refresh a rotating token. + OpenAIAuth func(ctx context.Context, retry bool) (OpenAIAuth, error) + ChatGPT bool // use the experimental ChatGPT/Codex backend instead of Platform +} + +// OpenAIAuth is the resolved authorization for one OpenAI request. +type OpenAIAuth struct { + Token string + AccountID string // required by the ChatGPT/Codex backend } // New constructs a Provider by id. diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index e0c909e..395e0e4 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -50,7 +50,7 @@ func mustContain(t *testing.T, body string, wants ...string) { func TestOpenAIAdapter(t *testing.T) { var body string - resp := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_9","type":"function","function":{"name":"bash","arguments":"{\"command\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":6}}` + resp := `{"output":[{"type":"function_call","call_id":"call_9","name":"bash","arguments":"{\"command\":\"pwd\"}"}],"usage":{"input_tokens":5,"output_tokens":6}}` srv := mockServer(resp, &body) defer srv.Close() @@ -60,14 +60,84 @@ func TestOpenAIAdapter(t *testing.T) { t.Fatal(err) } mustContain(t, body, - `"role":"system"`, `"reasoning_effort":"medium"`, `"type":"function"`, - `"role":"tool"`, `"tool_call_id":"call_1"`, `"arguments":"{\"command\":\"ls\"}"`, + `"instructions":"sys-prompt"`, `"reasoning":{"effort":"medium"}`, `"type":"function"`, + `"function_call_output"`, `"call_id":"call_1"`, `"arguments":"{\"command\":\"ls\"}"`, ) if calls := out.ToolCalls(); len(calls) != 1 || calls[0].ID != "call_9" || calls[0].Name != "bash" { t.Fatalf("bad parsed tool call: %+v", calls) } } +func TestOpenAIPreservesAssistantMessageItem(t *testing.T) { + raw := []byte(`{"output":[{"id":"msg_1","type":"message","role":"assistant","phase":"final","content":[{"type":"output_text","text":"hello"}]}]}`) + response, err := parseOAIResponse(raw) + if err != nil { + t.Fatal(err) + } + if len(response.Blocks) != 1 || response.Blocks[0].Text != "hello" { + t.Fatalf("parsed blocks = %+v", response.Blocks) + } + input := oaiResponseInput([]Message{{Role: RoleAssistant, Blocks: response.Blocks}}) + if len(input) != 1 { + t.Fatalf("input items = %d, want 1", len(input)) + } + for _, want := range []string{`"id":"msg_1"`, `"phase":"final"`, `"type":"output_text"`} { + if !strings.Contains(string(input[0]), want) { + t.Errorf("replayed item missing %s: %s", want, input[0]) + } + } +} + +func TestOpenAIChatGPTRefreshesAfterUnauthorized(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"store":false`) { + t.Errorf("ChatGPT request must set store=false: %s", body) + } + if strings.Contains(string(body), `"max_output_tokens"`) { + t.Errorf("ChatGPT request must omit max_output_tokens: %s", body) + } + if got := r.Header.Get("ChatGPT-Account-ID"); got != "workspace_1" { + t.Errorf("ChatGPT-Account-ID=%q", got) + } + if requests == 1 { + if got := r.Header.Get("Authorization"); got != "Bearer stale" { + t.Errorf("first Authorization=%q", got) + } + w.WriteHeader(http.StatusUnauthorized) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer fresh" { + t.Errorf("second Authorization=%q", got) + } + w.Header().Set("content-type", "application/json") + io.WriteString(w, `{"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}`) + })) + defer srv.Close() + + refreshes := 0 + p, _ := New("openai", Options{ + BaseURL: srv.URL, + ChatGPT: true, + OpenAIAuth: func(_ context.Context, retry bool) (OpenAIAuth, error) { + if retry { + refreshes++ + return OpenAIAuth{Token: "fresh", AccountID: "workspace_1"}, nil + } + return OpenAIAuth{Token: "stale", AccountID: "workspace_1"}, nil + }, + }) + out, err := p.Chat(context.Background(), historyReq("gpt-5.2")) + if err != nil { + t.Fatal(err) + } + if requests != 2 || refreshes != 1 || out.Text() != "ok" { + t.Fatalf("requests=%d refreshes=%d response=%+v", requests, refreshes, out) + } +} + func TestGeminiAdapter(t *testing.T) { var body string resp := `{"candidates":[{"content":{"parts":[{"functionCall":{"name":"bash","args":{"command":"pwd"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":6}}` diff --git a/internal/provider/stream_test.go b/internal/provider/stream_test.go index 8c70f46..1b07ecb 100644 --- a/internal/provider/stream_test.go +++ b/internal/provider/stream_test.go @@ -96,13 +96,10 @@ func TestAnthropicStream(t *testing.T) { func TestOpenAIStream(t *testing.T) { var body string srv := sseServer(&body, - [2]string{"", `{"choices":[{"delta":{"content":"hi "},"finish_reason":null}]}`}, - [2]string{"", `{"choices":[{"delta":{"content":"there"},"finish_reason":null}]}`}, - [2]string{"", `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"comm"}}]},"finish_reason":null}]}`}, - [2]string{"", `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"and\":\"ls\"}"}}]},"finish_reason":null}]}`}, - [2]string{"", `{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`}, - [2]string{"", `{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":6}}`}, - [2]string{"", `[DONE]`}, + [2]string{"response.output_text.delta", `{"type":"response.output_text.delta","delta":"hi "}`}, + [2]string{"response.output_text.delta", `{"type":"response.output_text.delta","delta":"there"}`}, + [2]string{"response.output_item.done", `{"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}`}, + [2]string{"response.completed", `{"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"hi there"}]}],"usage":{"input_tokens":5,"output_tokens":6}}}`}, ) defer srv.Close() @@ -113,10 +110,13 @@ func TestOpenAIStream(t *testing.T) { } text, _, resp := collect(t, events) - mustContain(t, body, `"stream":true`, `"include_usage":true`) + mustContain(t, body, `"stream":true`, `"instructions":"sys-prompt"`) if text != "hi there" { t.Errorf("text=%q", text) } + if len(resp.Blocks) == 0 || !strings.Contains(string(resp.Blocks[0].Reasoning), `"type":"message"`) { + t.Errorf("assistant message was not preserved for replay: %+v", resp.Blocks) + } calls := resp.ToolCalls() if len(calls) != 1 || calls[0].ID != "call_1" || calls[0].Name != "bash" || string(calls[0].Args) != `{"command":"ls"}` {