Skip to content

Commit e135779

Browse files
authored
Merge pull request #3512 from docker/feat/chatgpt-provider
feat(provider): add chatgpt provider with ChatGPT account sign-in
2 parents be683e3 + 54e2530 commit e135779

27 files changed

Lines changed: 1923 additions & 21 deletions

File tree

agent-schema.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
"properties": {
185185
"provider": {
186186
"type": "string",
187-
"description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, azure, xai, ollama, mistral, baseten, ovhcloud, groq, fireworks, deepseek, cerebras, together, huggingface, moonshot, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, etc.).",
187+
"description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, azure, xai, ollama, mistral, baseten, ovhcloud, groq, fireworks, deepseek, cerebras, together, huggingface, moonshot, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, github-copilot, chatgpt, etc.).",
188188
"examples": [
189189
"openai",
190190
"anthropic",
@@ -1446,7 +1446,8 @@
14461446
"anthropic",
14471447
"dmr",
14481448
"ollama",
1449-
"github-copilot"
1449+
"github-copilot",
1450+
"chatgpt"
14501451
]
14511452
},
14521453
"model": {

cmd/root/doctor.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/docker/cli/cli"
1515
"github.com/spf13/cobra"
1616

17+
"github.com/docker/docker-agent/pkg/chatgpt"
1718
"github.com/docker/docker-agent/pkg/config"
1819
"github.com/docker/docker-agent/pkg/environment"
1920
"github.com/docker/docker-agent/pkg/model/provider/dmr"
@@ -266,8 +267,8 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
266267
if found, known := credFound[auto.Provider]; known && !found {
267268
autoStatus.Usable = false
268269
report.Issues = append(report.Issues, fmt.Sprintf(
269-
"the configured default model %s/%s has no credential for provider %s; set %s (%s)",
270-
auto.Provider, auto.Model, auto.Provider, primaryEnvVar[auto.Provider], environment.SecretsDocsURL))
270+
"the configured default model %s/%s has no credential for provider %s; %s (%s)",
271+
auto.Provider, auto.Model, auto.Provider, providerCredentialHint(auto.Provider, primaryEnvVar[auto.Provider]), environment.SecretsDocsURL))
271272
}
272273
}
273274
report.AutoModel = autoStatus
@@ -366,6 +367,16 @@ func (f *doctorFlags) listDMRModels(ctx context.Context) ([]string, error) {
366367
return dmr.ListModels(ctx)
367368
}
368369

370+
// providerCredentialHint phrases the remediation for a missing provider
371+
// credential: account-based providers point at the setup wizard's sign-in,
372+
// the rest at their API-key env var.
373+
func providerCredentialHint(provider, envVar string) string {
374+
if provider == chatgpt.ProviderName {
375+
return "sign in with `docker agent setup` (pick chatgpt) or set " + envVar
376+
}
377+
return "set " + envVar
378+
}
379+
369380
// findSource returns the name of the first secret source that supplies a
370381
// non-empty value for the variable. Empty values are skipped so a source that
371382
// merely defines the variable (e.g. an env file with `KEY=`) is not reported

cmd/root/doctor_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,44 @@ func TestDoctorCommand_SourcePrecedenceMatchesProviderChain(t *testing.T) {
8282
assert.Regexp(t, `openai\s+found\s+OPENAI_API_KEY\s+env-file`, output)
8383
}
8484

