mirror of
https://github.com/Heretek-AI/open-code-review.git
synced 2026-11-07 10:56:07 -10:00
* Fix ChatGPT auth recovery and provider credential handling * Handle rejected refresh identities and review feedback * docs: explain ChatGPT recovery and gateway credentials * Fix refresh window after pending token recovery --------- Co-authored-by: Qiyuanqiii <[email protected]>
This commit is contained in:
1 parent
c7734212e1
commit
3694a716c3
26 files changed
+898
-121
No files matched your search
@@ -286,18 +286,18 @@ func TestCheckAPIKeyRequirement(t *testing.T) {
|
||||
t.Fatal("chatgpt preset not registered")
|
||||
}
|
||||
|
||||
if err := checkAPIKeyRequirement("bedrock", "", "", bedrock, true); err != nil {
|
||||
if err := checkAPIKeyRequirement("bedrock", "", "", bedrock, true, ProviderEntry{}); err != nil {
|
||||
t.Errorf("ambient provider with no api_key = %v, want nil", err)
|
||||
}
|
||||
if err := checkAPIKeyRequirement("chatgpt", "", "", chatgpt, true); err != nil {
|
||||
if err := checkAPIKeyRequirement("chatgpt", "", "", chatgpt, true, ProviderEntry{}); err != nil {
|
||||
t.Errorf("external-auth provider with no api_key = %v, want nil", err)
|
||||
}
|
||||
|
||||
t.Setenv(anthropic.EnvVar, "")
|
||||
if err := checkAPIKeyRequirement("anthropic", "", "", anthropic, true); err == nil {
|
||||
if err := checkAPIKeyRequirement("anthropic", "", "", anthropic, true, ProviderEntry{}); err == nil {
|
||||
t.Error("key-based provider with no api_key and no env var = nil, want an error")
|
||||
}
|
||||
if err := checkAPIKeyRequirement("anthropic", "", "op read op://vault/key", anthropic, true); err != nil {
|
||||
if err := checkAPIKeyRequirement("anthropic", "", "op read op://vault/key", anthropic, true, ProviderEntry{}); err != nil {
|
||||
t.Errorf("key-based provider with api_key_cmd = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +240,12 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
||||
// Ambient-auth and external-auth providers have no credential to save here:
|
||||
// their credentials come from the environment chain or a dedicated token store
|
||||
// rather than the config file.
|
||||
func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.Provider, isPreset bool) error {
|
||||
func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.Provider, isPreset bool, entry ProviderEntry) error {
|
||||
if apiKey != "" || strings.TrimSpace(apiKeyCmd) != "" {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case isPreset && (preset.AmbientAuth || preset.ExternalAuth):
|
||||
case isPreset && providerUsesExternalCredentials(preset, entry):
|
||||
return nil
|
||||
case isPreset && preset.EnvVar != "":
|
||||
if os.Getenv(preset.EnvVar) == "" {
|
||||
@@ -257,6 +257,16 @@ func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.P
|
||||
}
|
||||
}
|
||||
|
||||
func providerUsesExternalCredentials(preset llm.Provider, entry ProviderEntry) bool {
|
||||
if preset.ExternalAuth {
|
||||
return true
|
||||
}
|
||||
if entry.Protocol != "" {
|
||||
return llm.NormalizeProtocol(entry.Protocol) == llm.ProtocolAnthropicBedrock
|
||||
}
|
||||
return preset.AmbientAuth || llm.NormalizeProtocol(preset.Protocol) == llm.ProtocolAnthropicBedrock
|
||||
}
|
||||
|
||||
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.provider == "" {
|
||||
return fmt.Errorf("provider and model are required")
|
||||
@@ -268,7 +278,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
|
||||
|
||||
preset, isPreset := llm.LookupProvider(result.provider)
|
||||
|
||||
if err := checkAPIKeyRequirement(result.provider, result.apiKey, cfg.Providers[result.provider].APIKeyCmd, preset, isPreset); err != nil {
|
||||
if err := checkAPIKeyRequirement(result.provider, result.apiKey, cfg.Providers[result.provider].APIKeyCmd, preset, isPreset, cfg.Providers[result.provider]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/alibaba/open-code-review/internal/llm"
|
||||
)
|
||||
|
||||
func TestProviderGatewayKeySurvivesModelSelection(t *testing.T) {
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "")
|
||||
t.Setenv("OCR_LLM_EXTRA_HEADERS", "")
|
||||
setTestHome(t, t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "bedrock",
|
||||
Model: "gateway-a",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"bedrock": {Protocol: llm.ProtocolOpenAIChatCompletions, URL: "https://example.invalid/v1", APIKey: "dummy-gateway-key", Model: "gateway-a", Models: []string{"gateway-a", "gateway-b"}},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"})
|
||||
if err != nil || before.Token != "dummy-gateway-key" || before.Protocol != llm.ProtocolOpenAIChatCompletions {
|
||||
t.Fatalf("initial gateway configuration must resolve: %v", err)
|
||||
}
|
||||
m := newProviderTUI(cfg, path)
|
||||
if m.currentProvider().Name != "bedrock" {
|
||||
t.Fatal("wrong provider")
|
||||
}
|
||||
updated, _ := m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
found := false
|
||||
for i, model := range m.models() {
|
||||
if model == "gateway-b" {
|
||||
m.modelIdx = i
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("target model missing")
|
||||
}
|
||||
updated, _ = m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
if m.step == stepAPIKey {
|
||||
updated, _ = m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
}
|
||||
if !m.confirmed {
|
||||
t.Fatalf("expected completed model selection, got step=%d", m.step)
|
||||
}
|
||||
result := m.result()
|
||||
if result.resolvedModel() != "gateway-b" {
|
||||
t.Fatalf("wrong selected model: %s", result.resolvedModel())
|
||||
}
|
||||
oldTest := runLLMTestPath
|
||||
t.Cleanup(func() { runLLMTestPath = oldTest })
|
||||
runLLMTestPath = func(path string) error {
|
||||
_, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"})
|
||||
return err
|
||||
}
|
||||
if err := applyOfficialProviderConfig(path, cfg, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, resolveErr := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"})
|
||||
saved, err := loadOrCreateConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("selected_model=%s persisted_key_cleared=%t resolution_error=%v", saved.Providers["bedrock"].Model, saved.Providers["bedrock"].APIKey == "", resolveErr)
|
||||
if resolveErr != nil || after.Token != "dummy-gateway-key" {
|
||||
t.Fatalf("changing the model must preserve the existing gateway credential: %v", resolveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderAmbientProtocolOverrideCanBeSaved(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||
setTestHome(t, t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := &Config{Provider: "anthropic", Providers: map[string]ProviderEntry{
|
||||
"anthropic": {Protocol: llm.ProtocolAnthropicBedrock, AWSRegion: "us-west-2", Model: "fixture-model", Models: []string{"fixture-model"}},
|
||||
}}
|
||||
m := newProviderTUI(cfg, path)
|
||||
updated, _ := m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
updated, _ = m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
if !m.confirmed || m.step == stepAPIKey {
|
||||
t.Fatal("ambient protocol unexpectedly requires a key")
|
||||
}
|
||||
previous := runLLMTestPath
|
||||
t.Cleanup(func() { runLLMTestPath = previous })
|
||||
runLLMTestPath = func(path string) error {
|
||||
ep, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "anthropic"})
|
||||
if err == nil && (!ep.AmbientAuth || ep.Token != "") {
|
||||
t.Error("saved ambient protocol requires a token")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := applyOfficialProviderConfig(path, cfg, m.result()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderGatewayOverrideRequiresCredential(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
cfg := &Config{Provider: "bedrock", Providers: map[string]ProviderEntry{
|
||||
"bedrock": {Protocol: llm.ProtocolOpenAIChatCompletions, URL: "https://example.invalid/v1", Model: "gateway", Models: []string{"gateway"}},
|
||||
}}
|
||||
m := newProviderTUI(cfg, "")
|
||||
updated, _ := m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
updated, _ = m.Update(enterKey())
|
||||
m = updated.(providerTUIModel)
|
||||
if m.step != stepAPIKey {
|
||||
t.Fatal("token protocol skipped its credential step")
|
||||
}
|
||||
if ok, _ := m.apiKeyStepCanConfirm(); ok {
|
||||
t.Fatal("token protocol accepted an absent credential")
|
||||
}
|
||||
preset, _ := llm.LookupProvider("bedrock")
|
||||
if err := checkAPIKeyRequirement("bedrock", "", "", preset, true, cfg.Providers["bedrock"]); err == nil {
|
||||
t.Fatal("save validation accepted an absent credential")
|
||||
}
|
||||
}
|
||||
@@ -983,6 +983,15 @@ func (m providerTUIModel) apiKeyCmdForStep() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m providerTUIModel) officialProviderUsesExternalCredentials() bool {
|
||||
p := m.currentProvider()
|
||||
var entry ProviderEntry
|
||||
if m.existingCfg != nil {
|
||||
entry = m.existingCfg.Providers[p.Name]
|
||||
}
|
||||
return providerUsesExternalCredentials(p, entry)
|
||||
}
|
||||
|
||||
func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) {
|
||||
if m.apiKeyOriginal != "" {
|
||||
return true, ""
|
||||
@@ -998,7 +1007,7 @@ func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) {
|
||||
}
|
||||
if m.activeTab == tabOfficial {
|
||||
p := m.currentProvider()
|
||||
if p.AmbientAuth || p.ExternalAuth {
|
||||
if m.officialProviderUsesExternalCredentials() {
|
||||
// Reachable when an existing config is edited: an empty key is the
|
||||
// correct state when credentials are supplied outside the config.
|
||||
return true, ""
|
||||
@@ -1869,7 +1878,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
|
||||
m.formError = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
if m.activeTab == tabOfficial && (m.currentProvider().AmbientAuth || m.currentProvider().ExternalAuth) {
|
||||
if m.activeTab == tabOfficial && m.officialProviderUsesExternalCredentials() {
|
||||
// This provider has no key to collect, so the model step is the last
|
||||
// one. Showing an API-key prompt that must be left blank would read as
|
||||
// a step the user failed to complete.
|
||||
|
||||
@@ -12,9 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func openBrowserURL(ctx context.Context, target string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := "xdg-open"
|
||||
if runtime.GOOS == "darwin" {
|
||||
cmd = "open"
|
||||
}
|
||||
return exec.CommandContext(ctx, cmd, target).Run()
|
||||
// A browser may outlive the login; cancelling the callback must not close it.
|
||||
return exec.Command(cmd, target).Run()
|
||||
}
|
||||
@@ -11,5 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func openBrowserURL(ctx context.Context, target string) error {
|
||||
return exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", target).Run()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Run()
|
||||
}
|
||||
@@ -42,6 +42,10 @@ type keyCache struct {
|
||||
until time.Time
|
||||
}
|
||||
|
||||
type invalidIDTokenError string
|
||||
|
||||
func (e invalidIDTokenError) Error() string { return string(e) }
|
||||
|
||||
func (c *Client) signingKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
c.keys.mu.Lock()
|
||||
defer c.keys.mu.Unlock()
|
||||
@@ -91,10 +95,14 @@ func (c *Client) signingKey(ctx context.Context, kid string) (*rsa.PublicKey, er
|
||||
return &rsa.PublicKey{N: modulus, E: int(exponent)}, nil
|
||||
}
|
||||
func (c *Client) verify(ctx context.Context, token, clientID, nonce string) (identity, error) {
|
||||
return c.verifyAt(ctx, token, clientID, nonce, time.Time{})
|
||||
}
|
||||
|
||||
func (c *Client) verifyAt(ctx context.Context, token, clientID, nonce string, receivedAt time.Time) (identity, error) {
|
||||
var claims identity
|
||||
p := strings.Split(token, ".")
|
||||
if len(p) != 3 || len(token) > 1<<20 {
|
||||
return claims, errors.New("invalid ID token")
|
||||
return claims, invalidIDTokenError("invalid ID token")
|
||||
}
|
||||
decode := func(s string, v any) error {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
@@ -109,7 +117,7 @@ func (c *Client) verify(ctx context.Context, token, clientID, nonce string) (ide
|
||||
Crit []string `json:"crit"`
|
||||
}
|
||||
if err := decode(p[0], &header); err != nil || header.Alg != "RS256" || header.Kid == "" || len(header.Crit) > 0 {
|
||||
return claims, errors.New("invalid ID-token signing header")
|
||||
return claims, invalidIDTokenError("invalid ID-token signing header")
|
||||
}
|
||||
key, err := c.signingKey(ctx, header.Kid)
|
||||
if err != nil {
|
||||
@@ -117,32 +125,37 @@ func (c *Client) verify(ctx context.Context, token, clientID, nonce string) (ide
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(p[2])
|
||||
if err != nil {
|
||||
return claims, errors.New("invalid ID-token signature")
|
||||
return claims, invalidIDTokenError("invalid ID-token signature")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(p[0] + "." + p[1]))
|
||||
if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig) != nil {
|
||||
return claims, errors.New("invalid ID-token signature")
|
||||
return claims, invalidIDTokenError("invalid ID-token signature")
|
||||
}
|
||||
if err := decode(p[1], &claims); err != nil {
|
||||
return claims, errors.New("invalid ID-token claims")
|
||||
return claims, invalidIDTokenError("invalid ID-token claims")
|
||||
}
|
||||
var audiences []string
|
||||
var single string
|
||||
if json.Unmarshal(claims.Audience, &single) == nil {
|
||||
audiences = []string{single}
|
||||
} else if json.Unmarshal(claims.Audience, &audiences) != nil {
|
||||
return claims, errors.New("invalid ID-token audience")
|
||||
return claims, invalidIDTokenError("invalid ID-token audience")
|
||||
}
|
||||
found := false
|
||||
for _, a := range audiences {
|
||||
found = found || a == clientID
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if !receivedAt.IsZero() {
|
||||
// A persisted refresh response was received over the issuer's authenticated
|
||||
// connection. Verify its lifetime at receipt, not after a network outage.
|
||||
now = receivedAt.Unix()
|
||||
}
|
||||
if claims.Issuer != Issuer || claims.Subject == "" || !found || claims.Expires <= now || claims.Issued <= 0 || claims.Issued > now+5 || claims.NotBefore > now+5 || (claims.AuthorizedParty != "" && claims.AuthorizedParty != clientID) || (len(audiences) > 1 && claims.AuthorizedParty != clientID) {
|
||||
return claims, errors.New("ID-token issuer, audience, subject or lifetime did not match")
|
||||
return claims, invalidIDTokenError("ID-token issuer, audience, subject or lifetime did not match")
|
||||
}
|
||||
if nonce != "" && claims.Nonce != nonce {
|
||||
return claims, errors.New("ID-token nonce did not match")
|
||||
return claims, invalidIDTokenError("ID-token nonce did not match")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -90,6 +90,7 @@ func TestLoopbackLogin(t *testing.T) {
|
||||
callback.Set("state", "wrong")
|
||||
case "denied":
|
||||
callback.Set("error", "access_denied")
|
||||
callback.Del("code")
|
||||
case "wrong-identity":
|
||||
f.claims["sub"] = "different"
|
||||
}
|
||||
@@ -102,6 +103,9 @@ func TestLoopbackLogin(t *testing.T) {
|
||||
if err == nil {
|
||||
r.Body.Close()
|
||||
}
|
||||
if scenario == "bad-state" || scenario == "missing-id" || scenario == "changed-id" {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var output bytes.Buffer
|
||||
@@ -439,6 +443,8 @@ func TestCallbackMalformedQueryDoesNotExchangeCode(t *testing.T) {
|
||||
t.Run(suffix, func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
old := openBrowser
|
||||
defer func() { openBrowser = old }()
|
||||
openBrowser = func(_ context.Context, local string) error {
|
||||
@@ -456,12 +462,11 @@ func TestCallbackMalformedQueryDoesNotExchangeCode(t *testing.T) {
|
||||
if r.StatusCode != 400 || strings.Contains(string(body), "sensitive-fixture-code") || strings.Contains(string(body), q.Get("state")) {
|
||||
t.Error("malformed callback response exposed authorization parameters or did not fail")
|
||||
}
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
got, err := f.client.Login(ctx, s, "", false, false, false, io.Discard)
|
||||
if got != nil || err == nil || err.Error() != "invalid OAuth callback query" || f.calls.Load() != 0 {
|
||||
if got != nil || !errors.Is(err, context.Canceled) || f.calls.Load() != 0 {
|
||||
t.Fatal("malformed callback reached code exchange", err)
|
||||
}
|
||||
d, err := s.Snapshot()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package chatgptauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginIgnoresUnrelatedCallbacks(t *testing.T) {
|
||||
for _, query := range []string{"", "state=wrong", "state=STATE&state=STATE", "state=STATE&broken=%zz", "state=STATE", "state=STATE&code=code", "state=STATE&code=a&code=b", "state=STATE&error=denied&error=denied", "state=STATE&code=code&error=denied", "state=STATE&code=&error=access_denied", "state=STATE&code=code&error=&client_id=oaiapp_one"} {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
previous := openBrowser
|
||||
defer func() { openBrowser = previous }()
|
||||
openBrowser = func(_ context.Context, local string) error {
|
||||
q, err := localAuthorization(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invalid := strings.ReplaceAll(query, "STATE", q.Get("state"))
|
||||
r, err := http.Get(q.Get("redirect_uri") + "?" + invalid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != http.StatusBadRequest {
|
||||
return fmt.Errorf("invalid callback status: %d", r.StatusCode)
|
||||
}
|
||||
f.claims["nonce"] = q.Get("nonce")
|
||||
return sendCallback(q, "oaiapp_one")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
got, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard)
|
||||
if err != nil || got == nil || f.calls.Load() != 1 {
|
||||
t.Fatal("valid callback could not complete login", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCompletesWhileBrowserOpenerIsRunning(t *testing.T) {
|
||||
f := fixture(t)
|
||||
previous := openBrowser
|
||||
defer func() { openBrowser = previous }()
|
||||
release, stopped := make(chan struct{}), make(chan struct{})
|
||||
defer func() { close(release); <-stopped }()
|
||||
openBrowser = func(_ context.Context, local string) error {
|
||||
defer close(stopped)
|
||||
q, err := localAuthorization(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.claims["nonce"] = q.Get("nonce")
|
||||
if err := sendCallback(q, "oaiapp_one"); err != nil {
|
||||
return err
|
||||
}
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
got, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard)
|
||||
if err != nil || got == nil || f.calls.Load() != 1 {
|
||||
t.Fatal("browser lifetime blocked login", err)
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("browser opener exited before login completed")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAcceptsMatchingStateDenial(t *testing.T) {
|
||||
f := fixture(t)
|
||||
previous := openBrowser
|
||||
defer func() { openBrowser = previous }()
|
||||
openBrowser = func(_ context.Context, local string) error {
|
||||
q, err := localAuthorization(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := http.Get(q.Get("redirect_uri") + "?" + url.Values{"state": {q.Get("state")}, "error": {"access_denied"}}.Encode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Body.Close()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard)
|
||||
if err == nil || err.Error() != "ChatGPT authorization was declined" || f.calls.Load() != 0 {
|
||||
t.Fatal("matching denial was not consumed", err)
|
||||
}
|
||||
}
|
||||
+124
-46
@@ -193,13 +193,16 @@ type tokenResponse struct {
|
||||
}
|
||||
|
||||
func (t tokenResponse) apply(a *Auth, refresh bool) error {
|
||||
return t.applyAt(a, refresh, time.Now())
|
||||
}
|
||||
|
||||
func (t tokenResponse) applyAt(a *Auth, refresh bool, now time.Time) error {
|
||||
if t.Access == "" || t.Refresh == "" || !strings.EqualFold(t.Type, "Bearer") || t.Expires <= 0 || t.Expires > 86400 {
|
||||
return errors.New("ChatGPT token response is missing credentials, Bearer type or valid expiry")
|
||||
}
|
||||
if !refresh && (t.ID == "" || t.Scope == nil) {
|
||||
return errors.New("ChatGPT sign-in response is missing ID token or granted scopes")
|
||||
}
|
||||
now := time.Now()
|
||||
earliest := time.Time{}
|
||||
if len(t.Earliest) > 0 && string(t.Earliest) != "null" {
|
||||
var seconds int64
|
||||
@@ -396,13 +399,18 @@ func (c *Client) loginAttempt(ctx context.Context, host string, registration, ol
|
||||
return
|
||||
}
|
||||
q, parseErr := url.ParseQuery(r.URL.RawQuery)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if parseErr != nil || len(q["state"]) != 1 || q.Get("state") != a.state || len(q["code"]) > 1 || len(q["client_id"]) > 1 || len(q["error"]) > 1 || (len(q["error"]) > 0 && len(q["code"]) > 0) {
|
||||
http.Error(w, "invalid OAuth callback state or query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, id, callbackErr := a.callback(q, registration)
|
||||
if parseErr != nil {
|
||||
callbackErr = errors.New("invalid OAuth callback query")
|
||||
if callbackErr != nil && q.Get("error") == "" {
|
||||
http.Error(w, "invalid OAuth callback parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
consumed := false
|
||||
once.Do(func() { consumed = true; done <- result{code: code, id: id, err: callbackErr} })
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !consumed {
|
||||
http.Error(w, "callback already consumed", 410)
|
||||
} else if callbackErr != nil {
|
||||
@@ -419,31 +427,42 @@ func (c *Client) loginAttempt(ctx context.Context, host string, registration, ol
|
||||
if _, err := fmt.Fprintln(out, "Continue with ChatGPT. Eligible requests use your ChatGPT plan."); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var browserResult <-chan error
|
||||
if noBrowser {
|
||||
if _, err := fmt.Fprintln(out, "Open this local URL in your browser:", localURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := openBrowser(ctx, localURL); err != nil {
|
||||
return nil, errors.New("could not open browser; retry with --no-browser")
|
||||
} else {
|
||||
opened := make(chan error, 1)
|
||||
opener := openBrowser
|
||||
go func() { opened <- opener(ctx, localURL) }()
|
||||
browserResult = opened
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case err := <-serveErr:
|
||||
return nil, fmt.Errorf("ChatGPT callback server stopped: %w", err)
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
for {
|
||||
select {
|
||||
case err := <-browserResult:
|
||||
browserResult = nil
|
||||
if err != nil {
|
||||
return nil, errors.New("could not open browser; retry with --no-browser")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case err := <-serveErr:
|
||||
return nil, fmt.Errorf("ChatGPT callback server stopped: %w", err)
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
*issued = r.id
|
||||
auth, err := c.exchange(ctx, a, r.code, r.id, old)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
*issued = r.id
|
||||
auth, err := c.exchange(ctx, a, r.code, r.id, old)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +477,25 @@ func terminalRefresh(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var errAccessNeedsRefresh = errors.New("ChatGPT access token needs refresh after verification; retry the request")
|
||||
|
||||
func (a *Auth) refreshDue(now time.Time) bool {
|
||||
if !now.Before(a.ExpiresAt) {
|
||||
return true
|
||||
}
|
||||
return !a.ExpiresAt.After(now.Add(5*time.Minute)) && !now.Before(a.EarliestRefreshAt)
|
||||
}
|
||||
|
||||
func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Auth, error) {
|
||||
auth, err := c.credentials(ctx, s, account)
|
||||
if errors.Is(err, errAccessNeedsRefresh) {
|
||||
return c.credentials(ctx, s, account)
|
||||
}
|
||||
return auth, err
|
||||
}
|
||||
|
||||
func (c *Client) credentials(ctx context.Context, s Store, account string) (*Auth, error) {
|
||||
var result *Auth
|
||||
err := s.withLock(ctx, func() error {
|
||||
d, err := s.load()
|
||||
@@ -469,6 +506,12 @@ func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Aut
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signInRequired := func(reason string) error {
|
||||
return fmt.Errorf("%s; run ocr auth login --provider chatgpt --account %s", reason, auth.ClientID)
|
||||
}
|
||||
if auth.PendingRefresh != nil && auth.PendingRefresh.IdentityError != "" {
|
||||
return signInRequired(auth.PendingRefresh.IdentityError)
|
||||
}
|
||||
if auth.AccessToken == "" {
|
||||
return errors.New("ChatGPT account is signed out; run ocr auth login --provider chatgpt")
|
||||
}
|
||||
@@ -476,7 +519,8 @@ func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Aut
|
||||
return fmt.Errorf("ChatGPT plan usage is disabled; run ocr auth login --provider chatgpt --enable-plan --account %s or select an API-key provider", auth.ClientID)
|
||||
}
|
||||
now := time.Now()
|
||||
if auth.ExpiresAt.After(now.Add(5*time.Minute)) || (!auth.EarliestRefreshAt.IsZero() && now.Before(auth.EarliestRefreshAt) && now.Before(auth.ExpiresAt)) {
|
||||
recoveringPending := auth.PendingRefresh != nil
|
||||
if !recoveringPending && !auth.refreshDue(now) {
|
||||
copy := *auth
|
||||
result = ©
|
||||
return nil
|
||||
@@ -485,34 +529,60 @@ func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Aut
|
||||
return errors.New("ChatGPT refresh token is missing; sign in again")
|
||||
}
|
||||
var t tokenResponse
|
||||
err = c.post(ctx, c.base+"/api/accounts/oauth/token", url.Values{"grant_type": {"refresh_token"}, "client_id": {auth.ClientID}, "refresh_token": {auth.RefreshToken}, "resource": {Resource}}, &t)
|
||||
if err != nil {
|
||||
if terminalRefresh(err) {
|
||||
auth.clear()
|
||||
if saveErr := s.save(d); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
rotated := *auth
|
||||
if t.ID != "" {
|
||||
identity, err := c.verify(ctx, t.ID, auth.ClientID, "")
|
||||
if auth.PendingRefresh != nil {
|
||||
t = auth.PendingRefresh.Response
|
||||
now = auth.PendingRefresh.ReceivedAt
|
||||
} else {
|
||||
err = c.post(ctx, c.base+"/api/accounts/oauth/token", url.Values{"grant_type": {"refresh_token"}, "client_id": {auth.ClientID}, "refresh_token": {auth.RefreshToken}, "resource": {Resource}}, &t)
|
||||
if err != nil {
|
||||
if terminalRefresh(err) {
|
||||
auth.clear()
|
||||
if saveErr := s.save(d); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if identity.Subject != auth.Subject || identity.Issuer != auth.Issuer {
|
||||
return errors.New("refresh changed ChatGPT identity")
|
||||
now = time.Now()
|
||||
}
|
||||
rotated := *auth
|
||||
if err := t.applyAt(&rotated, true, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.ID != "" {
|
||||
// The issuer may already have consumed the old token. Keep the replacement
|
||||
// recoverable, but never expose it for inference before identity verification.
|
||||
if auth.PendingRefresh == nil {
|
||||
auth.PendingRefresh = &pendingRefresh{Response: t, ReceivedAt: now}
|
||||
auth.Revision++
|
||||
if err := s.save(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if identity.Nonce != "" && identity.Nonce != auth.Nonce {
|
||||
return errors.New("refresh changed the original ID-token nonce")
|
||||
identity, err := c.verifyAt(ctx, t.ID, auth.ClientID, "", now)
|
||||
if err == nil && (identity.Subject != auth.Subject || identity.Issuer != auth.Issuer) {
|
||||
err = invalidIDTokenError("refresh changed ChatGPT identity")
|
||||
}
|
||||
if err == nil && identity.Nonce != "" && identity.Nonce != auth.Nonce {
|
||||
err = invalidIDTokenError("refresh changed the original ID-token nonce")
|
||||
}
|
||||
if err != nil {
|
||||
var invalid invalidIDTokenError
|
||||
if errors.As(err, &invalid) {
|
||||
// Retain the latest refresh token for logout, but stop retrying a rejected identity.
|
||||
auth.PendingRefresh.IdentityError = invalid.Error()
|
||||
auth.Revision++
|
||||
if saveErr := s.save(d); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return signInRequired(invalid.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
rotated.Email = identity.Email
|
||||
}
|
||||
if err := t.apply(&rotated, true); err != nil {
|
||||
return err
|
||||
}
|
||||
rotated.Revision++
|
||||
rotated.PendingRefresh = nil
|
||||
rotated.Revision = auth.Revision + 1
|
||||
*auth = rotated
|
||||
if err := s.save(d); err != nil {
|
||||
return err
|
||||
@@ -524,6 +594,10 @@ func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Aut
|
||||
if !rotated.PlanEnabled() {
|
||||
return fmt.Errorf("refreshed ChatGPT grant does not permit plan usage; run ocr auth login --provider chatgpt --enable-plan --account %s", auth.ClientID)
|
||||
}
|
||||
now = time.Now()
|
||||
if !rotated.ExpiresAt.After(now) || (recoveringPending && rotated.refreshDue(now)) {
|
||||
return errAccessNeedsRefresh
|
||||
}
|
||||
result = &rotated
|
||||
return nil
|
||||
})
|
||||
@@ -574,7 +648,11 @@ func (c *Client) Logout(ctx context.Context, s Store, account string) error {
|
||||
}
|
||||
var revokeErr error
|
||||
if a.RefreshToken != "" {
|
||||
revokeErr = c.revokeSession(ctx, a)
|
||||
session := *a
|
||||
if a.PendingRefresh != nil {
|
||||
session.RefreshToken = a.PendingRefresh.Response.Refresh
|
||||
}
|
||||
revokeErr = c.revokeSession(ctx, &session)
|
||||
}
|
||||
a.clear()
|
||||
if err := s.save(d); err != nil {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package chatgptauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshRecoveryAfterVerificationFailure(t *testing.T) {
|
||||
for _, scenario := range []string{"healthy_id_token", "no_id_token", "jwks_unavailable", "jwks_missing_key", "cancel_after_token_response"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
a := testAuth()
|
||||
a.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
saveTest(t, s, a)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rotated, recoverNetwork := false, false
|
||||
refreshCalls, rejectedOld, failedJWKS := 0, 0, 0
|
||||
f.client.http = &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.URL.Path == "/api/accounts/oauth/token" {
|
||||
refreshCalls++
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rotated && r.Form.Get("refresh_token") == a.RefreshToken {
|
||||
rejectedOld++
|
||||
return &http.Response{StatusCode: 400, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant"}`))}, nil
|
||||
}
|
||||
rotated = true
|
||||
tokens := f.tokens()
|
||||
if scenario == "no_id_token" {
|
||||
delete(tokens, "id_token")
|
||||
}
|
||||
body, err := json.Marshal(tokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reader io.ReadCloser = io.NopCloser(strings.NewReader(string(body)))
|
||||
if scenario == "cancel_after_token_response" && !recoverNetwork {
|
||||
reader = cancelBody{Reader: strings.NewReader(string(body)), cancel: cancel}
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: reader}, nil
|
||||
}
|
||||
if r.URL.Path == "/.well-known/jwks.json" && scenario == "jwks_unavailable" && !recoverNetwork {
|
||||
failedJWKS++
|
||||
return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"temporarily_unavailable"}`))}, nil
|
||||
}
|
||||
if r.URL.Path == "/.well-known/jwks.json" && scenario == "jwks_missing_key" && !recoverNetwork {
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"keys":[]}`))}, nil
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
})}
|
||||
first, firstErr := f.client.Credentials(ctx, s, "")
|
||||
needsRecovery := scenario == "jwks_unavailable" || scenario == "jwks_missing_key" || scenario == "cancel_after_token_response"
|
||||
if needsRecovery {
|
||||
if firstErr == nil || first != nil || !rotated {
|
||||
t.Fatalf("fault did not occur after rotation: rotated=%t err=%v", rotated, firstErr)
|
||||
}
|
||||
if scenario == "jwks_unavailable" && failedJWKS != 1 {
|
||||
t.Fatalf("expected one JWKS fault, got %d", failedJWKS)
|
||||
}
|
||||
if scenario == "cancel_after_token_response" && !errors.Is(firstErr, context.Canceled) {
|
||||
t.Fatalf("expected canceled context, got %v", firstErr)
|
||||
}
|
||||
} else if firstErr != nil {
|
||||
t.Fatal(firstErr)
|
||||
}
|
||||
saved, err := s.Selected("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("first_error=%v stored_old_refresh=%t", firstErr, saved.RefreshToken == a.RefreshToken)
|
||||
if needsRecovery {
|
||||
if saved.PendingRefresh == nil || saved.PendingRefresh.Response.Refresh != "refresh-new" || saved.AccessToken != a.AccessToken {
|
||||
t.Fatal("replacement was lost or activated before verification")
|
||||
}
|
||||
}
|
||||
recoverNetwork = true
|
||||
restarted := NewClient()
|
||||
restarted.base, restarted.http = f.client.base, f.client.http
|
||||
next, nextErr := restarted.Credentials(context.Background(), s, "")
|
||||
after, err := s.Selected("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if needsRecovery && next != nil && !next.ExpiresAt.Equal(saved.PendingRefresh.ReceivedAt.Add(time.Hour)) {
|
||||
t.Fatal("recovery extended the original token lifetime")
|
||||
}
|
||||
t.Logf("refresh_calls=%d rejected_old=%d next_error=%v tokens_cleared=%t", refreshCalls, rejectedOld, nextErr, after.AccessToken == "" && after.RefreshToken == "")
|
||||
if nextErr != nil || next == nil || next.AccessToken != "access-new" || next.PendingRefresh != nil || refreshCalls != 1 || rejectedOld != 0 {
|
||||
t.Fatalf("refresh must recover after the temporary validation fault without replaying the consumed token: %v", nextErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingRefreshRejectsUnverifiedIdentity(t *testing.T) {
|
||||
for _, invalid := range []string{"malformed", "signature", "subject", "nonce", "expired_at_receipt"} {
|
||||
t.Run(invalid, func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
a := testAuth()
|
||||
if invalid == "subject" {
|
||||
f.claims["sub"] = "another-account"
|
||||
}
|
||||
if invalid == "nonce" {
|
||||
f.claims["nonce"] = "another-login"
|
||||
}
|
||||
if invalid == "expired_at_receipt" {
|
||||
f.claims["exp"] = time.Now().Add(-time.Minute).Unix()
|
||||
}
|
||||
var response tokenResponse
|
||||
b, err := json.Marshal(f.tokens())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invalid == "signature" {
|
||||
response.ID += "invalid"
|
||||
}
|
||||
if invalid == "malformed" {
|
||||
response.ID = "malformed"
|
||||
}
|
||||
a.PendingRefresh = &pendingRefresh{Response: response, ReceivedAt: time.Now()}
|
||||
saveTest(t, s, a)
|
||||
got, err := f.client.Credentials(context.Background(), s, "")
|
||||
if err == nil || got != nil || f.calls.Load() != 0 {
|
||||
t.Fatal("unverified pending credentials were used", err)
|
||||
}
|
||||
wantRecovery := "ocr auth login --provider chatgpt --account " + a.ClientID
|
||||
if !strings.Contains(err.Error(), wantRecovery) {
|
||||
t.Fatalf("permanent identity failure needs sign-in guidance: %v", err)
|
||||
}
|
||||
firstError := err.Error()
|
||||
saved, err := s.Selected("")
|
||||
if err != nil || saved.AccessToken != a.AccessToken || saved.RefreshToken != a.RefreshToken || saved.PendingRefresh == nil || saved.PendingRefresh.IdentityError == "" {
|
||||
t.Fatal("active identity changed", err)
|
||||
}
|
||||
restarted := NewClient()
|
||||
requests := 0
|
||||
restarted.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
return nil, errors.New("unexpected network request")
|
||||
})
|
||||
if got, err := restarted.Credentials(context.Background(), s, ""); got != nil || err == nil || err.Error() != firstError || requests != 0 {
|
||||
t.Fatal("permanent failure was retried after restart", requests, err)
|
||||
}
|
||||
|
||||
f.claims["sub"], f.claims["exp"] = a.Subject, time.Now().Add(time.Hour).Unix()
|
||||
previous := openBrowser
|
||||
defer func() { openBrowser = previous }()
|
||||
openBrowser = func(_ context.Context, local string) error {
|
||||
q, err := localAuthorization(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.claims["nonce"] = q.Get("nonce")
|
||||
return sendCallback(q, a.ClientID)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if _, err := f.client.Login(ctx, s, "", false, false, false, io.Discard); err != nil {
|
||||
t.Fatal("could not sign in after permanent refresh failure", err)
|
||||
}
|
||||
if got, err := f.client.Credentials(ctx, s, ""); err != nil || got == nil || got.PendingRefresh != nil || got.AccessToken != "access-new" {
|
||||
t.Fatal("sign-in did not restore usable credentials", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingRefreshLogoutAndClear(t *testing.T) {
|
||||
for _, logout := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "logout", false: "clear"}[logout], func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
a := testAuth()
|
||||
a.PendingRefresh = &pendingRefresh{Response: tokenResponse{Refresh: "pending-refresh"}, ReceivedAt: time.Now(), IdentityError: "invalid ID token"}
|
||||
saveTest(t, s, a)
|
||||
transport := f.client.http.Transport
|
||||
revoked := false
|
||||
f.client.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/revoke" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Form.Get("token") != "pending-refresh" {
|
||||
t.Error("revocation used the consumed token")
|
||||
}
|
||||
revoked = true
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||
}
|
||||
return transport.RoundTrip(r)
|
||||
})
|
||||
var err error
|
||||
if logout {
|
||||
err = f.client.Logout(context.Background(), s, "")
|
||||
} else {
|
||||
err = s.Clear(context.Background(), "")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := s.Selected("")
|
||||
if err != nil || saved.PendingRefresh != nil || saved.AccessToken != "" || saved.RefreshToken != "" || revoked != logout {
|
||||
t.Fatal("pending credentials survived clearing", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingRefreshRecoversAfterIDAndAccessExpiry(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
a := testAuth()
|
||||
received := time.Now().Add(-2 * time.Minute)
|
||||
f.claims["iat"] = received.Add(-time.Minute).Unix()
|
||||
f.claims["exp"] = received.Add(time.Minute).Unix()
|
||||
a.PendingRefresh = &pendingRefresh{Response: tokenResponse{Access: "expired-access", Refresh: "refresh-new", ID: f.jwt(), Type: "Bearer", Expires: 60}, ReceivedAt: received}
|
||||
saveTest(t, s, a)
|
||||
f.token = func(r *http.Request) any {
|
||||
if r.Form.Get("refresh_token") != "refresh-new" {
|
||||
t.Error("replayed consumed refresh token")
|
||||
}
|
||||
saved, err := s.Selected("")
|
||||
if err != nil || saved.PendingRefresh != nil || saved.ExpiresAt.After(time.Now()) {
|
||||
t.Error("expired pending token was not committed with its original expiry", err)
|
||||
}
|
||||
f.claims["iat"] = time.Now().Unix()
|
||||
f.claims["exp"] = time.Now().Add(time.Hour).Unix()
|
||||
return f.tokens()
|
||||
}
|
||||
if got, err := f.client.Credentials(context.Background(), s, ""); err != nil || got == nil || got.AccessToken != "access-new" || f.calls.Load() != 1 {
|
||||
t.Fatal("verified replacement could not refresh again", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsRefreshWindow(t *testing.T) {
|
||||
for _, pending := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "verified", true: "pending"}[pending], func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
remaining time.Duration
|
||||
earliest time.Duration
|
||||
refreshCalls int32
|
||||
refreshedLifetime int
|
||||
}{
|
||||
{name: "outside_window", remaining: 10 * time.Minute},
|
||||
{name: "two_minutes", remaining: 2 * time.Minute, refreshCalls: 1},
|
||||
{name: "seconds_remaining", remaining: 15 * time.Second, refreshCalls: 1},
|
||||
{name: "before_earliest", remaining: 2 * time.Minute, earliest: time.Minute},
|
||||
{name: "after_earliest", remaining: 2 * time.Minute, earliest: -time.Minute, refreshCalls: 1},
|
||||
{name: "short_lived_replacement", remaining: 2 * time.Minute, refreshCalls: 1, refreshedLifetime: 120},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := fixture(t)
|
||||
s := testStore(t)
|
||||
a := testAuth()
|
||||
now := time.Now()
|
||||
expiry := now.Add(tc.remaining)
|
||||
a.ExpiresAt = expiry
|
||||
if tc.earliest != 0 {
|
||||
a.EarliestRefreshAt = now.Add(tc.earliest).Truncate(time.Second)
|
||||
}
|
||||
wantAccess, wantRefresh := a.AccessToken, a.RefreshToken
|
||||
if pending {
|
||||
received := now.Add(-10 * time.Minute)
|
||||
f.claims["iat"] = received.Unix()
|
||||
response := tokenResponse{Access: "access-pending", Refresh: "refresh-pending", ID: f.jwt(), Type: "Bearer", Expires: int64(expiry.Sub(received) / time.Second)}
|
||||
if tc.earliest != 0 {
|
||||
response.Earliest, _ = json.Marshal(a.EarliestRefreshAt.Unix())
|
||||
}
|
||||
a.PendingRefresh = &pendingRefresh{Response: response, ReceivedAt: received}
|
||||
a.ExpiresAt = now.Add(-time.Minute)
|
||||
wantAccess, wantRefresh = response.Access, response.Refresh
|
||||
}
|
||||
saveTest(t, s, a)
|
||||
f.token = func(r *http.Request) any {
|
||||
if r.Form.Get("refresh_token") != wantRefresh {
|
||||
t.Error("refresh did not use the latest verified token")
|
||||
}
|
||||
stored, err := s.Selected("")
|
||||
if err != nil || stored.PendingRefresh != nil || stored.AccessToken != wantAccess || !stored.ExpiresAt.Equal(expiry) {
|
||||
t.Error("credentials were not persisted before refreshing", err)
|
||||
}
|
||||
tokens := f.tokens()
|
||||
if tc.refreshedLifetime != 0 {
|
||||
tokens["expires_in"] = tc.refreshedLifetime
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
got, err := f.client.Credentials(context.Background(), s, "")
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("credentials unavailable", err)
|
||||
}
|
||||
if calls := f.calls.Load(); calls != tc.refreshCalls {
|
||||
t.Fatalf("refresh calls = %d, want %d", calls, tc.refreshCalls)
|
||||
}
|
||||
if tc.refreshCalls > 0 {
|
||||
wantAccess, wantRefresh = "access-new", "refresh-new"
|
||||
} else if !got.ExpiresAt.Equal(expiry) || !got.EarliestRefreshAt.Equal(a.EarliestRefreshAt) {
|
||||
t.Fatal("recovery changed the token refresh times")
|
||||
}
|
||||
if got.AccessToken != wantAccess || got.RefreshToken != wantRefresh || got.PendingRefresh != nil {
|
||||
t.Fatal("unexpected credentials returned")
|
||||
}
|
||||
stored, err := s.Selected("")
|
||||
if err != nil || stored.AccessToken != got.AccessToken || stored.RefreshToken != got.RefreshToken || stored.PendingRefresh != nil {
|
||||
t.Fatal("returned credentials were not persisted", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,20 +19,27 @@ import (
|
||||
var ErrNotFound = errors.New("no ChatGPT registration found; run ocr auth login --provider chatgpt")
|
||||
|
||||
type Auth struct {
|
||||
Revision uint64 `json:"revision,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
EarliestRefreshAt time.Time `json:"earliest_refresh_at,omitempty"`
|
||||
Welcomed bool `json:"welcomed,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Revision uint64 `json:"revision,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
EarliestRefreshAt time.Time `json:"earliest_refresh_at,omitempty"`
|
||||
Welcomed bool `json:"welcomed,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
PendingRefresh *pendingRefresh `json:"pending_refresh,omitempty"`
|
||||
}
|
||||
|
||||
type pendingRefresh struct {
|
||||
Response tokenResponse `json:"response"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
IdentityError string `json:"identity_error,omitempty"`
|
||||
}
|
||||
|
||||
func (a Auth) PlanEnabled() bool {
|
||||
@@ -53,6 +60,7 @@ func (a *Auth) clear() {
|
||||
a.Scopes = nil
|
||||
a.ExpiresAt = time.Time{}
|
||||
a.EarliestRefreshAt = time.Time{}
|
||||
a.PendingRefresh = nil
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package llm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChatGPTOnlySubscriptionPreset(t *testing.T) {
|
||||
if _, ok := LookupProvider("codex"); ok {
|
||||
t.Fatal("undocumented Codex preset must not ship")
|
||||
}
|
||||
p, ok := LookupProvider("chatgpt")
|
||||
if !ok {
|
||||
t.Fatal("chatgpt preset not found")
|
||||
}
|
||||
if p.Protocol != ProtocolOpenAIResponses || p.BaseURL != "https://api.openai.com/v1" || p.EnvVar != "" || !p.ExternalAuth || !p.RequiresStreaming || !p.RejectsSamplingParams || len(p.Models) != 0 {
|
||||
t.Fatalf("unexpected chatgpt preset: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestLookupProvider_KnownProviders(t *testing.T) {
|
||||
names := []string{"anthropic", "openai", "openrouter", "dashscope", "edenai"}
|
||||
names := []string{"anthropic", "openai", "openrouter", "dashscope", "edenai", "chatgpt"}
|
||||
for _, name := range names {
|
||||
p, ok := LookupProvider(name)
|
||||
if !ok {
|
||||
@@ -26,12 +26,25 @@ func TestLookupProvider_KnownProviders(t *testing.T) {
|
||||
if p.BaseURL == "" {
|
||||
t.Errorf("LookupProvider(%q).BaseURL is empty", name)
|
||||
}
|
||||
if len(p.Models) == 0 {
|
||||
if len(p.Models) == 0 && name != "chatgpt" {
|
||||
t.Errorf("LookupProvider(%q).Models is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_ChatGPTDetails(t *testing.T) {
|
||||
if _, ok := LookupProvider("codex"); ok {
|
||||
t.Fatal("undocumented Codex preset must not ship")
|
||||
}
|
||||
p, ok := LookupProvider("chatgpt")
|
||||
if !ok {
|
||||
t.Fatal("chatgpt preset not found")
|
||||
}
|
||||
if p.Protocol != ProtocolOpenAIResponses || p.BaseURL != "https://api.openai.com/v1" || p.EnvVar != "" || !p.ExternalAuth || !p.RequiresStreaming || !p.RejectsSamplingParams || len(p.Models) != 0 {
|
||||
t.Fatalf("unexpected chatgpt preset: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_MiniMaxDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, wantURL, wantEnvVar string
|
||||
|
||||
@@ -310,7 +310,12 @@ func (a *responseStreamAccumulator) add(event responses.ResponseStreamEventUnion
|
||||
if err := json.Unmarshal([]byte(event.Response.RawJSON()), &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
return &responseStreamEventError{code: string(event.Response.Error.Code), param: envelope.Error.Param, message: fmt.Sprintf("%s: %s %s; Manage usage: %s", event.Type, event.Response.Error.Message, envelope.Incomplete, chatgptauth.UsageURL)}
|
||||
message := fmt.Sprintf("%s: %s", event.Type, event.Response.Error.Message)
|
||||
if len(envelope.Incomplete) > 0 && string(envelope.Incomplete) != "null" {
|
||||
message += " " + string(envelope.Incomplete)
|
||||
}
|
||||
message += "; Manage usage: " + chatgptauth.UsageURL
|
||||
return &responseStreamEventError{code: string(event.Response.Error.Code), param: envelope.Error.Param, message: message}
|
||||
}
|
||||
a.terminalType = event.Type
|
||||
response := event.Response
|
||||
|
||||
@@ -6,6 +6,7 @@ package llm
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -848,6 +849,30 @@ func TestAccumulateResponseStreamRejectsTerminalBeyondLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatGPTStreamFailureDetails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, eventType, details, want string
|
||||
}{
|
||||
{"failed_missing", "failed", "", "response.failed: rate limited; Manage usage: "},
|
||||
{"failed_null", "failed", `,"incomplete_details":null`, "response.failed: rate limited; Manage usage: "},
|
||||
{"incomplete_null", "incomplete", `,"incomplete_details":null`, "response.incomplete: rate limited; Manage usage: "},
|
||||
{"incomplete_reason", "incomplete", `,"incomplete_details":{"reason":"max_output_tokens"}`, `response.incomplete: rate limited {"reason":"max_output_tokens"}; Manage usage: `},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
events := responseStreamEvents(t, `{"type":"response.`+tc.eventType+`","response":{"id":"resp_failed","status":"`+tc.eventType+`","error":{"code":"rate_limit_exceeded","message":"rate limited","param":"model"}`+tc.details+`}}`)
|
||||
accumulator := responseStreamAccumulator{requireCompleted: true}
|
||||
err := accumulator.add(events[0])
|
||||
var streamErr *responseStreamEventError
|
||||
if !errors.As(err, &streamErr) || streamErr.code != "rate_limit_exceeded" || streamErr.param != "model" {
|
||||
t.Fatal("structured error details were lost", err)
|
||||
}
|
||||
if streamErr.message != tc.want+"https://chatgpt.com/settings/usage" {
|
||||
t.Fatalf("unexpected error message: %s", streamErr.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClient_StreamingIncompleteContract(t *testing.T) {
|
||||
for _, status := range []string{"incomplete", "failed"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
|
||||
@@ -60,7 +60,17 @@ On Unix, credential files use owner-only `0600` permissions and the store direct
|
||||
|
||||
Store mutations and refresh rotation share an operating-system file lock, but browser authorization runs outside it so other accounts remain usable. Completion reloads the store and checks the retained registration's revision. Logout, refresh or another completed login on that registration invalidates the pending result. Unrelated account updates are preserved. Access tokens are refreshed before expiry with rotating refresh tokens.
|
||||
|
||||
Logout attempts to revoke the renewable session, clears access, refresh and ID tokens, and retains the account registration and host ID. Later sign-in omits `id_token_hint` after logout. If remote revocation cannot be confirmed, the CLI reports that local tokens were cleared and directs you to disconnect the app in ChatGPT settings.
|
||||
If a refresh returns new tokens but verification is interrupted by cancellation or a temporary failure to fetch public keys for ID-token verification, OCR keeps the received tokens. Run the original command again. The next request resumes verification, even after restarting the CLI.
|
||||
|
||||
If the refreshed ID token fails signature or identity validation, OCR stops retrying and asks you to sign in again. Run the command shown in the error, using the saved client ID:
|
||||
|
||||
```bash
|
||||
ocr auth login --provider chatgpt --account <saved-client-id>
|
||||
```
|
||||
|
||||
A successful sign-in replaces the rejected credentials.
|
||||
|
||||
Logout attempts to revoke the renewable session using the newest refresh token, including a replacement awaiting ID-token verification. It clears access, refresh and ID tokens, and retains the account registration and host ID. Later sign-in omits `id_token_hint` after logout. If remote revocation cannot be confirmed, the CLI reports that local tokens were cleared and directs you to disconnect the app in ChatGPT settings.
|
||||
|
||||
## Transport and usage
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ preset default — so you only need to set it when your endpoint differs.
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
`bedrock` speaks the same Messages API as `anthropic`, but requests are
|
||||
With its default `anthropic-bedrock` protocol, `bedrock` uses the same Messages API as `anthropic`, but requests are
|
||||
SigV4-signed from the standard AWS credential chain instead of carrying an API
|
||||
key, and the region decides the host. There is no `api_key` to set, and none is
|
||||
accepted as a substitute for a signature:
|
||||
@@ -126,6 +126,8 @@ ocr config set providers.bedrock.aws_region us-west-2
|
||||
ocr config set providers.bedrock.aws_profile example-profile
|
||||
```
|
||||
|
||||
If you set `providers.bedrock.protocol` to `openai` for an OpenAI-compatible gateway, configure the gateway URL in `providers.bedrock.url` and supply its API key through `providers.bedrock.api_key` or `providers.bedrock.api_key_cmd`. Selecting another model in `ocr config provider` preserves the saved API key.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `providers.bedrock.aws_region` | Region whose `bedrock-runtime` host serves the request. Falls back to `AWS_REGION` or the active profile. |
|
||||
@@ -143,8 +145,8 @@ when absent from it. Run `aws bedrock list-inference-profiles --region <region>`
|
||||
to see what an account offers; a version suffix such as `-v1:0` is invalid for
|
||||
the newer families.
|
||||
|
||||
`ocr llm test` reports the region and profile in place of a URL, because bedrock
|
||||
has no configured URL — the region decides the host:
|
||||
With the default `anthropic-bedrock` protocol, `ocr llm test` reports the region
|
||||
and profile in place of a URL. The region determines the host:
|
||||
|
||||
```
|
||||
Source: provider:bedrock
|
||||
|
||||
@@ -58,7 +58,17 @@ Unix では資格情報ファイルに所有者のみ読み書き可能な `0600
|
||||
|
||||
保存内容の変更と refresh token のローテーションには OS ファイルロックを使いますが、ブラウザー認証はロックの外で実行するため、他のアカウントを利用できます。完了時に保存内容を読み直し、登録の revision を確認します。その登録のログアウト、refresh、または別のログイン完了が保留結果を無効にします。別アカウントの更新は保持されます。access token は期限前にローテーション refresh token で更新します。
|
||||
|
||||
ログアウトは更新可能なセッションの失効を試み、access、refresh、ID token を消去し、アカウント登録とホスト ID は保持します。ログアウト後の再ログインでは `id_token_hint` を送信しません。サーバー側の失効を確認できない場合、CLI はローカル token を消去したことを表示し、ChatGPT 設定からアプリを切断するよう案内します。
|
||||
更新で新しいトークンを受信した後、リクエストのキャンセルや署名検証用の公開鍵を一時的に取得できないことにより検証が中断された場合、OCR は受信済みのトークンを保持します。元のコマンドを再実行してください。CLI を再起動した場合も、次のリクエストで検証を再開します。
|
||||
|
||||
更新時に返された ID トークンの署名やアカウントの同一性の検証に失敗した場合、OCR は再試行を停止し、再ログインを求めます。エラーに表示されたクライアント ID を使い、次のコマンドを実行してください。
|
||||
|
||||
```bash
|
||||
ocr auth login --provider chatgpt --account <saved-client-id>
|
||||
```
|
||||
|
||||
ログインに成功すると、検証に失敗した資格情報が新しい資格情報に置き換わります。
|
||||
|
||||
ログアウトでは最新の refresh token を使って更新可能なセッションの失効を試みます。ID トークンの検証待ちで保持している新しい refresh token も対象です。その後、access、refresh、ID token を消去し、アカウント登録とホスト ID は保持します。ログアウト後の再ログインでは `id_token_hint` を送信しません。サーバー側の失効を確認できない場合、CLI はローカル token を消去したことを表示し、ChatGPT 設定からアプリを切断するよう案内します。
|
||||
|
||||
## 通信と利用状況
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
`bedrock` は `anthropic` と同じ Messages API を話しますが、API key を持たせる
|
||||
`bedrock` は既定の `anthropic-bedrock` プロトコルで `anthropic` と同じ Messages API を使いますが、API key を持たせる
|
||||
代わりに標準の AWS 認証情報チェーンから SigV4 署名を行い、ホストはリージョンが
|
||||
決めます。設定すべき `api_key` はなく、署名の代わりとして受け付けることも
|
||||
ありません。
|
||||
@@ -124,6 +124,8 @@ ocr config set providers.bedrock.aws_region us-west-2
|
||||
ocr config set providers.bedrock.aws_profile example-profile
|
||||
```
|
||||
|
||||
OpenAI 互換ゲートウェイに接続するために `providers.bedrock.protocol` を `openai` に設定する場合は、`providers.bedrock.url` にゲートウェイの URL を設定し、`providers.bedrock.api_key` または `providers.bedrock.api_key_cmd` でその API キーを指定してください。`ocr config provider` でモデルを変更しても、保存済みの API キーは保持されます。
|
||||
|
||||
| フィールド | 意味 |
|
||||
|---|---|
|
||||
| `providers.bedrock.aws_region` | リクエストを処理する `bedrock-runtime` ホストのリージョン。未設定なら `AWS_REGION` または有効なプロファイルにフォールバックします。 |
|
||||
@@ -140,7 +142,7 @@ ocr config set providers.bedrock.aws_profile example-profile
|
||||
そのアカウントが提供するものを確認できます。なお新しいファミリーでは
|
||||
`-v1:0` のようなバージョンサフィックスは無効です。
|
||||
|
||||
bedrock には設定された URL がなく、ホストはリージョンが決めるため、
|
||||
既定の `anthropic-bedrock` プロトコルではホストがリージョンによって決まるため、
|
||||
`ocr llm test` は URL の代わりにリージョンとプロファイルを表示します。
|
||||
|
||||
```
|
||||
|
||||
@@ -58,7 +58,17 @@ Unix에서는 자격 증명 파일에 소유자만 읽고 쓸 수 있는 `0600`,
|
||||
|
||||
저장소 변경 및 갱신 토큰 회전에는 OS 파일 잠금을 사용하지만 브라우저 인증은 잠금 바깥에서 진행되어 다른 계정을 계속 사용할 수 있습니다. 완료 시 저장소를 다시 읽고 등록 revision을 확인합니다. 해당 등록의 로그아웃, 갱신 또는 다른 로그인 완료는 대기 중 결과를 무효화합니다. 다른 계정의 업데이트는 보존됩니다. 만료 전에 회전형 갱신 토큰으로 액세스 토큰을 갱신합니다.
|
||||
|
||||
로그아웃은 갱신 가능한 세션의 폐기를 시도하고 액세스, 갱신 및 ID 토큰을 지우며 계정 등록과 호스트 ID는 보존합니다. 로그아웃 후 로그인은 `id_token_hint`를 보내지 않습니다. 원격 폐기를 확인할 수 없으면 CLI가 로컬 토큰을 지웠음을 알리고 ChatGPT 설정에서 앱 연결을 해제하도록 안내합니다.
|
||||
갱신으로 새 토큰을 받은 뒤 요청 취소나 일시적인 서명 검증용 공개 키 조회 실패로 검증이 중단되면 OCR은 받은 토큰을 보관합니다. 원래 명령을 다시 실행하세요. CLI를 재시작했더라도 다음 요청에서 검증을 이어갑니다.
|
||||
|
||||
갱신 응답의 ID 토큰이 서명 또는 계정 신원 검증에 실패하면 OCR은 재시도를 멈추고 다시 로그인하도록 안내합니다. 오류 메시지에 표시된 클라이언트 ID로 다음 명령을 실행하세요.
|
||||
|
||||
```bash
|
||||
ocr auth login --provider chatgpt --account <saved-client-id>
|
||||
```
|
||||
|
||||
로그인에 성공하면 검증에 실패한 자격 증명이 새 자격 증명으로 교체됩니다.
|
||||
|
||||
로그아웃은 최신 갱신 토큰으로 갱신 가능한 세션의 폐기를 시도합니다. ID 토큰 검증을 기다리는 동안 보관한 새 갱신 토큰도 사용합니다. 이후 액세스, 갱신 및 ID 토큰을 지우며 계정 등록과 호스트 ID는 보존합니다. 로그아웃 후 로그인은 `id_token_hint`를 보내지 않습니다. 원격 폐기를 확인할 수 없으면 CLI가 로컬 토큰을 지웠음을 알리고 ChatGPT 설정에서 앱 연결을 해제하도록 안내합니다.
|
||||
|
||||
## 전송 및 사용량
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1
|
||||
|
||||
### AWS Bedrock {#aws-bedrock}
|
||||
|
||||
`bedrock`은 `anthropic`과 같은 Messages API를 사용하지만, 요청에 API 키를 싣는 대신 표준 AWS 자격 증명 체인으로 SigV4 서명을 하며 호스트는 리전이 결정합니다. 설정할 `api_key`가 없고, 서명을 대신할 키도 받지 않습니다:
|
||||
`bedrock`은 기본 `anthropic-bedrock` 프로토콜에서 `anthropic`과 같은 Messages API를 사용하지만, 요청에 API 키를 싣는 대신 표준 AWS 자격 증명 체인으로 SigV4 서명을 하며 호스트는 리전이 결정합니다. 설정할 `api_key`가 없고, 서명을 대신할 키도 받지 않습니다:
|
||||
|
||||
```bash
|
||||
ocr config set provider bedrock
|
||||
@@ -114,6 +114,8 @@ ocr config set providers.bedrock.aws_region us-west-2
|
||||
ocr config set providers.bedrock.aws_profile example-profile
|
||||
```
|
||||
|
||||
OpenAI 호환 게이트웨이에 연결하려고 `providers.bedrock.protocol`을 `openai`로 설정했다면, `providers.bedrock.url`에 게이트웨이 URL을 설정하고 `providers.bedrock.api_key` 또는 `providers.bedrock.api_key_cmd`로 게이트웨이의 API 키를 제공하세요. `ocr config provider`에서 모델을 변경해도 저장된 API 키는 유지됩니다.
|
||||
|
||||
| 필드 | 의미 |
|
||||
|---|---|
|
||||
| `providers.bedrock.aws_region` | 요청을 처리할 `bedrock-runtime` 호스트의 리전. 비어 있으면 `AWS_REGION`이나 활성 프로필로 대체합니다. |
|
||||
@@ -123,7 +125,7 @@ ocr config set providers.bedrock.aws_profile example-profile
|
||||
|
||||
모델 식별자는 계정 **과** 리전 단위로 정해지므로 OCR이 제공하는 목록은 닫힌 집합이 아니라 출발점입니다. 목록에 없더라도 계정에서 유효한 추론 프로필 ID나 애플리케이션 추론 프로필 ARN이면 그대로 받습니다. 계정에서 무엇을 쓸 수 있는지는 `aws bedrock list-inference-profiles --region <region>`으로 확인하세요. 최신 계열에서는 `-v1:0` 같은 버전 접미사가 유효하지 않습니다.
|
||||
|
||||
bedrock에는 설정된 URL이 없고 호스트를 리전이 결정하므로, `ocr llm test`는 URL 대신 리전과 프로필을 표시합니다:
|
||||
기본 `anthropic-bedrock` 프로토콜에서는 리전이 호스트를 결정하므로, `ocr llm test`는 URL 대신 리전과 프로필을 표시합니다:
|
||||
|
||||
```
|
||||
Source: provider:bedrock
|
||||
|
||||
@@ -58,7 +58,17 @@ ocr auth logout --provider chatgpt --account <saved-client-id>
|
||||
|
||||
Изменения хранилища и ротация refresh-токена используют блокировку ОС, но браузерная авторизация выполняется вне блокировки, чтобы другие аккаунты оставались доступны. При завершении хранилище перечитывается и проверяется версия регистрации. Выход, обновление токена или другой завершенный вход для этой регистрации делают ожидающий результат недействительным. Изменения других аккаунтов сохраняются. Access-токены обновляются до истечения срока с помощью ротируемых refresh-токенов.
|
||||
|
||||
При выходе предпринимается попытка отозвать обновляемую сессию, access-, refresh- и ID-токены удаляются, а регистрация аккаунта и идентификатор хоста сохраняются. После выхода при следующем входе `id_token_hint` не отправляется. Если удаленный отзыв подтвердить нельзя, CLI сообщает об удалении локальных токенов и предлагает отключить приложение в настройках ChatGPT.
|
||||
Если при обновлении получены новые токены, но проверка прервалась из-за отмены запроса или временной недоступности открытых ключей проверки подписи, OCR сохраняет полученные токены. Повторите исходную команду. Проверка продолжится при следующем запросе, в том числе после перезапуска CLI.
|
||||
|
||||
Если подпись или данные учетной записи в новом ID-токене не проходят проверку, OCR прекращает повторные попытки и предлагает войти снова. Выполните команду из сообщения об ошибке с сохраненным идентификатором клиента:
|
||||
|
||||
```bash
|
||||
ocr auth login --provider chatgpt --account <saved-client-id>
|
||||
```
|
||||
|
||||
После успешного входа новые учетные данные заменят те, которые не прошли проверку.
|
||||
|
||||
При выходе OCR пытается отозвать обновляемую сессию с помощью самого нового refresh-токена, включая токен, полученный до завершения проверки ID-токена. Затем access-, refresh- и ID-токены удаляются, а регистрация аккаунта и идентификатор хоста сохраняются. После выхода при следующем входе `id_token_hint` не отправляется. Если удаленный отзыв подтвердить нельзя, CLI сообщает об удалении локальных токенов и предлагает отключить приложение в настройках ChatGPT.
|
||||
|
||||
## Передача запросов и лимиты
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
`bedrock` использует тот же Messages API, что и `anthropic`, но запросы
|
||||
По умолчанию `bedrock` использует протокол `anthropic-bedrock` и тот же Messages API, что и `anthropic`, но запросы
|
||||
подписываются по SigV4 из стандартной цепочки учётных данных AWS вместо
|
||||
передачи API-ключа, а хост определяется регионом. Задавать `api_key` не нужно,
|
||||
и он не принимается как замена подписи:
|
||||
@@ -132,6 +132,8 @@ ocr config set providers.bedrock.aws_region us-west-2
|
||||
ocr config set providers.bedrock.aws_profile example-profile
|
||||
```
|
||||
|
||||
Если для подключения к OpenAI-совместимому шлюзу вы задаете `providers.bedrock.protocol` значение `openai`, укажите адрес шлюза в `providers.bedrock.url`, а его API-ключ — через `providers.bedrock.api_key` или `providers.bedrock.api_key_cmd`. При выборе другой модели в `ocr config provider` сохраненный API-ключ остается на месте.
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---|
|
||||
| `providers.bedrock.aws_region` | Регион, чей хост `bedrock-runtime` обслуживает запрос. По умолчанию — `AWS_REGION` или активный профиль. |
|
||||
@@ -149,8 +151,8 @@ ocr config set providers.bedrock.aws_profile example-profile
|
||||
`aws bedrock list-inference-profiles --region <region>`, чтобы увидеть, что
|
||||
доступно в аккаунте; суффикс версии вроде `-v1:0` недопустим для новых семейств.
|
||||
|
||||
`ocr llm test` показывает регион и профиль вместо URL, потому что у bedrock нет
|
||||
настроенного URL — хост определяется регионом:
|
||||
При использовании протокола по умолчанию `anthropic-bedrock` команда `ocr llm test`
|
||||
показывает регион и профиль вместо URL. Хост определяется регионом:
|
||||
|
||||
```
|
||||
Source: provider:bedrock
|
||||
|
||||
@@ -58,7 +58,17 @@ ocr auth logout --provider chatgpt --account <saved-client-id>
|
||||
|
||||
存储变更和刷新令牌轮换使用操作系统文件锁;浏览器授权在锁外运行,以便其他账户继续使用。完成时会重新读取存储,并检查原注册版本。该注册上的退出、刷新或其他已完成登录会使待处理结果失效;其他账户的更新会保留。访问令牌会在过期前使用轮换刷新令牌续期。
|
||||
|
||||
退出登录会尝试撤销可续期会话、清除访问令牌、刷新令牌和 ID token,并保留账户注册及主机 ID。退出后的后续登录不会发送 `id_token_hint`。如果无法确认远端撤销,CLI 会说明本地令牌已清除,并提示在 ChatGPT 设置中断开应用。
|
||||
如果刷新已返回新令牌,但验证因请求取消或暂时无法获取验证签名所需的公钥而中断,OCR 会保留收到的令牌。重新运行原命令即可。下次请求会继续验证,重启 CLI 后也一样。
|
||||
|
||||
如果刷新返回的 ID token 未通过签名或身份验证,OCR 会停止重试并提示重新登录。请按错误提示,使用已保存的客户端 ID 运行:
|
||||
|
||||
```bash
|
||||
ocr auth login --provider chatgpt --account <saved-client-id>
|
||||
```
|
||||
|
||||
登录成功后,新凭证会替换验证失败的凭证。
|
||||
|
||||
退出登录会使用最新的刷新令牌尝试撤销可续期会话,包括 ID token 尚待验证时收到的新刷新令牌。随后清除访问令牌、刷新令牌和 ID token,并保留账户注册及主机 ID。退出后的后续登录不会发送 `id_token_hint`。如果无法确认远端撤销,CLI 会说明本地令牌已清除,并提示在 ChatGPT 设置中断开应用。
|
||||
|
||||
## 传输与用量
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
`bedrock` 使用与 `anthropic` 相同的 Messages API,但请求不携带 API key,而是
|
||||
`bedrock` 默认使用 `anthropic-bedrock` 协议,调用与 `anthropic` 相同的 Messages API,但请求不携带 API key,而是
|
||||
用标准 AWS 凭证链做 SigV4 签名,主机由区域决定。没有 `api_key` 需要设置,也不
|
||||
接受用它替代签名:
|
||||
|
||||
@@ -120,6 +120,8 @@ ocr config set providers.bedrock.aws_region us-west-2
|
||||
ocr config set providers.bedrock.aws_profile example-profile
|
||||
```
|
||||
|
||||
如果将 `providers.bedrock.protocol` 设为 `openai` 来连接 OpenAI 兼容网关,请在 `providers.bedrock.url` 中配置网关地址,并通过 `providers.bedrock.api_key` 或 `providers.bedrock.api_key_cmd` 提供网关的 API key。在 `ocr config provider` 中切换模型会保留已保存的 API key。
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `providers.bedrock.aws_region` | 处理请求的 `bedrock-runtime` 主机所在区域。未设置时回退到 `AWS_REGION` 或当前 profile。 |
|
||||
@@ -134,7 +136,7 @@ ocr config set providers.bedrock.aws_profile example-profile
|
||||
会被接受。运行 `aws bedrock list-inference-profiles --region <region>` 可以查看
|
||||
账号提供了哪些;注意新系列不接受 `-v1:0` 这类版本后缀。
|
||||
|
||||
bedrock 没有配置的 URL——主机由区域决定——所以 `ocr llm test` 显示区域和 profile
|
||||
使用默认的 `anthropic-bedrock` 协议时,主机由区域决定,`ocr llm test` 显示区域和 profile
|
||||
而不是 URL:
|
||||
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user