Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ remote-management:
# Authentication directory (supports ~ for home directory)
auth-dir: "~/.cli-proxy-api"

# OAuth auth files may optionally include base_url or base-url to override the
# upstream endpoint for that account only. This is useful for routing one OAuth
# account through a local gateway while leaving other accounts on the default
# provider endpoint. Example auth file field:
# "base_url": "http://127.0.0.1:18081"

# API keys for authentication
api-keys:
- "your-api-key-1"
Expand Down Expand Up @@ -106,6 +112,13 @@ redis-usage-queue-retention-seconds: 60
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
proxy-url: ""

# Provider-wide upstream base URL overrides for OAuth/file-backed credentials.
# Per-auth-file base_url/base-url values and per-api-key base-url values take precedence.
# Empty keeps the official upstream defaults.
# claude-base-url: "http://127.0.0.1:18081"
# codex-base-url: "http://127.0.0.1:18082"


# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
force-model-prefix: false

Expand Down
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ type Config struct {
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`

// CodexBaseURL globally overrides the upstream Codex API endpoint for
// OAuth/file-backed credentials that do not set their own base URL.
// Per-credential base URLs still take precedence.
CodexBaseURL string `yaml:"codex-base-url,omitempty" json:"codex-base-url,omitempty"`

// Codex configures provider-wide Codex request behavior.
Codex CodexConfig `yaml:"codex" json:"codex"`

Expand All @@ -132,6 +137,11 @@ type Config struct {
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`

// ClaudeBaseURL globally overrides the upstream Claude API endpoint for
// OAuth/file-backed credentials that do not set their own base URL.
// Per-credential base URLs still take precedence.
ClaudeBaseURL string `yaml:"claude-base-url,omitempty" json:"claude-base-url,omitempty"`

// ClaudeHeaderDefaults configures default header values for Claude API requests.
// These are used as fallbacks when the client does not send its own headers.
ClaudeHeaderDefaults ClaudeHeaderDefaults `yaml:"claude-header-defaults" json:"claude-header-defaults"`
Expand Down
23 changes: 18 additions & 5 deletions internal/runtime/executor/claude_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au
if req == nil {
return nil
}
apiKey, _ := claudeCreds(auth)
apiKey, _ := claudeCredsWithConfig(e.cfg, auth)
if strings.TrimSpace(apiKey) == "" {
return nil
}
Expand Down Expand Up @@ -201,7 +201,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := claudeCreds(auth)
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
Expand Down Expand Up @@ -393,7 +393,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := claudeCreds(auth)
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
Expand Down Expand Up @@ -675,7 +675,7 @@ func validateClaudeStreamingResponse(data []byte) error {
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := claudeCreds(auth)
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
Expand Down Expand Up @@ -1135,12 +1135,25 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
}

func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
return claudeCredsWithConfig(nil, a)
}

func claudeCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
apiKey = a.Attributes["api_key"]
baseURL = a.Attributes["base_url"]
baseURL = strings.TrimSpace(a.Attributes["base_url"])
}
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil {
baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata)
}
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil {
baseURL = strings.TrimSpace(cfg.ClaudeBaseURL)
Comment on lines +1147 to +1153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trim Claude override URLs before request construction

When a newly-supported OAuth override is configured with a trailing slash, e.g. claude-base-url: http://proxy/ or an auth-file base_url, this returns that slash unchanged; the Claude request builders later concatenate fmt.Sprintf("%s/v1/messages?beta=true", baseURL) and .../count_tokens, producing //v1/.... Gateways with exact routing on /v1/messages will 404, while the Codex paths already use strings.TrimSuffix, so normalize the Claude override before returning it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor metadata base URLs before the global fallback

When an OAuth Auth reaches this helper with base_url/base-url still only in Metadata (for example direct SDK registrations or Home refresh payloads, which bypass the file-store/synthesizer copy step), baseURL is still empty here and this line replaces it with cfg.ClaudeBaseURL. That breaks the documented per-auth override precedence for those supported metadata-backed auth paths; check cliproxyauth.BaseURLFromMetadata(a.Metadata) before falling back to the provider-wide value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2357dd5. @chatgpt-codex-connector please review again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 2357dd51d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2357dd5. @chatgpt-codex-connector please review again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 2357dd51d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}
if baseURL != "" {
baseURL = normalizeClaudeBaseURL(baseURL)
}
if apiKey == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
Expand Down
79 changes: 79 additions & 0 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2887,3 +2887,82 @@ func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testin
t.Fatalf("Glob should be restored to glob, got: %s", string(out))
}
}

func TestClaudeCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) {
apiKey, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
Metadata: map[string]any{"access_token": "oauth-token"},
})
if apiKey != "oauth-token" {
t.Fatalf("apiKey = %q, want oauth-token", apiKey)
}
if baseURL != "http://127.0.0.1:18081" {
t.Fatalf("baseURL = %q, want global override", baseURL)
}
}

func TestClaudeCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) {
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
Attributes: map[string]string{"base_url": "http://127.0.0.1:18101"},
Metadata: map[string]any{"access_token": "oauth-token"},
})
if baseURL != "http://127.0.0.1:18101" {
t.Fatalf("baseURL = %q, want auth override", baseURL)
}
}

func TestClaudeCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) {
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
Metadata: map[string]any{"access_token": "oauth-token", "base-url": "http://127.0.0.1:18103/"},
})
if baseURL != "http://127.0.0.1:18103" {
t.Fatalf("baseURL = %q, want metadata override", baseURL)
}
}

func TestClaudeCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) {
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
Attributes: map[string]string{"api_key": "sk-ant-test"},
})
if baseURL != "" {
t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL)
}
}

func TestClaudeCredsTrimsTrailingSlashFromBaseURLOverrides(t *testing.T) {
_, authBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{
Attributes: map[string]string{"base_url": "http://127.0.0.1:18101/"},
Metadata: map[string]any{"access_token": "oauth-token"},
})
if authBaseURL != "http://127.0.0.1:18101" {
t.Fatalf("auth baseURL = %q, want trimmed trailing slash", authBaseURL)
}

_, globalBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{
Metadata: map[string]any{"access_token": "oauth-token"},
})
if globalBaseURL != "http://127.0.0.1:18081" {
t.Fatalf("global baseURL = %q, want trimmed trailing slash", globalBaseURL)
}
}

func TestResolveClaudeKeyConfigNormalizesTrailingSlashForBaseURLMatch(t *testing.T) {
cfg := &config.Config{
ClaudeKey: []config.ClaudeKey{{
APIKey: "key-123",
BaseURL: "http://127.0.0.1:18101/",
Cloak: &config.CloakConfig{Mode: "always"},
}},
}
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"api_key": "key-123",
"base_url": "http://127.0.0.1:18101/",
}}

cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
if cloakCfg == nil {
t.Fatal("expected trailing-slash base URL to match Claude key config")
}
if cloakCfg.Mode != "always" {
t.Fatalf("cloak mode = %q, want always", cloakCfg.Mode)
}
}
6 changes: 5 additions & 1 deletion internal/runtime/executor/claude_signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config
for i := range cfg.ClaudeKey {
entry := &cfg.ClaudeKey[i]
cfgKey := strings.TrimSpace(entry.APIKey)
cfgBase := strings.TrimSpace(entry.BaseURL)
cfgBase := normalizeClaudeBaseURL(entry.BaseURL)
if !strings.EqualFold(cfgKey, apiKey) {
continue
}
Expand All @@ -66,6 +66,10 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config
return nil
}

func normalizeClaudeBaseURL(baseURL string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
}

// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
entry := resolveClaudeKeyConfig(cfg, auth)
Expand Down
20 changes: 15 additions & 5 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut
if req == nil {
return nil
}
apiKey, _ := codexCreds(auth)
apiKey, _ := codexCredsWithConfig(e.cfg, auth)
if strings.TrimSpace(apiKey) != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
Expand Down Expand Up @@ -747,7 +747,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -927,7 +927,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -1034,7 +1034,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -1853,12 +1853,22 @@ func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time
}

func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
return codexCredsWithConfig(nil, a)
}
Comment on lines 1855 to +1857

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route Codex websocket/image paths through override

This legacy helper is still used by Codex paths that were not updated, so they cannot see codex-base-url: I checked CodexWebsocketsExecutor.Execute/ExecuteStream and the Codex OpenAI image handlers, which still call codexCreds(auth). With an OAuth Codex auth file that has no per-file base_url and a provider-wide codex-base-url, normal Codex execute/stream/compact requests use the override, but downstream WebSocket and image requests still fall back to https://chatgpt.com/backend-api/codex, bypassing the configured gateway.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f0f640. @chatgpt-codex-connector please review again.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex revicew


func codexCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
apiKey = a.Attributes["api_key"]
baseURL = a.Attributes["base_url"]
baseURL = strings.TrimSpace(a.Attributes["base_url"])
}
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil {
baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata)
}
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil {
baseURL = strings.TrimSpace(cfg.CodexBaseURL)
}
if apiKey == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
Expand Down
48 changes: 48 additions & 0 deletions internal/runtime/executor/codex_oauth_base_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package executor

import (
"testing"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)

func TestCodexCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) {
apiKey, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
Metadata: map[string]any{"access_token": "oauth-token"},
})
if apiKey != "oauth-token" {
t.Fatalf("apiKey = %q, want oauth-token", apiKey)
}
if baseURL != "http://127.0.0.1:18082" {
t.Fatalf("baseURL = %q, want global override", baseURL)
}
}

func TestCodexCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) {
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
Attributes: map[string]string{"base_url": "http://127.0.0.1:18102"},
Metadata: map[string]any{"access_token": "oauth-token"},
})
if baseURL != "http://127.0.0.1:18102" {
t.Fatalf("baseURL = %q, want auth override", baseURL)
}
}

func TestCodexCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) {
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
Metadata: map[string]any{"access_token": "oauth-token", "base_url": "http://127.0.0.1:18104"},
})
if baseURL != "http://127.0.0.1:18104" {
t.Fatalf("baseURL = %q, want metadata override", baseURL)
}
}

func TestCodexCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) {
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
Attributes: map[string]string{"api_key": "sk-codex-test"},
})
if baseURL != "" {
t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL)
}
}
8 changes: 4 additions & 4 deletions internal/runtime/executor/codex_openai_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
return resp, errPrepare
}

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -187,7 +187,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
return nil, errPrepare
}

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -319,7 +319,7 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip
return resp, errPrepare
}

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down Expand Up @@ -379,7 +379,7 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth
return nil, errPrepare
}

apiKey, baseURL := codexCreds(auth)
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
Expand Down
23 changes: 23 additions & 0 deletions internal/runtime/executor/codex_openai_images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,26 @@ func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForMultipart(t
t.Fatalf("mask.image_url = %q, want mask-data data URL; body=%s", maskURL, string(gotBody))
}
}

func TestCodexExecutorDirectOpenAIImageGenerationUsesGlobalBaseURLForOAuthAuth(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}],"usage":{"total_tokens":1,"input_tokens":1,"output_tokens":0}}`))
}))
defer server.Close()

executor := NewCodexExecutor(&config.Config{CodexBaseURL: server.URL})
auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"access_token": "oauth-token"}}
_, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "codex/gpt-image-1.5",
Payload: []byte(`{"model":"codex/gpt-image-1.5","prompt":"draw","stream":false}`),
}, codexOpenAIImageTestOptions(codexImagesGenerationsPath, false))
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotPath != "/images/generations" {
t.Fatalf("path = %q, want /images/generations", gotPath)
}
}
Loading
Loading