85+
func TestDoctorCommand_ChatGPTLoginSource(t *testing.T) {
86+
t.Parallel()
87+
88+
// The chatgpt-login source serves the virtual CHATGPT_OAUTH_TOKEN
89+
// variable from the stored browser sign-in.
90+
login := environment.NewMapEnvProvider(map[string]string{"CHATGPT_OAUTH_TOKEN": "chatgpt-access-token"})
91+
92+
output, err := executeDoctor(t, nil, func(f *doctorFlags) {
93+
f.runConfig.EnvProviderForTests = login
94+
f.sourcesForTests = []environment.Source{
95+
{Name: "environment", Provider: environment.NewMapEnvProvider(nil)},
96+
{Name: "chatgpt-login", Provider: login},
97+
}
98+
f.dmrLister = func(context.Context) ([]string, error) { return []string{"ai/qwen3:latest"}, nil }
99+
f.loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
100+
})
101+
102+
require.NoError(t, err)
103+
assert.Regexp(t, `chatgpt\s+found\s+CHATGPT_OAUTH_TOKEN\s+chatgpt-login`, output)
104+
assert.Contains(t, output, "auto -> chatgpt/gpt-5.2")
105+
assert.Contains(t, output, "No issues found.")
106+
assert.NotContains(t, output, "chatgpt-access-token", "secret values must never be printed")
107+
}
108+
109+
func TestDoctorCommand_ChatGPTDefaultModelWithoutLoginSuggestsSignIn(t *testing.T) {
110+
t.Parallel()
111+
112+
output, err := executeDoctor(t, nil,
113+
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
114+
func(f *doctorFlags) {
115+
f.runConfig.DefaultModel = &latest.ModelConfig{Provider: "chatgpt", Model: "gpt-5.2"}
116+
})
117+
118+
require.Error(t, err, "a default model without credentials is an issue")
119+
assert.Regexp(t, `chatgpt\s+not set\s+CHATGPT_OAUTH_TOKEN`, output)
120+
assert.Contains(t, output, "sign in with `docker agent setup` (pick chatgpt) or set CHATGPT_OAUTH_TOKEN")
121+
}
122+
85123
func TestDoctorCommand_EmptyValueIsNotACredential(t *testing.T) {
86124
t.Parallel()
87125

cmd/root/setup.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/spf13/cobra"
1616
"golang.org/x/term"
1717

18+
"github.com/docker/docker-agent/pkg/chatgpt"
1819
"github.com/docker/docker-agent/pkg/cli"
1920
"github.com/docker/docker-agent/pkg/config"
2021
"github.com/docker/docker-agent/pkg/environment"
@@ -52,10 +53,11 @@ type setupWizard struct {
5253
in *bufio.Reader
5354
out io.Writer
5455

55-
readSecret func(prompt string) (string, error)
56-
stores []environment.SecretStore
57-
dmrLister config.DMRModelLister
58-
pullModel func(ctx context.Context, model string) error
56+
readSecret func(prompt string) (string, error)
57+
stores []environment.SecretStore
58+
dmrLister config.DMRModelLister
59+
pullModel func(ctx context.Context, model string) error
60+
chatgptLogin func(ctx context.Context, out io.Writer) (*chatgpt.LoginResult, error)
5961
}
6062

6163
func newSetupCmd() *cobra.Command {
@@ -66,7 +68,9 @@ func newSetupCmd() *cobra.Command {
6668
6769
Two paths:
6870
- Cloud provider: pick a provider, paste its API key, and choose where to
69-
store it (OS keychain, pass, or the docker agent env file).
71+
store it (OS keychain, pass, or the docker agent env file). Picking
72+
chatgpt signs in with your ChatGPT account in the browser instead of
73+
asking for an API key.
7074
- Local model: check Docker Model Runner and pull a model. No API key needed.
7175
7276
Ends with the exact command to start chatting. Secret values are stored where
@@ -116,9 +120,10 @@ func newTerminalSetupWizard(in io.Reader, out io.Writer) *setupWizard {
116120
}
117121
return string(value), nil
118122
},
119-
stores: environment.SecretStores(),
120-
dmrLister: dmr.ListModels,
121-
pullModel: dmr.Pull,
123+
stores: environment.SecretStores(),
124+
dmrLister: dmr.ListModels,
125+
pullModel: dmr.Pull,
126+
chatgptLogin: chatgpt.Login,
122127
}
123128
}
124129

@@ -158,7 +163,11 @@ func (w *setupWizard) setupCloudProvider(ctx context.Context) (*setupResult, err
158163
fmt.Fprintln(w.out)
159164
fmt.Fprintln(w.out, "Pick a provider:")
160165
for i, p := range providers {
161-
fmt.Fprintf(w.out, " %2d. %-15s (%s)\n", i+1, p.Provider, p.EnvVars[0])
166+
credential := p.EnvVars[0]
167+
if p.Provider == chatgpt.ProviderName {
168+
credential = "ChatGPT account sign-in, no API key"
169+
}
170+
fmt.Fprintf(w.out, " %2d. %-15s (%s)\n", i+1, p.Provider, credential)
162171
}
163172

164173
choice, err := w.promptChoice(ctx, len(providers), 1)
@@ -168,6 +177,22 @@ func (w *setupWizard) setupCloudProvider(ctx context.Context) (*setupResult, err
168177
selected := providers[choice-1]
169178
envVar := selected.EnvVars[0]
170179

180+
// The chatgpt provider signs in with a browser flow instead of a pasted
181+
// API key; the credential is stored by the login itself.
182+
if selected.Provider == chatgpt.ProviderName {
183+
fmt.Fprintln(w.out)
184+
result, err := w.chatgptLogin(ctx, w.out)
185+
if err != nil {
186+
return nil, err
187+
}
188+
if result.Email != "" {
189+
fmt.Fprintf(w.out, "Signed in as %s.\n", result.Email)
190+
} else {
191+
fmt.Fprintln(w.out, "Signed in with your ChatGPT account.")
192+
}
193+
return &setupResult{Model: selected.Provider + "/" + config.DefaultModels[selected.Provider]}, nil
194+
}
195+
171196
key, err := w.promptSecret(ctx, fmt.Sprintf("\nPaste your %s API key (%s, input hidden): ", selected.Provider, envVar))
172197
if err != nil {
173198
return nil, err

cmd/root/setup_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"io"
10+
"slices"
911
"strings"
1012
"testing"
1113

1214
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/require"
1416

17+
"github.com/docker/docker-agent/pkg/chatgpt"
1518
"github.com/docker/docker-agent/pkg/config"
1619
"github.com/docker/docker-agent/pkg/config/latest"
1720
"github.com/docker/docker-agent/pkg/environment"
@@ -135,6 +138,36 @@ func TestSetupWizard_CloudPathReasksOnEmptyKey(t *testing.T) {
135138
assert.Equal(t, "sk-key", store.stored["ANTHROPIC_API_KEY"])
136139
}
137140

141+
func TestSetupWizard_ChatGPTPathRunsBrowserSignIn(t *testing.T) {
142+
t.Parallel()
143+
144+
providers := config.CloudProviderEnvVars()
145+
idx := slices.IndexFunc(providers, func(p config.ProviderEnvVars) bool { return p.Provider == "chatgpt" })
146+
require.GreaterOrEqual(t, idx, 0, "chatgpt must be offered by the wizard")
147+
148+
store := &fakeSecretStore{name: "keychain"}
149+
// cloud -> chatgpt: no key prompt, no store prompt.
150+
wizard, out, _ := newTestWizard(fmt.Sprintf("1\n%d\n", idx+1), nil, []environment.SecretStore{store}, nil, nil)
151+
loginCalled := false
152+
wizard.chatgptLogin = func(_ context.Context, _ io.Writer) (*chatgpt.LoginResult, error) {
153+
loginCalled = true
154+
return &chatgpt.LoginResult{Email: "user@example.com", Plan: "plus"}, nil
155+
}
156+
157+
result, err := wizard.run(t.Context())
158+
require.NoError(t, err)
159+
160+
assert.True(t, loginCalled)
161+
assert.Empty(t, result.EnvVar, "the sign-in stores the credential itself; nothing to export")
162+
assert.Equal(t, "chatgpt/"+config.DefaultModels["chatgpt"], result.Model)
163+
assert.Empty(t, store.stored, "no secret store is involved")
164+
165+
output := out.String()
166+
assert.Contains(t, output, "ChatGPT account sign-in")
167+
assert.Contains(t, output, "Signed in as user@example.com.")
168+
assert.Contains(t, output, "--model chatgpt/"+config.DefaultModels["chatgpt"])
169+
}
170+
138171
func TestSetupWizard_LocalPathWithPulledModels(t *testing.T) {
139172
t.Parallel()
140173

docs/concepts/models/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ for details.
9999
| Azure OpenAI | `azure` | gpt-4o, gpt-5 on Azure | `AZURE_API_KEY` + `base_url` |
100100
| Ollama | `ollama` | Any local Ollama model | None (local; optional `base_url`) |
101101
| GitHub Copilot | `github-copilot` | Copilot-hosted OpenAI/Anthropic | `GITHUB_TOKEN` (PAT with `copilot`) |
102+
| ChatGPT (OpenAI account) | `chatgpt` | gpt-5 family via ChatGPT subscription | None (sign in via `docker agent setup`) |
102103

103104
See the [Model Providers](../../providers/overview/index.md) section for detailed configuration guides.
104105

docs/configuration/models/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ models:
5050
| Property | Type | Required | Description |
5151
| --------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
5252
| `first_available` | array | ✗ | Candidate model references tried in order; selects the first whose credentials are configured. Mutually exclusive with other model settings. |
53-
| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks`, `deepseek`, `cerebras`, `together`, `huggingface`, `moonshot`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `azure`, `ollama`, `github-copilot`, or any [named provider](../../providers/custom/index.md). |
53+
| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks`, `deepseek`, `cerebras`, `together`, `huggingface`, `moonshot`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `azure`, `ollama`, `github-copilot`, `chatgpt`, or any [named provider](../../providers/custom/index.md). |
5454
| `model` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Model name (e.g., `gpt-4o`, `claude-sonnet-4-5`, `gemini-3.5-flash`) |
5555
| `temperature` | float | ✗ | Sampling randomness. Range is provider-dependent — typically `0.0–2.0` (Anthropic caps at `1.0`). `0.0` is deterministic. |
5656
| `max_tokens` | int | ✗ | Maximum response length in tokens |

docs/providers/chatgpt/index.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
---
2+
title: "ChatGPT (OpenAI account)"
3+
description: "Use your ChatGPT Plus/Pro/Business subscription with docker-agent by signing in with your OpenAI account, no API key needed."
4+
keywords: docker agent, ai agents, model providers, llm, chatgpt, openai, codex, subscription
5+
weight: 55
6+
canonical: https://docs.docker.com/ai/docker-agent/providers/chatgpt/
7+
---
8+
9+
_Use your ChatGPT subscription with docker-agent by signing in with your OpenAI account. No API key needed._
10+
11+
## Overview
12+
13+
The `chatgpt` provider authenticates with a ChatGPT account (the same
14+
"Sign in with ChatGPT" flow used by OpenAI's Codex CLI) instead of an
15+
`OPENAI_API_KEY`. Usage is billed against your ChatGPT Plus, Pro, or
16+
Business plan rather than pay-per-token API credits.
17+
18+
Under the hood, docker-agent talks to the ChatGPT Codex backend
19+
(`https://chatgpt.com/backend-api/codex`), which serves the `gpt-5` model
20+
family over the OpenAI Responses API.
21+
22+
## Prerequisites
23+
24+
- A paid **ChatGPT** subscription (Plus, Pro, or Business).
25+
- A browser on the machine running the sign-in (the OAuth flow uses a
26+
fixed `localhost:1455` callback).
27+
28+
## Sign In
29+
30+
```bash
31+
docker agent setup
32+
```
33+
34+
Pick **chatgpt** in the provider list: instead of asking for an API key, the
35+
wizard opens your browser on the ChatGPT sign-in page and stores the
36+
resulting OAuth credential in the docker-agent config directory
37+
(`~/.config/cagent/chatgpt-auth.json`, owner-only permissions). The access
38+
token is refreshed automatically; you only need to sign in again if the
39+
refresh token is revoked.
40+
41+
Related commands:
42+
43+
```bash
44+
docker agent doctor # the chatgpt row shows the credential state
45+
rm ~/.config/cagent/chatgpt-auth.json # sign out (remove the stored sign-in)
46+
```
47+
48+
## Configuration
49+
50+
### Inline
51+
52+
```yaml
53+
agents:
54+
root:
55+
model: chatgpt/gpt-5.2
56+
instruction: You are a helpful assistant.
57+
```
58+
59+
### Named model
60+
61+
```yaml
62+
models:
63+
gpt:
64+
provider: chatgpt
65+
model: gpt-5.2
66+
thinking_budget: medium
67+
68+
agents:
69+
root:
70+
model: gpt
71+
```
72+
73+
## Available Models
74+
75+
The Codex backend serves the models available to your ChatGPT plan,
76+
typically:
77+
78+
| Model | Best For |
79+
| ------------------- | ------------------------------------- |
80+
| `gpt-5.2` | General purpose, strong reasoning |
81+
| `gpt-5.2-codex` | Agentic coding workflows |
82+
| `gpt-5.1` | Previous flagship |
83+
| `gpt-5.1-codex-mini`| Fast and cheap coding tasks |
84+
85+
## How It Works
86+
87+
- **Auth:** the `docker agent setup` sign-in runs an OAuth 2.0
88+
authorization-code + PKCE flow against `auth.openai.com`. The stored login
89+
is exposed to credential checks (doctor, `first_available`, auto model
90+
selection) as the virtual `CHATGPT_OAUTH_TOKEN` variable.
91+
- **API:** requests go to the Responses API only; the backend has no Chat
92+
Completions endpoint, so `api_type` is pinned automatically.
93+
- **Request shape:** the backend requires stateless requests (`store: false`)
94+
and a top-level `instructions` field, so docker-agent moves system messages
95+
there. Client-side sampling parameters (`temperature`, `top_p`,
96+
`max_tokens`) are not supported by the backend and are dropped.
97+
98+
## Setting the Token Explicitly
99+
100+
`CHATGPT_OAUTH_TOKEN` can also be set like any other credential (shell
101+
environment, `--env-from-file`, keychain, ...). An explicitly set value takes
102+
precedence over the stored sign-in. This is useful for short-lived CI runs
103+
with a pre-minted access token, but note that such a token expires and is not
104+
refreshed.
105+
106+
## ChatGPT Subscription vs. OpenAI API Key
107+
108+
| | `chatgpt` | `openai` |
109+
| --- | --- | --- |
110+
| Credential | ChatGPT account sign-in | `OPENAI_API_KEY` |
111+
| Billing | Included in the ChatGPT plan (rate-limited) | Pay per token |
112+
| Models | `gpt-5` family served by the Codex backend | Full OpenAI API catalog |
113+
| Sampling controls (`temperature`, ...) | Not supported | Supported |
114+
| Embeddings / reranking | Not supported | Supported |
115+
116+
When both credentials are configured, automatic model selection prefers
117+
`openai`; pin `--model chatgpt/gpt-5.2` (or use a named model) to use the
118+
subscription.
119+
120+
> [!NOTE]
121+
> Use of the Codex backend is governed by OpenAI's terms for ChatGPT and
122+
> Codex. Sign-in is per user; do not share the stored credential.

docs/providers/openai/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ _Use GPT-4o, GPT-5, GPT-5-mini, and other OpenAI models with docker-agent._
1515
export OPENAI_API_KEY="sk-..."
1616
```
1717

18+
> [!TIP]
19+
> No API key? A ChatGPT Plus/Pro/Business subscription can be used instead
20+
> through the [`chatgpt` provider](../chatgpt/index.md): sign in once with
21+
> `docker agent setup` (pick chatgpt).
22+
1823
## Configuration
1924

2025
### Inline

docs/providers/overview/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ docker-agent also includes built-in aliases for these providers:
3636

3737
| Provider | Alias | API Key / Env Variable |
3838
| -------------- | ---------------- | ----------------------------------- |
39+
| ChatGPT (OpenAI account) | [`chatgpt`](../chatgpt/index.md) | None (sign in via `docker agent setup`) |
3940
| OpenCode Zen | `opencode-zen` | `OPENCODE_API_KEY` |
4041
| OpenCode Go | `opencode-go` | `OPENCODE_API_KEY` |
4142
| Mistral | `mistral` | `MISTRAL_API_KEY` |

0 commit comments

Comments
 (0)