Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[codex] add Codex OAuth provider (#45)

* fix: skip managed runtimes with missing tokens

* feat: add codex oauth provider

* feat: add Codex OAuth support

* feat: refine Codex OAuth console auth

* fix: skip missing managed channel tokens

* fix: codex oauth refresh and status

authored by

Lyric Wai and committed by
GitHub
37221a73 98541cf4

+4468 -184
+1 -1
assets/config/config.example.yaml
··· 14 14 user_agent: "mistermorph/1.0 (+https://github.com/quailyquaily)" 15 15 16 16 llm: 17 - # LLM provider name. Supported: openai|openai_resp|openai_custom|deepseek|xai|gemini|azure|anthropic|bedrock|susanoo|cloudflare. 17 + # LLM provider name. Supported: openai|openai_resp|openai_custom|openai_codex|deepseek|xai|gemini|azure|anthropic|bedrock|susanoo|cloudflare. 18 18 provider: openai 19 19 # Default model used by the main agent loop. 20 20 model: "gpt-5.4"
+313
cmd/mistermorph/auth_codex.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "time" 11 + 12 + "github.com/quailyquaily/mistermorph/internal/codexauth" 13 + "github.com/quailyquaily/mistermorph/internal/configbootstrap" 14 + "github.com/quailyquaily/mistermorph/internal/fsstore" 15 + "github.com/quailyquaily/mistermorph/internal/pathutil" 16 + "github.com/spf13/cobra" 17 + "github.com/spf13/viper" 18 + "gopkg.in/yaml.v3" 19 + ) 20 + 21 + type codexLoginOptions struct { 22 + SetDefault bool 23 + } 24 + 25 + type codexLoginRuntimeConfig struct { 26 + Provider string 27 + Endpoint string 28 + APIKey string 29 + CloudflareAccountID string 30 + CloudflareAPIToken string 31 + } 32 + 33 + func newAuthCmd() *cobra.Command { 34 + cmd := &cobra.Command{ 35 + Use: "auth", 36 + Short: "Manage local auth credentials", 37 + } 38 + cmd.AddCommand(newCodexAuthCmd()) 39 + return cmd 40 + } 41 + 42 + func newCodexAuthCmd() *cobra.Command { 43 + cmd := &cobra.Command{ 44 + Use: "codex", 45 + Short: "Manage OpenAI Codex OAuth login", 46 + } 47 + var loginOpts codexLoginOptions 48 + loginCmd := &cobra.Command{ 49 + Use: "login", 50 + Short: "Sign in with OpenAI Codex OAuth device code", 51 + RunE: func(cmd *cobra.Command, args []string) error { 52 + return runCodexLogin(cmd.Context(), loginOpts) 53 + }, 54 + } 55 + loginCmd.Flags().BoolVar(&loginOpts.SetDefault, "set-default", false, "Set llm.provider to openai_codex after login even when existing LLM credentials are configured.") 56 + cmd.AddCommand(loginCmd) 57 + cmd.AddCommand(&cobra.Command{ 58 + Use: "status", 59 + Short: "Show OpenAI Codex OAuth status", 60 + RunE: func(cmd *cobra.Command, args []string) error { 61 + return runCodexStatus() 62 + }, 63 + }) 64 + cmd.AddCommand(&cobra.Command{ 65 + Use: "logout", 66 + Short: "Delete local OpenAI Codex OAuth token", 67 + RunE: func(cmd *cobra.Command, args []string) error { 68 + return runCodexLogout() 69 + }, 70 + }) 71 + return cmd 72 + } 73 + 74 + func runCodexLogin(ctx context.Context, opts codexLoginOptions) error { 75 + stateDir := strings.TrimSpace(viper.GetString("file_state_dir")) 76 + cfg := codexauth.DefaultOAuthConfigValue() 77 + deviceCode, err := codexauth.RequestDeviceCode(ctx, cfg) 78 + if err != nil { 79 + return err 80 + } 81 + 82 + fmt.Fprintf(os.Stdout, "Open this URL and enter the code:\n\n%s\n\nCode: %s\nExpires: %s\n\n", deviceCode.VerificationURL, deviceCode.UserCode, deviceCode.ExpiresAt.Format(time.RFC3339)) 83 + fmt.Fprintln(os.Stdout, "Waiting for authorization...") 84 + 85 + interval := deviceCode.Interval 86 + if interval <= 0 { 87 + interval = 5 * time.Second 88 + } 89 + for { 90 + if !deviceCode.ExpiresAt.IsZero() && !deviceCode.ExpiresAt.After(time.Now().UTC()) { 91 + return fmt.Errorf("codex device code expired") 92 + } 93 + select { 94 + case <-ctx.Done(): 95 + return ctx.Err() 96 + case <-time.After(interval): 97 + } 98 + token, err := codexauth.CompleteDeviceCodeLogin(ctx, cfg, deviceCode) 99 + if codexauth.IsAuthorizationPending(err) { 100 + continue 101 + } 102 + if err != nil { 103 + return err 104 + } 105 + if err := codexauth.WriteToken(stateDir, token); err != nil { 106 + return err 107 + } 108 + configUpdated, configPath, autoUpdated, err := maybeSetCodexAsDefaultLLM(opts.SetDefault) 109 + if err != nil { 110 + return err 111 + } 112 + fmt.Fprintf(os.Stdout, "Logged in with OpenAI Codex OAuth.\nToken file: %s\n", codexauth.DisplayTokenPath()) 113 + if !token.ExpiresAt.IsZero() { 114 + fmt.Fprintf(os.Stdout, "Access token expires: %s\n", token.ExpiresAt.Format(time.RFC3339)) 115 + } 116 + if configUpdated { 117 + if autoUpdated { 118 + fmt.Fprintf(os.Stdout, "LLM config was empty; set default provider to openai_codex in %s.\n", configPath) 119 + } else { 120 + fmt.Fprintf(os.Stdout, "Set default provider to openai_codex in %s.\n", configPath) 121 + } 122 + } else { 123 + fmt.Fprintln(os.Stdout, "LLM config was not changed. Run login with --set-default to use openai_codex as the default provider.") 124 + } 125 + return nil 126 + } 127 + } 128 + 129 + func maybeSetCodexAsDefaultLLM(force bool) (updated bool, configPath string, autoUpdated bool, err error) { 130 + configPath, err = codexLoginConfigPath() 131 + if err != nil { 132 + return false, "", false, err 133 + } 134 + data, readErr := os.ReadFile(configPath) 135 + if readErr != nil { 136 + if !os.IsNotExist(readErr) { 137 + return false, configPath, false, readErr 138 + } 139 + data = nil 140 + } 141 + empty, err := codexLoginCurrentLLMConfigEmpty(data, codexLoginRuntimeConfigFromViper()) 142 + if err != nil { 143 + return false, configPath, false, err 144 + } 145 + if !force && !empty { 146 + return false, configPath, false, nil 147 + } 148 + serialized, err := applyCodexDefaultLLMConfig(data) 149 + if err != nil { 150 + return false, configPath, false, err 151 + } 152 + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { 153 + return false, configPath, false, err 154 + } 155 + if err := fsstore.WriteTextAtomic(configPath, string(serialized), fsstore.FileOptions{DirPerm: 0o755, FilePerm: 0o600}); err != nil { 156 + return false, configPath, false, err 157 + } 158 + viper.Set("config", configPath) 159 + viper.Set("llm.provider", codexauth.ProviderName) 160 + viper.Set("llm.model", codexauth.DefaultModel) 161 + viper.Set("llm.endpoint", "") 162 + viper.Set("llm.api_key", "") 163 + viper.Set("llm.cloudflare.account_id", "") 164 + viper.Set("llm.cloudflare.api_token", "") 165 + return true, configPath, !force && empty, nil 166 + } 167 + 168 + func codexLoginConfigPath() (string, error) { 169 + if explicit := strings.TrimSpace(viper.GetString("config")); explicit != "" { 170 + return filepath.Clean(pathutil.ExpandHomePath(explicit)), nil 171 + } 172 + if path, _ := resolveConfigFile(); strings.TrimSpace(path) != "" { 173 + return filepath.Clean(path), nil 174 + } 175 + stateDir := strings.TrimSpace(viper.GetString("file_state_dir")) 176 + if stateDir == "" { 177 + stateDir = "~/.morph" 178 + } 179 + return filepath.Join(pathutil.ExpandHomePath(stateDir), "config.yaml"), nil 180 + } 181 + 182 + func codexLoginCurrentLLMConfigEmpty(data []byte, runtimeCfg codexLoginRuntimeConfig) (bool, error) { 183 + if len(bytes.TrimSpace(data)) == 0 { 184 + return codexLoginRuntimeLLMConfigEmpty(runtimeCfg), nil 185 + } 186 + doc, err := configbootstrap.LoadDocumentBytes(data) 187 + if err != nil { 188 + return false, err 189 + } 190 + root, err := configbootstrap.DocumentMapping(doc) 191 + if err != nil { 192 + return false, err 193 + } 194 + llmNode := configbootstrap.FindMappingValue(root, "llm") 195 + if llmNode == nil { 196 + return codexLoginRuntimeLLMConfigEmpty(runtimeCfg), nil 197 + } 198 + if llmNode.Kind != yaml.MappingNode { 199 + return false, nil 200 + } 201 + provider := codexLoginFirstNonEmpty(codexLoginScalarValue(llmNode, "provider"), runtimeCfg.Provider) 202 + if strings.EqualFold(provider, "cloudflare") { 203 + cloudflareNode := configbootstrap.FindMappingValue(llmNode, "cloudflare") 204 + accountIDConfigured := codexLoginScalarConfigured(cloudflareNode, "account_id", runtimeCfg.CloudflareAccountID) || 205 + codexLoginScalarConfigured(llmNode, "account_id", runtimeCfg.CloudflareAccountID) 206 + apiTokenConfigured := codexLoginScalarConfigured(cloudflareNode, "api_token", runtimeCfg.CloudflareAPIToken) || 207 + codexLoginScalarConfigured(llmNode, "api_token", runtimeCfg.CloudflareAPIToken) 208 + return !accountIDConfigured && !apiTokenConfigured, nil 209 + } 210 + endpointConfigured := codexLoginScalarConfigured(llmNode, "endpoint", runtimeCfg.Endpoint) 211 + apiKeyConfigured := codexLoginScalarConfigured(llmNode, "api_key", runtimeCfg.APIKey) 212 + return !endpointConfigured && !apiKeyConfigured, nil 213 + } 214 + 215 + func codexLoginRuntimeLLMConfigEmpty(cfg codexLoginRuntimeConfig) bool { 216 + if strings.EqualFold(strings.TrimSpace(cfg.Provider), "cloudflare") { 217 + return strings.TrimSpace(cfg.CloudflareAccountID) == "" && strings.TrimSpace(cfg.CloudflareAPIToken) == "" 218 + } 219 + return strings.TrimSpace(cfg.Endpoint) == "" && strings.TrimSpace(cfg.APIKey) == "" 220 + } 221 + 222 + func codexLoginRuntimeConfigFromViper() codexLoginRuntimeConfig { 223 + return codexLoginRuntimeConfig{ 224 + Provider: strings.TrimSpace(viper.GetString("llm.provider")), 225 + Endpoint: strings.TrimSpace(viper.GetString("llm.endpoint")), 226 + APIKey: strings.TrimSpace(viper.GetString("llm.api_key")), 227 + CloudflareAccountID: strings.TrimSpace(viper.GetString("llm.cloudflare.account_id")), 228 + CloudflareAPIToken: strings.TrimSpace(viper.GetString("llm.cloudflare.api_token")), 229 + } 230 + } 231 + 232 + func applyCodexDefaultLLMConfig(data []byte) ([]byte, error) { 233 + doc, err := configbootstrap.LoadDocumentBytes(data) 234 + if err != nil { 235 + return nil, err 236 + } 237 + root, err := configbootstrap.DocumentMapping(doc) 238 + if err != nil { 239 + return nil, err 240 + } 241 + llmNode := configbootstrap.EnsureMappingValue(root, "llm") 242 + configbootstrap.SetOrDeleteMappingScalar(llmNode, "provider", codexauth.ProviderName) 243 + configbootstrap.SetOrDeleteMappingScalar(llmNode, "model", codexauth.DefaultModel) 244 + configbootstrap.SetOrDeleteMappingScalar(llmNode, "endpoint", "") 245 + configbootstrap.SetOrDeleteMappingScalar(llmNode, "api_key", "") 246 + configbootstrap.DeleteMappingKey(llmNode, "cloudflare") 247 + return configbootstrap.MarshalDocument(doc) 248 + } 249 + 250 + func codexLoginScalarConfigured(node *yaml.Node, key string, runtimeValue string) bool { 251 + if strings.TrimSpace(runtimeValue) != "" { 252 + return true 253 + } 254 + return strings.TrimSpace(codexLoginScalarValue(node, key)) != "" 255 + } 256 + 257 + func codexLoginScalarValue(node *yaml.Node, key string) string { 258 + value := configbootstrap.FindMappingValue(node, key) 259 + if value == nil || value.Kind != yaml.ScalarNode { 260 + return "" 261 + } 262 + return strings.TrimSpace(value.Value) 263 + } 264 + 265 + func codexLoginFirstNonEmpty(values ...string) string { 266 + for _, value := range values { 267 + if value = strings.TrimSpace(value); value != "" { 268 + return value 269 + } 270 + } 271 + return "" 272 + } 273 + 274 + func runCodexStatus() error { 275 + status := codexauth.ReadStatus(viper.GetString("file_state_dir"), time.Now().UTC()) 276 + if !status.LoggedIn { 277 + fmt.Fprintln(os.Stdout, "OpenAI Codex OAuth: not logged in") 278 + return nil 279 + } 280 + fmt.Fprintln(os.Stdout, "OpenAI Codex OAuth: logged in") 281 + fmt.Fprintf(os.Stdout, "Token file: %s\n", codexauth.DisplayTokenPath()) 282 + fmt.Fprintf(os.Stdout, "Access token present: %t\n", status.AccessTokenPresent) 283 + fmt.Fprintf(os.Stdout, "Refresh token present: %t\n", status.RefreshTokenPresent) 284 + fmt.Fprintf(os.Stdout, "Access token expired: %t\n", status.AccessTokenExpired) 285 + if status.ExpiresAt != nil && !status.ExpiresAt.IsZero() { 286 + fmt.Fprintf(os.Stdout, "Access token expires: %s\n", status.ExpiresAt.Format(time.RFC3339)) 287 + } 288 + if status.AccountID != "" { 289 + fmt.Fprintf(os.Stdout, "Account ID: %s\n", status.AccountID) 290 + } 291 + if status.PlanType != "" { 292 + fmt.Fprintf(os.Stdout, "Plan: %s\n", status.PlanType) 293 + } 294 + fmt.Fprintf(os.Stdout, "Token file permissions ok: %t\n", status.FileModeOK) 295 + if status.FileModeWarning != "" { 296 + fmt.Fprintf(os.Stdout, "Token file warning: %s\n", status.FileModeWarning) 297 + } 298 + return nil 299 + } 300 + 301 + func runCodexLogout() error { 302 + removed, err := codexauth.DeleteToken(viper.GetString("file_state_dir")) 303 + if err != nil { 304 + return err 305 + } 306 + if removed { 307 + fmt.Fprintf(os.Stdout, "Deleted local OpenAI Codex OAuth token at %s.\n", codexauth.DisplayTokenPath()) 308 + } else { 309 + fmt.Fprintln(os.Stdout, "OpenAI Codex OAuth token was not present.") 310 + } 311 + fmt.Fprintln(os.Stdout, "This does not revoke the OpenAI authorization grant. Revoke it in OpenAI or ChatGPT settings if needed.") 312 + return nil 313 + }
+112
cmd/mistermorph/auth_codex_test.go
··· 1 + package main 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/codexauth" 8 + ) 9 + 10 + func TestApplyCodexDefaultLLMConfig(t *testing.T) { 11 + out, err := applyCodexDefaultLLMConfig([]byte(` 12 + user_agent: test 13 + llm: 14 + provider: openai 15 + endpoint: https://api.openai.com 16 + model: gpt-5.2 17 + api_key: ${OPENAI_API_KEY} 18 + cloudflare: 19 + account_id: acc-old 20 + api_token: token-old 21 + `)) 22 + if err != nil { 23 + t.Fatalf("applyCodexDefaultLLMConfig() error = %v", err) 24 + } 25 + got := string(out) 26 + for _, want := range []string{ 27 + "user_agent: test", 28 + "provider: " + codexauth.ProviderName, 29 + "model: " + codexauth.DefaultModel, 30 + } { 31 + if !strings.Contains(got, want) { 32 + t.Fatalf("serialized config missing %q: %s", want, got) 33 + } 34 + } 35 + for _, notWant := range []string{"endpoint:", "api_key:", "cloudflare:", "account_id:", "api_token:"} { 36 + if strings.Contains(got, notWant) { 37 + t.Fatalf("serialized config should remove %q: %s", notWant, got) 38 + } 39 + } 40 + } 41 + 42 + func TestCodexLoginCurrentLLMConfigEmpty(t *testing.T) { 43 + tests := []struct { 44 + name string 45 + data string 46 + runtimeCfg codexLoginRuntimeConfig 47 + want bool 48 + }{ 49 + { 50 + name: "blank file and blank runtime", 51 + want: true, 52 + }, 53 + { 54 + name: "missing llm and runtime api key", 55 + runtimeCfg: codexLoginRuntimeConfig{ 56 + APIKey: "sk-runtime", 57 + }, 58 + want: false, 59 + }, 60 + { 61 + name: "openai endpoint configured", 62 + data: "llm:\n provider: openai\n endpoint: https://api.openai.com\n", 63 + want: false, 64 + }, 65 + { 66 + name: "openai api key placeholder configured", 67 + data: "llm:\n provider: openai\n api_key: ${OPENAI_API_KEY}\n", 68 + want: false, 69 + }, 70 + { 71 + name: "openai empty credentials", 72 + data: "llm:\n provider: openai\n endpoint: \"\"\n api_key: \"\"\n", 73 + want: true, 74 + }, 75 + { 76 + name: "cloudflare nested token configured", 77 + data: "llm:\n provider: cloudflare\n cloudflare:\n api_token: ${CLOUDFLARE_API_TOKEN}\n", 78 + want: false, 79 + }, 80 + { 81 + name: "cloudflare empty credentials", 82 + data: "llm:\n provider: cloudflare\n cloudflare:\n account_id: \"\"\n api_token: \"\"\n", 83 + want: true, 84 + }, 85 + { 86 + name: "cloudflare runtime account configured", 87 + data: "llm:\n provider: cloudflare\n", 88 + runtimeCfg: codexLoginRuntimeConfig{ 89 + Provider: "cloudflare", 90 + CloudflareAccountID: "acc-runtime", 91 + }, 92 + want: false, 93 + }, 94 + { 95 + name: "non mapping llm is not auto empty", 96 + data: "llm: openai\n", 97 + want: false, 98 + }, 99 + } 100 + 101 + for _, tt := range tests { 102 + t.Run(tt.name, func(t *testing.T) { 103 + got, err := codexLoginCurrentLLMConfigEmpty([]byte(tt.data), tt.runtimeCfg) 104 + if err != nil { 105 + t.Fatalf("codexLoginCurrentLLMConfigEmpty() error = %v", err) 106 + } 107 + if got != tt.want { 108 + t.Fatalf("codexLoginCurrentLLMConfigEmpty() = %t, want %t", got, tt.want) 109 + } 110 + }) 111 + } 112 + }
+82 -15
cmd/mistermorph/consolecmd/agent_settings.go
··· 709 709 APIKey: apiKey, 710 710 Model: model, 711 711 RequestTimeoutRaw: "20s", 712 + FileStateDir: strings.TrimSpace(viper.GetString("file_state_dir")), 712 713 ReasoningEffortRaw: reasoningEffort, 713 714 ToolsEmulationMode: toolsEmulationMode, 714 715 BedrockAWSKey: bedrockAWSKey, ··· 936 937 merged.APIKey = "" 937 938 merged.CloudflareAPIToken = "" 938 939 merged.CloudflareAccountID = "" 940 + case "openai_codex": 941 + merged.Endpoint = "" 942 + merged.APIKey = "" 943 + merged.CloudflareAPIToken = "" 944 + merged.CloudflareAccountID = "" 945 + merged.BedrockAWSKey = "" 946 + merged.BedrockAWSSecret = "" 947 + merged.BedrockRegion = "" 948 + merged.BedrockModelARN = "" 939 949 default: 940 950 merged.CloudflareAPIToken = "" 941 951 merged.CloudflareAccountID = "" ··· 1067 1077 Provider: provider, 1068 1078 Endpoint: llmutil.EndpointForProviderWithValues(provider, values), 1069 1079 Model: llmutil.ModelForProviderWithValues(provider, values), 1070 - APIKey: strings.TrimSpace(values.APIKey), 1080 + APIKey: resolvedAgentSettingsAPIKey(provider, strings.TrimSpace(values.APIKey)), 1071 1081 BedrockAWSKey: strings.TrimSpace(values.BedrockAWSKey), 1072 1082 BedrockAWSSecret: strings.TrimSpace(values.BedrockAWSSecret), 1073 1083 BedrockRegion: strings.TrimSpace(values.BedrockAWSRegion), 1074 1084 BedrockModelARN: strings.TrimSpace(values.BedrockModelARN), 1075 1085 CloudflareAPIToken: resolvedCloudflareToken(provider, strings.TrimSpace(values.APIKey), strings.TrimSpace(values.CloudflareAPIToken)), 1076 - CloudflareAccountID: strings.TrimSpace(values.CloudflareAccountID), 1086 + CloudflareAccountID: resolvedCloudflareAccountID(provider, strings.TrimSpace(values.CloudflareAccountID)), 1077 1087 ReasoningEffort: strings.TrimSpace(values.ReasoningEffortRaw), 1078 1088 ToolsEmulationMode: strings.TrimSpace(values.ToolsEmulationMode), 1079 1089 }, ··· 1110 1120 Provider: strings.TrimSpace(cfg.Provider), 1111 1121 Endpoint: strings.TrimSpace(cfg.Endpoint), 1112 1122 Model: strings.TrimSpace(cfg.Model), 1113 - APIKey: strings.TrimSpace(cfg.APIKey), 1123 + APIKey: resolvedAgentSettingsAPIKey(effectiveProvider, strings.TrimSpace(cfg.APIKey)), 1114 1124 BedrockAWSKey: strings.TrimSpace(cfg.Bedrock.AWSKey), 1115 1125 BedrockAWSSecret: strings.TrimSpace(cfg.Bedrock.AWSSecret), 1116 1126 BedrockRegion: strings.TrimSpace(cfg.Bedrock.Region), 1117 1127 BedrockModelARN: strings.TrimSpace(cfg.Bedrock.ModelARN), 1118 1128 CloudflareAPIToken: resolvedCloudflareToken(effectiveProvider, strings.TrimSpace(cfg.APIKey), strings.TrimSpace(cfg.Cloudflare.APIToken)), 1119 - CloudflareAccountID: strings.TrimSpace(cfg.Cloudflare.AccountID), 1129 + CloudflareAccountID: resolvedCloudflareAccountID(effectiveProvider, strings.TrimSpace(cfg.Cloudflare.AccountID)), 1120 1130 ReasoningEffort: strings.TrimSpace(cfg.ReasoningEffortRaw), 1121 1131 ToolsEmulationMode: strings.TrimSpace(cfg.ToolsEmulationMode), 1122 1132 }, ··· 1137 1147 fields.APIKey = "" 1138 1148 fields.CloudflareAPIToken = "" 1139 1149 fields.CloudflareAccountID = "" 1150 + case "openai_codex": 1151 + fields.Endpoint = "" 1152 + fields.APIKey = "" 1153 + fields.BedrockAWSKey = "" 1154 + fields.BedrockAWSSecret = "" 1155 + fields.BedrockRegion = "" 1156 + fields.BedrockModelARN = "" 1157 + fields.CloudflareAPIToken = "" 1158 + fields.CloudflareAccountID = "" 1140 1159 default: 1141 1160 fields.BedrockAWSKey = "" 1142 1161 fields.BedrockAWSSecret = "" ··· 1148 1167 return fields 1149 1168 } 1150 1169 1170 + func resolvedAgentSettingsAPIKey(provider, apiKey string) string { 1171 + if strings.EqualFold(strings.TrimSpace(provider), "openai_codex") { 1172 + return "" 1173 + } 1174 + return strings.TrimSpace(apiKey) 1175 + } 1176 + 1151 1177 func resolvedCloudflareToken(provider, apiKey, apiToken string) string { 1152 1178 if strings.EqualFold(strings.TrimSpace(provider), "cloudflare") { 1153 1179 return firstNonEmpty(apiToken, apiKey) 1154 1180 } 1181 + if strings.EqualFold(strings.TrimSpace(provider), "openai_codex") { 1182 + return "" 1183 + } 1155 1184 return strings.TrimSpace(apiToken) 1185 + } 1186 + 1187 + func resolvedCloudflareAccountID(provider, accountID string) string { 1188 + if strings.EqualFold(strings.TrimSpace(provider), "openai_codex") { 1189 + return "" 1190 + } 1191 + return strings.TrimSpace(accountID) 1156 1192 } 1157 1193 1158 1194 func normalizeLLMProfileSettings(profiles []llmProfileSettingsPayload) ([]llmProfileSettingsPayload, error) { ··· 1203 1239 normalized.APIKey = "" 1204 1240 normalized.CloudflareAPIToken = "" 1205 1241 normalized.CloudflareAccountID = "" 1242 + case strings.EqualFold(normalized.Provider, "openai_codex"): 1243 + normalized.Endpoint = "" 1244 + normalized.APIKey = "" 1245 + normalized.CloudflareAPIToken = "" 1246 + normalized.CloudflareAccountID = "" 1247 + normalized.BedrockAWSKey = "" 1248 + normalized.BedrockAWSSecret = "" 1249 + normalized.BedrockRegion = "" 1250 + normalized.BedrockModelARN = "" 1206 1251 case normalized.Provider != "": 1207 1252 normalized.CloudflareAPIToken = "" 1208 1253 normalized.CloudflareAccountID = "" ··· 1277 1322 configbootstrap.SetOrDeleteMappingScalar(node, "tools_emulation_mode", *update.ToolsEmulationMode) 1278 1323 } 1279 1324 switch strings.ToLower(strings.TrimSpace(effective.Provider)) { 1325 + case "openai_codex": 1326 + configbootstrap.SetOrDeleteMappingScalar(node, "endpoint", "") 1327 + configbootstrap.SetOrDeleteMappingScalar(node, "api_key", "") 1328 + configbootstrap.DeleteMappingKey(node, "cloudflare") 1329 + configbootstrap.DeleteMappingKey(node, "bedrock") 1330 + return 1280 1331 case "cloudflare": 1281 1332 configbootstrap.SetOrDeleteMappingScalar(node, "api_key", "") 1282 1333 configbootstrap.DeleteMappingKey(node, "bedrock") ··· 1584 1635 setOrDeleteStringMapValue(dst, "reasoning_effort", fields.ReasoningEffort) 1585 1636 setOrDeleteStringMapValue(dst, "tools_emulation_mode", fields.ToolsEmulationMode) 1586 1637 switch strings.ToLower(strings.TrimSpace(effectiveProvider)) { 1638 + case "openai_codex": 1639 + delete(dst, "endpoint") 1640 + delete(dst, "api_key") 1641 + delete(dst, "cloudflare") 1642 + delete(dst, "bedrock") 1643 + return 1587 1644 case "cloudflare": 1588 1645 delete(dst, "api_key") 1589 1646 delete(dst, "bedrock") ··· 1653 1710 APIKey: strings.TrimSpace(settings.APIKey), 1654 1711 Model: strings.TrimSpace(settings.Model), 1655 1712 RequestTimeoutRaw: "20s", 1713 + FileStateDir: strings.TrimSpace(viper.GetString("file_state_dir")), 1656 1714 ReasoningEffortRaw: strings.TrimSpace(settings.ReasoningEffort), 1657 1715 ToolsEmulationMode: strings.TrimSpace(settings.ToolsEmulationMode), 1658 1716 BedrockAWSKey: strings.TrimSpace(settings.BedrockAWSKey), ··· 2051 2109 } 2052 2110 case "api_key": 2053 2111 normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) 2054 - if normalizedProvider != "cloudflare" && normalizedProvider != "bedrock" { 2112 + if normalizedProvider != "cloudflare" && normalizedProvider != "bedrock" && normalizedProvider != "openai_codex" { 2055 2113 fieldPathSets = [][]string{{"api_key"}} 2056 2114 } 2057 2115 case "bedrock_aws_key": ··· 2063 2121 case "bedrock_model_arn": 2064 2122 fieldPathSets = [][]string{{"bedrock", "model_arn"}} 2065 2123 case "cloudflare_api_token": 2066 - fieldPathSets = [][]string{{"cloudflare", "api_token"}} 2124 + if !strings.EqualFold(strings.TrimSpace(provider), "openai_codex") { 2125 + fieldPathSets = [][]string{{"cloudflare", "api_token"}} 2126 + } 2067 2127 if strings.EqualFold(strings.TrimSpace(provider), "cloudflare") { 2068 2128 fieldPathSets = append(fieldPathSets, []string{"api_key"}) 2069 2129 } ··· 2229 2289 func currentAgentSettingsLLMEnvManaged(provider string) map[string]agentSettingsEnvManagedField { 2230 2290 fields := map[string]agentSettingsEnvManagedField{} 2231 2291 normalizedProvider := strings.TrimSpace(strings.ToLower(provider)) 2292 + isCodexProvider := normalizedProvider == "openai_codex" 2232 2293 2233 2294 if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_PROVIDER"); ok { 2234 2295 fields["provider"] = field 2235 2296 } 2236 - if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_ENDPOINT"); ok { 2237 - fields["endpoint"] = field 2297 + if !isCodexProvider { 2298 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_ENDPOINT"); ok { 2299 + fields["endpoint"] = field 2300 + } 2238 2301 } 2239 2302 if field, ok := currentAgentSettingsModelEnvField(provider); ok { 2240 2303 fields["model"] = field ··· 2250 2313 } 2251 2314 case "bedrock": 2252 2315 default: 2253 - if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_API_KEY"); ok { 2254 - fields["api_key"] = field 2255 - } 2256 - if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"); ok { 2257 - fields["cloudflare_api_token"] = field 2316 + if !isCodexProvider { 2317 + if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_API_KEY"); ok { 2318 + fields["api_key"] = field 2319 + } 2320 + if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN"); ok { 2321 + fields["cloudflare_api_token"] = field 2322 + } 2258 2323 } 2259 2324 } 2260 - if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID"); ok { 2261 - fields["cloudflare_account_id"] = field 2325 + if !isCodexProvider { 2326 + if field, ok := currentAgentSettingsManagedEnvField(false, "MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID"); ok { 2327 + fields["cloudflare_account_id"] = field 2328 + } 2262 2329 } 2263 2330 if field, ok := currentAgentSettingsManagedEnvField(true, "MISTER_MORPH_LLM_BEDROCK_AWS_KEY"); ok { 2264 2331 fields["bedrock_aws_key"] = field
+229
cmd/mistermorph/consolecmd/codex_auth.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "crypto/rand" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + "sync" 13 + "time" 14 + 15 + "github.com/quailyquaily/mistermorph/internal/codexauth" 16 + "github.com/quailyquaily/mistermorph/internal/fsstore" 17 + ) 18 + 19 + type codexLoginSession struct { 20 + DeviceCode codexauth.DeviceCode 21 + ExpiresAt time.Time 22 + } 23 + 24 + type codexLoginStore struct { 25 + mu sync.Mutex 26 + sessions map[string]codexLoginSession 27 + } 28 + 29 + func newCodexLoginStore() *codexLoginStore { 30 + return &codexLoginStore{sessions: map[string]codexLoginSession{}} 31 + } 32 + 33 + func (s *codexLoginStore) Create(code codexauth.DeviceCode) (string, error) { 34 + if s == nil { 35 + return "", fmt.Errorf("nil codex login store") 36 + } 37 + id, err := randomOpaqueID() 38 + if err != nil { 39 + return "", err 40 + } 41 + expiresAt := code.ExpiresAt.UTC() 42 + if expiresAt.IsZero() { 43 + expiresAt = time.Now().UTC().Add(15 * time.Minute) 44 + } 45 + s.mu.Lock() 46 + s.pruneLocked(time.Now().UTC()) 47 + s.sessions[id] = codexLoginSession{ 48 + DeviceCode: code, 49 + ExpiresAt: expiresAt, 50 + } 51 + s.mu.Unlock() 52 + return id, nil 53 + } 54 + 55 + func (s *codexLoginStore) Get(id string) (codexLoginSession, bool) { 56 + if s == nil { 57 + return codexLoginSession{}, false 58 + } 59 + id = strings.TrimSpace(id) 60 + now := time.Now().UTC() 61 + s.mu.Lock() 62 + defer s.mu.Unlock() 63 + s.pruneLocked(now) 64 + item, ok := s.sessions[id] 65 + if !ok || !item.ExpiresAt.After(now) { 66 + delete(s.sessions, id) 67 + return codexLoginSession{}, false 68 + } 69 + return item, true 70 + } 71 + 72 + func (s *codexLoginStore) Delete(id string) { 73 + if s == nil { 74 + return 75 + } 76 + s.mu.Lock() 77 + delete(s.sessions, strings.TrimSpace(id)) 78 + s.mu.Unlock() 79 + } 80 + 81 + func (s *codexLoginStore) pruneLocked(now time.Time) { 82 + for id, item := range s.sessions { 83 + if !item.ExpiresAt.After(now) { 84 + delete(s.sessions, id) 85 + } 86 + } 87 + } 88 + 89 + func randomOpaqueID() (string, error) { 90 + buf := make([]byte, 32) 91 + if _, err := rand.Read(buf); err != nil { 92 + return "", err 93 + } 94 + return base64.RawURLEncoding.EncodeToString(buf), nil 95 + } 96 + 97 + func (s *server) handleCodexAuthStatus(w http.ResponseWriter, r *http.Request) { 98 + if r.Method != http.MethodGet { 99 + writeError(w, http.StatusMethodNotAllowed, "method not allowed") 100 + return 101 + } 102 + writeJSON(w, http.StatusOK, codexauth.ReadStatus(s.cfg.stateDir, time.Now().UTC())) 103 + } 104 + 105 + func (s *server) handleCodexAuthLoginStart(w http.ResponseWriter, r *http.Request) { 106 + if r.Method != http.MethodPost { 107 + writeError(w, http.StatusMethodNotAllowed, "method not allowed") 108 + return 109 + } 110 + deviceCode, err := codexauth.RequestDeviceCode(r.Context(), codexauth.DefaultOAuthConfigValue()) 111 + if err != nil { 112 + writeError(w, http.StatusBadGateway, err.Error()) 113 + return 114 + } 115 + sessionID, err := s.codexLogins.Create(deviceCode) 116 + if err != nil { 117 + writeError(w, http.StatusInternalServerError, "failed to create login session") 118 + return 119 + } 120 + writeJSON(w, http.StatusOK, map[string]any{ 121 + "ok": true, 122 + "session_id": sessionID, 123 + "verification_url": deviceCode.VerificationURL, 124 + "user_code": deviceCode.UserCode, 125 + "expires_at": deviceCode.ExpiresAt.Format(time.RFC3339), 126 + "interval_seconds": int(deviceCode.Interval.Seconds()), 127 + }) 128 + } 129 + 130 + func (s *server) handleCodexAuthLoginPoll(w http.ResponseWriter, r *http.Request) { 131 + if r.Method != http.MethodPost { 132 + writeError(w, http.StatusMethodNotAllowed, "method not allowed") 133 + return 134 + } 135 + var req struct { 136 + SessionID string `json:"session_id"` 137 + SetDefault bool `json:"set_default"` 138 + } 139 + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { 140 + writeError(w, http.StatusBadRequest, "invalid json") 141 + return 142 + } 143 + sessionID := strings.TrimSpace(req.SessionID) 144 + session, ok := s.codexLogins.Get(sessionID) 145 + if !ok { 146 + writeError(w, http.StatusNotFound, "codex login session not found or expired") 147 + return 148 + } 149 + token, err := codexauth.CompleteDeviceCodeLogin(r.Context(), codexauth.DefaultOAuthConfigValue(), session.DeviceCode) 150 + if codexauth.IsAuthorizationPending(err) { 151 + writeJSON(w, http.StatusOK, map[string]any{ 152 + "ok": true, 153 + "pending": true, 154 + }) 155 + return 156 + } 157 + if err != nil { 158 + writeError(w, http.StatusBadGateway, err.Error()) 159 + return 160 + } 161 + if err := codexauth.WriteToken(s.cfg.stateDir, token); err != nil { 162 + writeError(w, http.StatusInternalServerError, "failed to save codex token") 163 + return 164 + } 165 + s.codexLogins.Delete(sessionID) 166 + settingsUpdated := false 167 + if req.SetDefault { 168 + if err := s.setCodexAsDefaultLLM(); err != nil { 169 + writeError(w, http.StatusInternalServerError, err.Error()) 170 + return 171 + } 172 + settingsUpdated = true 173 + } 174 + writeJSON(w, http.StatusOK, map[string]any{ 175 + "ok": true, 176 + "pending": false, 177 + "settings_updated": settingsUpdated, 178 + "status": codexauth.ReadStatus(s.cfg.stateDir, time.Now().UTC()), 179 + }) 180 + } 181 + 182 + func (s *server) setCodexAsDefaultLLM() error { 183 + provider := codexauth.ProviderName 184 + model := codexauth.DefaultModel 185 + empty := "" 186 + update := llmSettingsUpdatePayload{ 187 + llmConfigFieldsUpdatePayload: llmConfigFieldsUpdatePayload{ 188 + Provider: &provider, 189 + Model: &model, 190 + Endpoint: &empty, 191 + APIKey: &empty, 192 + CloudflareAPIToken: &empty, 193 + CloudflareAccountID: &empty, 194 + }, 195 + } 196 + configPath, err := resolveConsoleConfigPath() 197 + if err != nil { 198 + return err 199 + } 200 + serialized, err := writeAgentSettingsUpdate(configPath, agentSettingsUpdatePayload{LLM: update}) 201 + if err != nil { 202 + return err 203 + } 204 + effectiveLLM := resolveAgentSettingsLLMFromReader(s.currentRuntimeConfigReader(), update) 205 + if _, err := validateAgentConfigDocument(serialized, effectiveLLM); err != nil { 206 + return err 207 + } 208 + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { 209 + return err 210 + } 211 + return fsstore.WriteTextAtomic(configPath, string(serialized), fsstore.FileOptions{DirPerm: 0o755, FilePerm: 0o600}) 212 + } 213 + 214 + func (s *server) handleCodexAuthLogout(w http.ResponseWriter, r *http.Request) { 215 + if r.Method != http.MethodPost { 216 + writeError(w, http.StatusMethodNotAllowed, "method not allowed") 217 + return 218 + } 219 + removed, err := codexauth.DeleteToken(s.cfg.stateDir) 220 + if err != nil { 221 + writeError(w, http.StatusInternalServerError, err.Error()) 222 + return 223 + } 224 + writeJSON(w, http.StatusOK, map[string]any{ 225 + "ok": true, 226 + "removed": removed, 227 + "status": codexauth.ReadStatus(s.cfg.stateDir, time.Now().UTC()), 228 + }) 229 + }
+10
cmd/mistermorph/consolecmd/local_runtime.go
··· 26 26 "github.com/quailyquaily/mistermorph/internal/channelruntime/taskruntime" 27 27 "github.com/quailyquaily/mistermorph/internal/chatcommands" 28 28 "github.com/quailyquaily/mistermorph/internal/chathistory" 29 + "github.com/quailyquaily/mistermorph/internal/codexauth" 29 30 "github.com/quailyquaily/mistermorph/internal/daemonruntime" 30 31 "github.com/quailyquaily/mistermorph/internal/heartbeatutil" 31 32 "github.com/quailyquaily/mistermorph/internal/llmconfig" ··· 694 695 switch provider { 695 696 case "bedrock": 696 697 // Bedrock may rely on ambient AWS credentials outside llm.* config. 698 + return "" 699 + case "openai_codex": 700 + status := codexauth.ReadStatus(route.Values.FileStateDir, time.Now().UTC()) 701 + if !status.LoggedIn { 702 + return "sign in with OpenAI Codex OAuth to enable Console Local chat submit" 703 + } 704 + if !status.FileModeOK { 705 + return "fix OpenAI Codex token file permissions to enable Console Local chat submit" 706 + } 697 707 return "" 698 708 case "cloudflare": 699 709 if strings.TrimSpace(route.Values.CloudflareAccountID) == "" {
+31 -25
cmd/mistermorph/consolecmd/managed_runtime.go
··· 58 58 cleanup func() 59 59 } 60 60 61 - type managedRuntimeConfigError struct { 62 - err error 63 - } 64 - 65 - func (e managedRuntimeConfigError) Error() string { 66 - if e.err == nil { 67 - return "invalid managed runtime config" 68 - } 69 - return e.err.Error() 70 - } 71 - 72 - func (e managedRuntimeConfigError) Unwrap() error { 73 - return e.err 74 - } 75 - 76 61 func normalizeManagedRuntimeKinds(raw []string) ([]string, error) { 77 62 if len(raw) == 0 { 78 63 return nil, nil ··· 157 142 } 158 143 prepared := &managedRuntimePrepared{ 159 144 reader: reader, 160 - kinds: append([]string(nil), kinds...), 161 145 } 162 146 for _, kind := range kinds { 147 + if field, hint, missing := managedRuntimeMissingCredential(kind, reader); missing { 148 + s.logger().Warn("managed_runtime_channel_disabled", "channel", kind, "missing", field, "hint", hint) 149 + continue 150 + } 163 151 run, cleanup, err := s.buildRuntime(kind, reader) 164 152 if err != nil { 165 153 prepared.cleanup() 166 154 return nil, err 167 155 } 156 + if run == nil { 157 + if cleanup != nil { 158 + cleanup() 159 + } 160 + continue 161 + } 162 + prepared.kinds = append(prepared.kinds, kind) 168 163 prepared.children = append(prepared.children, managedPreparedRuntime{ 169 164 kind: kind, 170 165 run: run, ··· 265 260 switch kind { 266 261 case managedRuntimeTelegram: 267 262 botToken := strings.TrimSpace(reader.GetString("telegram.bot_token")) 268 - if botToken == "" { 269 - return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing telegram.bot_token (set via --telegram-bot-token or MISTER_MORPH_TELEGRAM_BOT_TOKEN)")} 270 - } 271 263 deps, cleanup := buildManagedRuntimeDepsFromReader(s.logger(), reader) 272 264 cfg := channelopts.TelegramConfigFromReader(reader) 273 265 runOpts, err := channelopts.BuildTelegramRunOptions(cfg, channelopts.TelegramInput{ ··· 298 290 }, cleanup, nil 299 291 case managedRuntimeSlack: 300 292 botToken := strings.TrimSpace(reader.GetString("slack.bot_token")) 301 - if botToken == "" { 302 - return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing slack.bot_token (set via --slack-bot-token or MISTER_MORPH_SLACK_BOT_TOKEN)")} 303 - } 304 293 appToken := strings.TrimSpace(reader.GetString("slack.app_token")) 305 - if appToken == "" { 306 - return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing slack.app_token (set via --slack-app-token or MISTER_MORPH_SLACK_APP_TOKEN)")} 307 - } 308 294 deps, cleanup := buildManagedRuntimeDepsFromReader(s.logger(), reader) 309 295 cfg := channelopts.SlackConfigFromReader(reader) 310 296 runOpts := channelopts.BuildSlackRunOptions(cfg, channelopts.SlackInput{ ··· 334 320 default: 335 321 return nil, nil, fmt.Errorf("unsupported managed runtime %q", kind) 336 322 } 323 + } 324 + 325 + func managedRuntimeMissingCredential(kind string, reader *viper.Viper) (string, string, bool) { 326 + if reader == nil { 327 + reader = viper.GetViper() 328 + } 329 + switch kind { 330 + case managedRuntimeTelegram: 331 + if strings.TrimSpace(reader.GetString("telegram.bot_token")) == "" { 332 + return "telegram.bot_token", "set MISTER_MORPH_TELEGRAM_BOT_TOKEN or telegram.bot_token", true 333 + } 334 + case managedRuntimeSlack: 335 + if strings.TrimSpace(reader.GetString("slack.bot_token")) == "" { 336 + return "slack.bot_token", "set MISTER_MORPH_SLACK_BOT_TOKEN or slack.bot_token", true 337 + } 338 + if strings.TrimSpace(reader.GetString("slack.app_token")) == "" { 339 + return "slack.app_token", "set MISTER_MORPH_SLACK_APP_TOKEN or slack.app_token", true 340 + } 341 + } 342 + return "", "", false 337 343 } 338 344 339 345 func (s *managedRuntimeSupervisor) logger() *slog.Logger {
+48 -9
cmd/mistermorph/consolecmd/managed_runtime_test.go
··· 1 1 package consolecmd 2 2 3 3 import ( 4 + "context" 4 5 "testing" 5 6 6 7 "github.com/spf13/viper" 7 8 ) 8 9 9 - func TestManagedRuntimeSupervisorReloadRejectsInvalidConfigWithoutMutatingState(t *testing.T) { 10 + func TestManagedRuntimeSupervisorReloadDisablesChannelMissingToken(t *testing.T) { 10 11 local := &consoleLocalRuntime{managedRuntimeRunning: map[string]bool{}} 11 12 local.SetManagedRuntimeRunning("telegram", true) 12 13 supervisor := newManagedRuntimeSupervisor(local, false, false) ··· 14 15 current := viper.New() 15 16 current.Set("console.managed_runtimes", []string{"telegram"}) 16 17 current.Set("telegram.bot_token", "old-token") 18 + supervisor.parentCtx = context.Background() 17 19 supervisor.configReader = current 18 20 supervisor.kinds = []string{"telegram"} 19 21 ··· 21 23 next.Set("console.managed_runtimes", []string{"telegram"}) 22 24 23 25 err := supervisor.ReloadConfig(next) 24 - if err == nil { 25 - t.Fatal("ReloadConfig() error = nil, want invalid config error") 26 + if err != nil { 27 + t.Fatalf("ReloadConfig() error = %v, want nil", err) 28 + } 29 + if got := supervisor.configReader.GetString("telegram.bot_token"); got != "" { 30 + t.Fatalf("configReader.telegram.bot_token = %q, want empty", got) 31 + } 32 + if len(supervisor.kinds) != 0 { 33 + t.Fatalf("kinds = %#v, want empty", supervisor.kinds) 26 34 } 27 - if got := supervisor.configReader.GetString("telegram.bot_token"); got != "old-token" { 28 - t.Fatalf("configReader.telegram.bot_token = %q, want %q", got, "old-token") 35 + if local.isManagedRuntimeRunning("telegram") { 36 + t.Fatal("telegram running = true, want false") 29 37 } 30 - if len(supervisor.kinds) != 1 || supervisor.kinds[0] != "telegram" { 31 - t.Fatalf("kinds = %#v, want [telegram]", supervisor.kinds) 38 + } 39 + 40 + func TestManagedRuntimeSupervisorSkipsSlackMissingToken(t *testing.T) { 41 + cases := []struct { 42 + name string 43 + setNext func(*viper.Viper) 44 + }{ 45 + {name: "missing bot token"}, 46 + { 47 + name: "missing app token", 48 + setNext: func(v *viper.Viper) { 49 + v.Set("slack.bot_token", "xoxb-token") 50 + }, 51 + }, 32 52 } 33 - if !local.isManagedRuntimeRunning("telegram") { 34 - t.Fatal("telegram running = false, want unchanged true") 53 + 54 + for _, tc := range cases { 55 + t.Run(tc.name, func(t *testing.T) { 56 + supervisor := newManagedRuntimeSupervisor(nil, false, false) 57 + reader := viper.New() 58 + reader.Set("console.managed_runtimes", []string{"slack"}) 59 + if tc.setNext != nil { 60 + tc.setNext(reader) 61 + } 62 + 63 + prepared, err := supervisor.PrepareReload(reader) 64 + if err != nil { 65 + t.Fatalf("PrepareReload() error = %v, want nil", err) 66 + } 67 + if len(prepared.kinds) != 0 { 68 + t.Fatalf("prepared.kinds = %#v, want empty", prepared.kinds) 69 + } 70 + if len(prepared.children) != 0 { 71 + t.Fatalf("prepared.children len = %d, want 0", len(prepared.children)) 72 + } 73 + }) 35 74 } 36 75 } 37 76
+6
cmd/mistermorph/consolecmd/serve.go
··· 99 99 streamTickets *sessionStore 100 100 artifactPreviews *artifactPreviewStore 101 101 limiter *loginLimiter 102 + codexLogins *codexLoginStore 102 103 endpoints []runtimeEndpoint 103 104 endpointByRef map[string]runtimeEndpoint 104 105 localRuntime *consoleLocalRuntime ··· 372 373 streamTickets: newSessionStore(""), 373 374 artifactPreviews: newArtifactPreviewStore(), 374 375 limiter: newLoginLimiter(), 376 + codexLogins: newCodexLoginStore(), 375 377 endpoints: endpoints, 376 378 endpointByRef: endpointByRef, 377 379 localRuntime: localRuntime, ··· 399 401 mux.HandleFunc(apiPrefix+"/auth/login", s.handleLogin) 400 402 mux.HandleFunc(apiPrefix+"/auth/logout", s.withAuth(s.handleLogout)) 401 403 mux.HandleFunc(apiPrefix+"/auth/me", s.withAuth(s.handleAuthMe)) 404 + mux.HandleFunc(apiPrefix+"/auth/codex/status", s.withAuth(s.handleCodexAuthStatus)) 405 + mux.HandleFunc(apiPrefix+"/auth/codex/login/start", s.withAuth(s.handleCodexAuthLoginStart)) 406 + mux.HandleFunc(apiPrefix+"/auth/codex/login/poll", s.withAuth(s.handleCodexAuthLoginPoll)) 407 + mux.HandleFunc(apiPrefix+"/auth/codex/logout", s.withAuth(s.handleCodexAuthLogout)) 402 408 mux.HandleFunc(apiPrefix+"/endpoints", s.withAuth(s.handleEndpoints)) 403 409 mux.HandleFunc(apiPrefix+"/setup/integrity", s.withAuth(s.handleSetupIntegrity)) 404 410 mux.HandleFunc(apiPrefix+"/setup/file", s.withAuth(s.handleSetupRepairFile))
+1
cmd/mistermorph/root.go
··· 202 202 }, 203 203 })) 204 204 cmd.AddCommand(newToolsCmd(registryResolver.Registry)) 205 + cmd.AddCommand(newAuthCmd()) 205 206 cmd.AddCommand(newBenchmarkCmd()) 206 207 cmd.AddCommand(newCreditsCmd()) 207 208 cmd.AddCommand(skillscmd.New())
+1 -1
cmd/mistermorph/runcmd/run.go
··· 347 347 cmd.Flags().Bool("heartbeat", false, "Run a single heartbeat check (ignores --task and stdin).") 348 348 cmd.Flags().String("workspace", "", "Attach a workspace directory for this run.") 349 349 cmd.Flags().Bool("no-workspace", false, "Run without a workspace attachment.") 350 - cmd.Flags().String("provider", "openai", "Provider: openai|openai_resp|openai_custom|deepseek|xai|gemini|azure|anthropic|bedrock|susanoo|cloudflare.") 350 + cmd.Flags().String("provider", "openai", "Provider: openai|openai_resp|openai_custom|openai_codex|deepseek|xai|gemini|azure|anthropic|bedrock|susanoo|cloudflare.") 351 351 cmd.Flags().String("endpoint", "https://api.openai.com", "Base URL for provider.") 352 352 cmd.Flags().String("model", "gpt-5.2", "Model name.") 353 353 cmd.Flags().String("api-key", "", "API key.")
+453
docs/feat/feat_20260424_codex_oauth_provider.md
··· 1 + --- 2 + date: 2026-04-24 3 + title: Codex OAuth Provider 4 + status: draft 5 + --- 6 + 7 + # Codex OAuth Provider 8 + 9 + ## 1) 背景 10 + 11 + `mistermorph` 现在主要通过 `llm.provider` + `llm.api_key` 接入模型服务。OpenAI 方向已经支持普通 API key 和 OpenAI-compatible endpoint,但还不支持 Codex CLI 类似的 ChatGPT OAuth 登录。 12 + 13 + OpenAI 官方 Codex CLI 文档写明:第一次运行 Codex CLI 时,可以用 ChatGPT 账号或 API key 登录。官方模型文档也把 GPT-5.3-Codex 描述为面向 Codex 或类似 coding agent 环境的模型,并标出 Responses API 支持。 14 + 15 + 这说明从产品形态看,Codex 类模型和 coding agent 环境是匹配的。但这不等于 OpenAI 已经把“第三方客户端复用 Codex OAuth grant”定义成稳定公开接口。这个需求必须按实验性能力处理。 16 + 17 + 外部背景: 18 + 19 + - OpenClaw、Hermes 已经做了类似 provider。 20 + - 之前检查没有发现它们在 system prompt 里伪装自己是 Codex CLI;主要是认证和请求兼容。 21 + - Sam 的公开表态可以作为方向信号,但不能作为接口稳定性、授权范围或安全边界的依据。 22 + 23 + ## 2) 可行性 24 + 25 + 这个需求可行,但必须收窄范围。 26 + 27 + 推荐先实现一个显式 opt-in 的实验性 provider: 28 + 29 + ```yaml 30 + llm: 31 + provider: openai_codex 32 + model: gpt-5.5 33 + ``` 34 + 35 + `openai_codex` 的含义是: 36 + 37 + - 使用 Codex OAuth 获取本地 bearer token。 38 + - 默认走 Codex/Responses 兼容请求形态。 39 + - 保持 `mistermorph` 自己的 agent 身份,不在 prompt 里自称 Codex CLI。 40 + - 不承诺免费额度、特殊额度或绕过 OpenAI 的正常计费与限流。 41 + 42 + 验证结果:当前 `uniai` 的 `openai_resp` 可以复用为底层 Responses 传输,但不能把 `openai_codex` 简单映射成 `openai_resp` 后直接使用。Codex backend 对请求体有额外约束,需要一层 Codex 兼容处理。 43 + 44 + ## 3) 目标 45 + 46 + 第一版要做到: 47 + 48 + 1. 新增 provider:`openai_codex`。 49 + 2. 新增本地登录命令:`mistermorph auth codex login`。 50 + 3. 新增状态命令:`mistermorph auth codex status`。 51 + 4. 新增退出命令:`mistermorph auth codex logout`。 52 + 5. Console Web Settings 支持 Codex OAuth 登录、状态查看和退出。 53 + 6. token 保存到 `<file_state_dir>/auth/codex.json`。 54 + 7. token 文件权限限制为当前用户可读写。 55 + 8. access token 过期前自动 refresh。 56 + 9. `llm.profiles` 和 `llm.routes` 可以使用 `openai_codex`。 57 + 10. 日志、错误、stats、Console API 中不能输出 access token、refresh token、authorization code。 58 + 11. system prompt 不因为该 provider 改成 Codex CLI 身份。 59 + 60 + ## 4) 非目标 61 + 62 + 第一版不做这些事: 63 + 64 + 1. 不做远端多用户 OAuth。 65 + 2. 不自动读取或复用官方 Codex CLI 的本地凭证。 66 + 3. 不在 system prompt、developer prompt 或工具描述里伪装成 Codex CLI。 67 + 4. 不承诺 OpenAI 会长期保持当前 Codex OAuth 行为。 68 + 5. 不实现服务端撤销 OAuth grant。 69 + 6. 不自动删除 OpenAI 侧已经生成的 API key。 70 + 7. 不改现有 `openai`、`openai_resp`、`openai_custom` 行为。 71 + 8. 不把 Codex OAuth 设为默认 provider。 72 + 73 + 其中第 2 点是安全边界。意思是:不自动读取 `~/.codex` 或其他官方 Codex CLI 保存的本地 token。官方 Codex CLI 的本地凭证属于另一个应用。即使技术上可以读,也不应该默认读,否则用户可能以为只是在使用 `mistermorph`,实际却复用了另一个应用的高权限凭证。 74 + 75 + 如果后续要支持导入,必须单独加显式命令,例如 `mistermorph auth codex import-cli-token`,并在命令输出里说明风险。 76 + 77 + ## 5) 用户流程 78 + 79 + ### 5.1 登录 80 + 81 + 用户执行: 82 + 83 + ```bash 84 + mistermorph auth codex login 85 + ``` 86 + 87 + 如果用户希望登录成功后直接把默认 LLM provider 切到 Codex OAuth,可以显式加参数: 88 + 89 + ```bash 90 + mistermorph auth codex login --set-default 91 + ``` 92 + 93 + 命令行为: 94 + 95 + 1. 向 OpenAI OAuth/device auth 端点发起登录。 96 + 2. 在终端打印登录 URL 和 user code。 97 + 3. 用户在浏览器完成授权。 98 + 4. 命令轮询 token 结果。 99 + 5. 成功后写入 `<file_state_dir>/auth/codex.json`。 100 + 6. 如果带了 `--set-default`,把当前配置的默认 `llm.provider` 改成 `openai_codex`,并清掉 `llm.endpoint`、`llm.api_key` 和 `llm.cloudflare`。 101 + 7. 如果没有带 `--set-default`,但当前 LLM credential 配置为空,也自动写入 `openai_codex`。 102 + 103 + 第 7 点里的“空”定义为: 104 + 105 + - 非 Cloudflare provider:没有配置 `llm.endpoint`,也没有配置 `llm.api_key`。 106 + - Cloudflare provider:没有配置 `llm.cloudflare.account_id`,也没有配置 `llm.cloudflare.api_token`。 107 + 108 + 登录成功输出只显示: 109 + 110 + - 登录状态 111 + - token 大致过期时间 112 + - 存储位置使用 `<file_state_dir>` 表示 113 + - 是否更新了配置文件 114 + 115 + 不能输出真实 token。 116 + 117 + ### 5.2 使用 118 + 119 + 配置示例: 120 + 121 + ```yaml 122 + llm: 123 + provider: openai_codex 124 + model: gpt-5.5 125 + request_timeout: "120s" 126 + ``` 127 + 128 + profile 示例: 129 + 130 + ```yaml 131 + llm: 132 + provider: openai 133 + model: gpt-5.5 134 + 135 + profiles: 136 + codex: 137 + provider: openai_codex 138 + model: gpt-5.5 139 + 140 + routes: 141 + main_loop: 142 + profile: codex 143 + ``` 144 + 145 + ### 5.3 状态 146 + 147 + 用户执行: 148 + 149 + ```bash 150 + mistermorph auth codex status 151 + ``` 152 + 153 + 输出: 154 + 155 + - 是否已登录 156 + - access token 是否已过期 157 + - refresh token 是否存在 158 + - token 文件是否权限过宽 159 + 160 + 不输出 token 内容。 161 + 162 + ### 5.4 退出 163 + 164 + 用户执行: 165 + 166 + ```bash 167 + mistermorph auth codex logout 168 + ``` 169 + 170 + 命令只删除本地 token 文件。 171 + 172 + 需要明确提示:本地 logout 不等于 OpenAI 侧撤销授权,也不等于删除 OpenAI 侧可能已经生成的 API key。用户如需完全撤销,需要去 OpenAI/ChatGPT 相关设置和 API dashboard 手动处理。 173 + 174 + ### 5.5 Console Web 登录 175 + 176 + Console Web 第一版也要支持登录。 177 + 178 + 入口建议放在 Settings 的 LLM 配置区域: 179 + 180 + - 当 `provider` 选择 `openai_codex` 时,显示 Codex OAuth 状态卡片。 181 + - 未登录时显示 `Sign in with OpenAI Codex`。 182 + - 登录中显示 user code、登录 URL、过期时间和轮询状态。 183 + - 已登录时显示 token 状态、过期时间和 `Logout`。 184 + - token 过期但 refresh token 可用时,显示“可自动刷新”。 185 + - refresh 失败时,显示重新登录操作。 186 + 187 + Console Web 登录流程: 188 + 189 + 1. 前端调用后端 start API。 190 + 2. 后端发起 device auth,保存短期登录 session。 191 + 3. 后端只把登录 URL、user code、过期时间、轮询间隔和 opaque session id 返回给前端。 192 + 4. 前端打开登录 URL 或提供复制按钮。 193 + 5. 前端按轮询间隔调用 poll API。 194 + 6. 后端轮询 OpenAI 端点,成功后把 token 写入 `<file_state_dir>/auth/codex.json`。 195 + 7. 前端刷新状态。 196 + 197 + Console Web 不应该拿到: 198 + 199 + - access token 200 + - refresh token 201 + - authorization code 202 + - device auth 内部 id 203 + - code verifier 204 + 205 + 这些值只应存在于后端内存或 token store 中。 206 + 207 + 建议 Console API: 208 + 209 + ```text 210 + GET /api/auth/codex/status 211 + POST /api/auth/codex/login/start 212 + POST /api/auth/codex/login/poll 213 + POST /api/auth/codex/logout 214 + ``` 215 + 216 + 这些 API 必须走现有 Console session 鉴权。未登录 Console 的请求返回 `401`。 217 + 218 + ## 6) 配置需求 219 + 220 + 新增 provider 名称: 221 + 222 + ```yaml 223 + llm: 224 + provider: openai_codex 225 + ``` 226 + 227 + 第一版不新增复杂配置。默认规则: 228 + 229 + - token 文件:`<file_state_dir>/auth/codex.json` 230 + - endpoint:由 provider 内部决定,不要求用户配置 231 + - model:沿用 `llm.model` 232 + - headers:仍允许通过 `llm.headers` 加额外 header,但不能覆盖 `Authorization` 233 + - OAuth client id 和 device auth endpoint:内置在代码中,作为实验性实现细节,不放进默认配置模板 234 + 235 + 如果后续需要调试 endpoint,可以再加: 236 + 237 + ```yaml 238 + llm: 239 + codex: 240 + endpoint: "" 241 + ``` 242 + 243 + 第一版先不加,避免把不稳定的内部 endpoint 变成公开配置契约。 244 + 245 + ## 7) 实现边界 246 + 247 + 建议新增一个小的 auth 包,职责只包含: 248 + 249 + - 发起 device auth 登录 250 + - 轮询授权结果 251 + - 交换 access token / refresh token 252 + - 刷新 access token 253 + - 读写 token store 254 + - 校验 token store 文件权限 255 + 256 + 不要把 LLM request 逻辑放进 auth 包。 257 + 258 + Console Web 需要复用同一个 auth 包,不另写一套 OAuth 逻辑。 259 + 260 + Console 后端需要再加一层短期 login session store: 261 + 262 + - 保存 device auth 内部 id、code verifier、过期时间和轮询状态。 263 + - 对前端只暴露 opaque session id。 264 + - session 过期后自动清理。 265 + - 进程重启后登录中 session 丢失是可接受行为,用户重新点登录即可。 266 + 267 + client 创建路径建议: 268 + 269 + 1. `llmutil` 解析出 provider 为 `openai_codex`。 270 + 2. 调用 Codex auth resolver 获取有效 access token。 271 + 3. 创建 Codex 兼容层,内部优先复用 `uniai` 的 `openai_resp`。 272 + 4. Codex 兼容层设置 Codex endpoint、bearer token、必要 header 和 Codex 请求体约束。 273 + 5. 如果 `uniai` 的 `openai_resp` 后续无法满足 Codex 响应解析或流式工具调用,再新增 `providers/codex` 实现 `llm.Client`。 274 + 275 + 这个 provider 不应该改 agent 层 prompt。agent 仍然是 `mistermorph`,不是 Codex CLI。 276 + 277 + ### 7.1 技术验证结论 278 + 279 + 2026-04-24 做了代码和 fake server 验证。 280 + 281 + 本地验证结果: 282 + 283 + - `uniai` 的 `openai_resp` 使用 OpenAI Go SDK 的 Responses client。 284 + - `OpenAIAPIBase` 可以设置成非 `api.openai.com` 的 base URL。 285 + - 当 base URL 是 `<server>/backend-api/codex` 时,实际请求路径是 `POST /backend-api/codex/responses`。 286 + - `OpenAIAPIKey` 会变成 `Authorization: Bearer <token>`。 287 + - `ChatHeaders` 会附加到请求上。 288 + 289 + 这说明 `uniai openai_resp` 的传输层、bearer token 和路径拼接可以复用。 290 + 291 + 不能直接复用的原因: 292 + 293 + - Codex backend 不是标准 OpenAI Platform Responses API 的完全等价实现。 294 + - 外部实测显示 `https://chatgpt.com/backend-api/codex/responses` 要求 `instructions` 字段存在且非空。 295 + - 外部实测显示 `instructions` 字段过大时会返回 `400 Bad Request`。 296 + - 外部实测显示 `input` 必须是 message array,不能是普通字符串。 297 + - 外部实测显示 `store` 必须是 `false`。 298 + - 外部实测显示 `stream` 必须是 `true`。 299 + - `mistermorph` 的 CLI、Telegram、Slack、LINE、Lark 路径默认不传 `OnStream`,所以不能依赖调用方天然走 streaming。 300 + - `mistermorph` 当前会把 system message 放进 input message list,但 Codex backend 还要求顶层 `instructions`。 301 + 302 + 第一版实现应按这个方向收敛: 303 + 304 + 1. `openai_codex` 内部使用 `openai_resp` 传输。 305 + 2. 固定 endpoint 为 `https://chatgpt.com/backend-api/codex`。 306 + 3. 固定 `store: false`。 307 + 4. 强制使用 streaming;调用方没有 `OnStream` 时,provider 内部用 no-op stream handler 触发 `uniai` streaming 路径。 308 + 5. 从 system messages 生成顶层 `instructions`,并避免重复发送同一份 system prompt。 309 + 6. 对 `instructions` 做大小限制;超过限制时放回 input 或走压缩策略。 310 + 7. 保留 tool calling 和流式解析的兼容测试。 311 + 8. 必要时补 `OpenAI-Beta: responses=v1`、`chatgpt-account-id` 和最小可解释的 client header。 312 + 313 + 结论:不需要一开始就写完整独立 `providers/codex`,但需要 `openai_codex` 兼容层。直接映射成 `openai_resp` 风险高。 314 + 315 + ## 8) 安全要求 316 + 317 + Codex OAuth refresh token 是高价值 secret。实现必须遵守: 318 + 319 + 1. token 文件使用 `0600`。 320 + 2. token 目录使用 `0700`。 321 + 3. 日志、错误、debug 输出全部做 token redaction。 322 + 4. `mistermorph auth codex status` 只展示状态,不展示 secret。 323 + 5. refresh 失败时 fail closed,不回退到空 token 或匿名请求。 324 + 6. provider 错误信息要能区分本地未登录、token 过期、refresh 失败、OpenAI 拒绝访问。 325 + 7. 不默认导入 Codex CLI 凭证。 326 + 8. 不在文档里暗示这是 OpenAI 官方支持的第三方 OAuth 集成。 327 + 9. Console API 只返回状态和登录辅助信息,不返回 OAuth secret。 328 + 10. Console Web 的登录 session id 只代表一次短期登录轮询,不能用于读取 token。 329 + 330 + 官方帮助文档里对 Codex CLI 登录权限的描述很重:相关授权可能让 CLI 获取 refresh token,并进一步生成 API key、消耗 credits、管理 API organization。实现和文档都必须把它当作高权限凭证处理。 331 + 332 + ## 9) 失败模式 333 + 334 + 需要给出清晰错误: 335 + 336 + - 未登录:提示运行 `mistermorph auth codex login` 337 + - refresh token 缺失:提示重新登录 338 + - refresh 失败:提示重新登录,并保留原 token 文件用于排查,除非用户执行 logout 339 + - 401 / 403:提示检查 ChatGPT/Codex 账号权限、workspace 限制和 OpenAI 侧授权状态 340 + - endpoint schema 变化:明确报错,提示 provider 需要升级,不自动降级到 OpenAI API key,不自动切换 endpoint,不输出原始 token 341 + - 非交互环境登录:login 命令应失败并说明需要交互式终端或浏览器 342 + 343 + ## 10) 测试需求 344 + 345 + 单元测试: 346 + 347 + 1. token store 写入权限。 348 + 2. token store 读取和缺字段错误。 349 + 3. access token 过期判断。 350 + 4. refresh 成功后原子写回。 351 + 5. refresh 失败不清空原 token。 352 + 6. provider 名称 `openai_codex` 可以被 route/profile 解析。 353 + 7. `Authorization` 不能被 `llm.headers` 覆盖。 354 + 8. logger redaction 覆盖 access token、refresh token、authorization code。 355 + 9. prompt 构造不因 provider 变化而加入 Codex CLI 身份。 356 + 357 + 集成测试: 358 + 359 + 1. 使用 fake OAuth server 跑完整 login polling。 360 + 2. 使用 fake Codex/Responses server 验证 request header 和 body。 361 + 3. `mistermorph auth codex status` 不泄露 secret。 362 + 4. `mistermorph auth codex logout` 只删除本地 token 文件。 363 + 5. Console start API 不返回 token、authorization code、device auth 内部 id 或 code verifier。 364 + 6. Console poll API 成功后只返回状态,不返回 token。 365 + 7. 未登录 Console session 时,Codex OAuth Console API 返回 `401`。 366 + 367 + 手工测试: 368 + 369 + 1. 登录真实 OpenAI 账号。 370 + 2. 用 `openai_codex` 跑一个最小任务。 371 + 3. 模拟 access token 过期,确认自动 refresh。 372 + 4. 删除 token 文件后确认错误可读。 373 + 374 + ## 11) 验收标准 375 + 376 + 第一版完成时应满足: 377 + 378 + 1. `mistermorph auth codex login/status/logout` 可用。 379 + 2. Console Web Settings 可完成 Codex OAuth login/status/logout。 380 + 3. `llm.provider: openai_codex` 可跑通 main loop。 381 + 4. profile 和 route 可选择 `openai_codex`。 382 + 5. 未登录时错误信息可直接指导用户下一步。 383 + 6. token 不出现在日志、错误、stats、Console API 返回里。 384 + 7. system prompt 不出现 Codex CLI 身份伪装。 385 + 8. `go test ./...` 通过。 386 + 9. `pnpm build` 通过。 387 + 10. 文档明确标注这是实验性 provider。 388 + 389 + ## 12) 需要实现前确认的问题 390 + 391 + 已确认: 392 + 393 + 1. provider 名称定为 `openai_codex`。 394 + 2. 内置 Codex OAuth client id 和 device auth endpoint。 395 + 3. 第一版包含 Console Web 登录。 396 + 4. 不默认读取官方 Codex CLI 的本地凭证。 397 + 5. `uniai openai_resp` 可以复用底层传输,但需要 Codex 兼容层。 398 + 6. 示例和默认建议模型使用 `gpt-5.5`。 399 + 7. OpenAI 修改 Codex OAuth 流程或 backend schema 时,provider 明确报错,不自动降级。 400 + 401 + 当前没有待确认项。 402 + 403 + ## 13) 参考资料 404 + 405 + - OpenAI Codex CLI: https://developers.openai.com/codex/cli 406 + - OpenAI GPT-5.3-Codex model: https://developers.openai.com/api/docs/models/gpt-5.3-codex 407 + - OpenAI codex-mini-latest model: https://developers.openai.com/api/docs/models/codex-mini-latest 408 + - OpenAI Help Center, Codex CLI and Sign in with ChatGPT: https://help.openai.com/en/articles/11381614 409 + - OpenClaw OpenAI provider docs: https://docs.openclaw.ai/providers/openai 410 + - OpenClaw model providers docs: https://docs.openclaw.ai/concepts/model-providers 411 + - OpenClaw issue on Codex request shape: https://github.com/openclaw/openclaw/issues/67740 412 + - OpenClaw issue on Codex instructions size: https://github.com/openclaw/openclaw/issues/57930 413 + - OpenClaw issue on Codex Cloudflare 403: https://github.com/openclaw/openclaw/issues/62142 414 + - Hermes issue on Codex stream backfill: https://github.com/NousResearch/hermes-agent/issues/5883 415 + 416 + ## 14) 任务拆分和实际情况 417 + 418 + - [x] 创建 `feat/codex_oauth` 分支。 419 + - [x] 在 `docs/feat` 增加需求文档。 420 + - [x] 验证 `uniai openai_resp` 的传输、base URL、bearer token 和 header 复用方式。 421 + - [x] 增加 Codex OAuth auth 包,支持 device login、poll、refresh 和 token 解析。 422 + - [x] 增加本地 token store,使用 `0700` 目录和 `0600` 文件权限。 423 + - [x] 增加 `mistermorph auth codex login/status/logout`。 424 + - [x] `auth codex login --set-default` 支持登录后把默认 provider 写成 `openai_codex`。 425 + - [x] 当前 LLM credential 配置为空时,`auth codex login` 自动写入 `openai_codex`。 426 + - [x] 增加 `openai_codex` provider 兼容层,不改 agent 身份 prompt。 427 + - [x] 支持从 system/developer messages 生成顶层 `instructions`,并避免重复发送。 428 + - [x] 限制顶层 `instructions` 大小,超出部分放回 input message,避免 Codex backend 返回 `400 Bad Request`。 429 + - [x] 禁止用户配置 header 覆盖 `Authorization`。 430 + - [x] 固定 Codex backend endpoint,避免从 profile 继承普通 OpenAI endpoint。 431 + - [x] 对进程内 token refresh 做串行处理,避免并发使用同一个 refresh token。 432 + - [x] 对齐 Codex HTTP 请求形态:不在 HTTP Responses 请求上发送 WebSocket beta header,并补 `prompt_cache_key`。 433 + - [x] 禁用 `openai_codex` 的通用 `prompt_cache_retention` 参数;Codex backend 会返回 `Unsupported parameter`。 434 + - [x] `ForceJSON` 时为 Codex 请求补 `response_format: json_object`,即使当前请求带 tools。 435 + - [x] `response_format: json_object` 时确保 `input` message 包含 `JSON`,满足 OpenAI JSON mode 的请求校验。 436 + - [x] 过滤 `max_tokens` / `max_output_tokens`,避免 Codex backend 返回 `Unsupported parameter: max_output_tokens`。 437 + - [x] 升级到 `uniai v0.1.20`,使用上游 `openai_resp` streaming result 累积逻辑。 438 + - [x] 移除 Codex provider 内本地 streaming 文本回填代码,避免重复实现上游协议解析。 439 + - [x] 复核 Codex OAuth 实现,移除无用 Config 字段、OAuth 状态字段和 Console 前端临时状态。 440 + - [x] 让 `uniai` 透传 OpenAI raw options,用于设置 `store: false` 等请求参数。 441 + - [x] 修正 Codex backend base URL,不追加标准 OpenAI `/v1` 路径。 442 + - [x] 支持 `llm.profiles` 和 `llm.routes` 使用 `openai_codex`。 443 + - [x] Console 后端增加 Codex OAuth status、start、poll、logout API。 444 + - [x] Console API 只返回状态和登录辅助信息,不返回 OAuth secret。 445 + - [x] Console Web Settings 增加 Codex OAuth 登录、状态和退出 UI。 446 + - [x] `openai_codex` 选择后隐藏 endpoint、API key 和 Cloudflare credential 输入。 447 + - [x] 更新默认配置模板中的 provider 说明。 448 + - [x] 增加 auth、provider、base URL 相关单元测试。 449 + - [x] `pnpm build` 已通过。 450 + - [x] `go test ./...` 最终通过。 451 + - [ ] 真实 OpenAI 账号登录 smoke test;需要用户在浏览器完成授权。 452 + - [ ] 真实 `openai_codex` 最小任务 smoke test;需要可用账号和网络。 453 + - [ ] 真实 token refresh smoke test;需要可控的过期 token 或等待 token 到期。
+584
internal/codexauth/oauth.go
··· 1 + package codexauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + "strconv" 14 + "strings" 15 + "sync" 16 + "time" 17 + ) 18 + 19 + const ( 20 + ProviderName = "openai_codex" 21 + DefaultClientID = "app_EMoamEEZ73f0CkXaXp7hrann" 22 + DefaultIssuer = "https://auth.openai.com" 23 + DefaultAPIBase = "https://chatgpt.com/backend-api/codex" 24 + DefaultModel = "gpt-5.5" 25 + defaultDeviceTTL = 15 * time.Minute 26 + defaultPollInterval = 5 * time.Second 27 + ) 28 + 29 + var ( 30 + ErrAuthorizationPending = errors.New("codex device authorization pending") 31 + ErrNotLoggedIn = errors.New("codex oauth is not logged in") 32 + 33 + refreshMu sync.Mutex 34 + ) 35 + 36 + type OAuthConfig struct { 37 + Issuer string 38 + ClientID string 39 + HTTPClient *http.Client 40 + Now func() time.Time 41 + } 42 + 43 + type DeviceCode struct { 44 + VerificationURL string 45 + UserCode string 46 + DeviceAuthID string 47 + Interval time.Duration 48 + ExpiresAt time.Time 49 + } 50 + 51 + type Token struct { 52 + IDToken string `json:"id_token,omitempty"` 53 + AccessToken string `json:"access_token"` 54 + RefreshToken string `json:"refresh_token"` 55 + AccountID string `json:"account_id,omitempty"` 56 + PlanType string `json:"plan_type,omitempty"` 57 + ExpiresAt time.Time `json:"expires_at,omitempty"` 58 + CreatedAt time.Time `json:"created_at,omitempty"` 59 + UpdatedAt time.Time `json:"updated_at,omitempty"` 60 + } 61 + 62 + type userCodeResponse struct { 63 + DeviceAuthID string `json:"device_auth_id"` 64 + UserCode string `json:"user_code"` 65 + UserCodeAlt string `json:"usercode"` 66 + Interval any `json:"interval"` 67 + ExpiresIn any `json:"expires_in"` 68 + } 69 + 70 + type tokenPollRequest struct { 71 + DeviceAuthID string `json:"device_auth_id"` 72 + UserCode string `json:"user_code"` 73 + } 74 + 75 + type tokenPollResponse struct { 76 + AuthorizationCode string `json:"authorization_code"` 77 + CodeVerifier string `json:"code_verifier"` 78 + } 79 + 80 + type tokenResponse struct { 81 + IDToken string `json:"id_token"` 82 + AccessToken string `json:"access_token"` 83 + RefreshToken string `json:"refresh_token"` 84 + ExpiresIn any `json:"expires_in"` 85 + } 86 + 87 + func DefaultOAuthConfigValue() OAuthConfig { 88 + return OAuthConfig{ 89 + Issuer: DefaultIssuer, 90 + ClientID: DefaultClientID, 91 + } 92 + } 93 + 94 + func RequestDeviceCode(ctx context.Context, cfg OAuthConfig) (DeviceCode, error) { 95 + cfg = normalizeOAuthConfig(cfg) 96 + endpoint := strings.TrimRight(cfg.Issuer, "/") + "/api/accounts/deviceauth/usercode" 97 + reqBody := map[string]string{"client_id": cfg.ClientID} 98 + 99 + var resp userCodeResponse 100 + if err := postJSON(ctx, cfg.HTTPClient, endpoint, reqBody, &resp); err != nil { 101 + return DeviceCode{}, err 102 + } 103 + 104 + userCode := firstNonEmpty(resp.UserCode, resp.UserCodeAlt) 105 + deviceAuthID := strings.TrimSpace(resp.DeviceAuthID) 106 + if deviceAuthID == "" || userCode == "" { 107 + return DeviceCode{}, fmt.Errorf("codex device auth response missing device id or user code") 108 + } 109 + 110 + interval := durationFromAny(resp.Interval, defaultPollInterval) 111 + expiresIn := durationFromAny(resp.ExpiresIn, defaultDeviceTTL) 112 + now := cfg.now() 113 + return DeviceCode{ 114 + VerificationURL: strings.TrimRight(cfg.Issuer, "/") + "/codex/device", 115 + UserCode: userCode, 116 + DeviceAuthID: deviceAuthID, 117 + Interval: interval, 118 + ExpiresAt: now.Add(expiresIn), 119 + }, nil 120 + } 121 + 122 + func CompleteDeviceCodeLogin(ctx context.Context, cfg OAuthConfig, code DeviceCode) (Token, error) { 123 + cfg = normalizeOAuthConfig(cfg) 124 + poll, err := pollDeviceCode(ctx, cfg, code) 125 + if err != nil { 126 + return Token{}, err 127 + } 128 + redirectURI := strings.TrimRight(cfg.Issuer, "/") + "/deviceauth/callback" 129 + return exchangeAuthorizationCode(ctx, cfg, redirectURI, poll.AuthorizationCode, poll.CodeVerifier) 130 + } 131 + 132 + func RefreshToken(ctx context.Context, cfg OAuthConfig, refreshToken string) (Token, error) { 133 + cfg = normalizeOAuthConfig(cfg) 134 + refreshToken = strings.TrimSpace(refreshToken) 135 + if refreshToken == "" { 136 + return Token{}, ErrNotLoggedIn 137 + } 138 + 139 + endpoint := strings.TrimRight(cfg.Issuer, "/") + "/oauth/token" 140 + form := url.Values{} 141 + form.Set("client_id", cfg.ClientID) 142 + form.Set("grant_type", "refresh_token") 143 + form.Set("refresh_token", refreshToken) 144 + 145 + var resp tokenResponse 146 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) 147 + if err != nil { 148 + return Token{}, err 149 + } 150 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 151 + httpResp, err := cfg.HTTPClient.Do(req) 152 + if err != nil { 153 + return Token{}, fmt.Errorf("codex refresh token request failed: %w", err) 154 + } 155 + defer httpResp.Body.Close() 156 + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { 157 + body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 4096)) 158 + return Token{}, statusError(endpoint, httpResp.StatusCode, body) 159 + } 160 + if err := json.NewDecoder(io.LimitReader(httpResp.Body, 1<<20)).Decode(&resp); err != nil { 161 + return Token{}, fmt.Errorf("decode codex refresh token response: %w", err) 162 + } 163 + token := tokenFromResponse(resp, cfg.now()) 164 + if token.RefreshToken == "" { 165 + token.RefreshToken = refreshToken 166 + } 167 + if token.AccessToken == "" { 168 + return Token{}, fmt.Errorf("codex refresh response missing access token") 169 + } 170 + return token, nil 171 + } 172 + 173 + func ResolveToken(ctx context.Context, stateDir string, cfg OAuthConfig) (Token, error) { 174 + cfg = normalizeOAuthConfig(cfg) 175 + token, ok, err := ReadToken(stateDir) 176 + if err != nil { 177 + return Token{}, err 178 + } 179 + if !ok { 180 + return Token{}, ErrNotLoggedIn 181 + } 182 + if token.IsAccessTokenUsable(cfg.now()) { 183 + return token, nil 184 + } 185 + if strings.TrimSpace(token.RefreshToken) == "" { 186 + return Token{}, ErrNotLoggedIn 187 + } 188 + 189 + refreshMu.Lock() 190 + defer refreshMu.Unlock() 191 + 192 + token, ok, err = ReadToken(stateDir) 193 + if err != nil { 194 + return Token{}, err 195 + } 196 + if !ok { 197 + return Token{}, ErrNotLoggedIn 198 + } 199 + if token.IsAccessTokenUsable(cfg.now()) { 200 + return token, nil 201 + } 202 + if strings.TrimSpace(token.RefreshToken) == "" { 203 + return Token{}, ErrNotLoggedIn 204 + } 205 + 206 + refreshed, err := RefreshToken(ctx, cfg, token.RefreshToken) 207 + if err != nil { 208 + return Token{}, err 209 + } 210 + if refreshed.IDToken == "" { 211 + refreshed.IDToken = token.IDToken 212 + } 213 + if refreshed.AccountID == "" { 214 + refreshed.AccountID = token.AccountID 215 + } 216 + if refreshed.PlanType == "" { 217 + refreshed.PlanType = token.PlanType 218 + } 219 + if refreshed.CreatedAt.IsZero() { 220 + refreshed.CreatedAt = token.CreatedAt 221 + } 222 + if err := WriteToken(stateDir, refreshed); err != nil { 223 + return Token{}, err 224 + } 225 + return refreshed, nil 226 + } 227 + 228 + func (t Token) IsAccessTokenUsable(now time.Time) bool { 229 + if strings.TrimSpace(t.AccessToken) == "" { 230 + return false 231 + } 232 + if t.ExpiresAt.IsZero() { 233 + return true 234 + } 235 + if now.IsZero() { 236 + now = time.Now().UTC() 237 + } 238 + return t.ExpiresAt.After(now.UTC().Add(60 * time.Second)) 239 + } 240 + 241 + func IsAuthorizationPending(err error) bool { 242 + return errors.Is(err, ErrAuthorizationPending) 243 + } 244 + 245 + func pollDeviceCode(ctx context.Context, cfg OAuthConfig, code DeviceCode) (tokenPollResponse, error) { 246 + if strings.TrimSpace(code.DeviceAuthID) == "" || strings.TrimSpace(code.UserCode) == "" { 247 + return tokenPollResponse{}, fmt.Errorf("codex device auth session is invalid") 248 + } 249 + if !code.ExpiresAt.IsZero() && !code.ExpiresAt.After(cfg.now()) { 250 + return tokenPollResponse{}, fmt.Errorf("codex device code expired") 251 + } 252 + 253 + endpoint := strings.TrimRight(cfg.Issuer, "/") + "/api/accounts/deviceauth/token" 254 + reqBody := tokenPollRequest{ 255 + DeviceAuthID: strings.TrimSpace(code.DeviceAuthID), 256 + UserCode: strings.TrimSpace(code.UserCode), 257 + } 258 + 259 + var out tokenPollResponse 260 + reqData, err := json.Marshal(reqBody) 261 + if err != nil { 262 + return tokenPollResponse{}, err 263 + } 264 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqData)) 265 + if err != nil { 266 + return tokenPollResponse{}, err 267 + } 268 + req.Header.Set("Content-Type", "application/json") 269 + 270 + resp, err := cfg.HTTPClient.Do(req) 271 + if err != nil { 272 + return tokenPollResponse{}, fmt.Errorf("codex device auth request failed: %w", err) 273 + } 274 + defer resp.Body.Close() 275 + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound { 276 + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) 277 + return tokenPollResponse{}, ErrAuthorizationPending 278 + } 279 + if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusAccepted { 280 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 281 + if isPendingBody(body) { 282 + return tokenPollResponse{}, ErrAuthorizationPending 283 + } 284 + return tokenPollResponse{}, statusError(endpoint, resp.StatusCode, body) 285 + } 286 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 287 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 288 + return tokenPollResponse{}, statusError(endpoint, resp.StatusCode, body) 289 + } 290 + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil { 291 + return tokenPollResponse{}, fmt.Errorf("decode codex device auth response: %w", err) 292 + } 293 + if strings.TrimSpace(out.AuthorizationCode) == "" || strings.TrimSpace(out.CodeVerifier) == "" { 294 + return tokenPollResponse{}, fmt.Errorf("codex device auth response missing authorization code or verifier") 295 + } 296 + return out, nil 297 + } 298 + 299 + func exchangeAuthorizationCode(ctx context.Context, cfg OAuthConfig, redirectURI, code, codeVerifier string) (Token, error) { 300 + endpoint := strings.TrimRight(cfg.Issuer, "/") + "/oauth/token" 301 + form := url.Values{} 302 + form.Set("grant_type", "authorization_code") 303 + form.Set("code", strings.TrimSpace(code)) 304 + form.Set("redirect_uri", strings.TrimSpace(redirectURI)) 305 + form.Set("client_id", cfg.ClientID) 306 + form.Set("code_verifier", strings.TrimSpace(codeVerifier)) 307 + 308 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) 309 + if err != nil { 310 + return Token{}, err 311 + } 312 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 313 + resp, err := cfg.HTTPClient.Do(req) 314 + if err != nil { 315 + return Token{}, fmt.Errorf("codex token exchange request failed: %w", err) 316 + } 317 + defer resp.Body.Close() 318 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 319 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 320 + return Token{}, statusError(endpoint, resp.StatusCode, body) 321 + } 322 + var decoded tokenResponse 323 + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil { 324 + return Token{}, fmt.Errorf("decode codex token exchange response: %w", err) 325 + } 326 + token := tokenFromResponse(decoded, cfg.now()) 327 + if token.AccessToken == "" || token.RefreshToken == "" { 328 + return Token{}, fmt.Errorf("codex token exchange response missing access or refresh token") 329 + } 330 + return token, nil 331 + } 332 + 333 + func tokenFromResponse(resp tokenResponse, now time.Time) Token { 334 + now = now.UTC() 335 + var expiresAt time.Time 336 + if d := durationFromAny(resp.ExpiresIn, 0); d > 0 { 337 + expiresAt = now.Add(d) 338 + } else { 339 + if exp, ok := JWTExpiration(resp.AccessToken); ok { 340 + expiresAt = exp 341 + } 342 + } 343 + accountID := firstNonEmpty( 344 + JWTStringClaim(resp.IDToken, "chatgpt_account_id"), 345 + JWTStringClaim(resp.AccessToken, "chatgpt_account_id"), 346 + JWTStringClaim(resp.AccessToken, "account_id"), 347 + ) 348 + planType := firstNonEmpty( 349 + JWTStringClaim(resp.AccessToken, "chatgpt_plan_type"), 350 + JWTStringClaim(resp.IDToken, "chatgpt_plan_type"), 351 + ) 352 + return Token{ 353 + IDToken: strings.TrimSpace(resp.IDToken), 354 + AccessToken: strings.TrimSpace(resp.AccessToken), 355 + RefreshToken: strings.TrimSpace(resp.RefreshToken), 356 + AccountID: accountID, 357 + PlanType: planType, 358 + ExpiresAt: expiresAt, 359 + CreatedAt: now, 360 + UpdatedAt: now, 361 + } 362 + } 363 + 364 + func JWTExpiration(token string) (time.Time, bool) { 365 + claims, ok := jwtClaims(token) 366 + if !ok { 367 + return time.Time{}, false 368 + } 369 + exp, ok := numberClaim(claims, "exp") 370 + if !ok || exp <= 0 { 371 + return time.Time{}, false 372 + } 373 + return time.Unix(int64(exp), 0).UTC(), true 374 + } 375 + 376 + func JWTStringClaim(token string, key string) string { 377 + claims, ok := jwtClaims(token) 378 + if !ok { 379 + return "" 380 + } 381 + key = strings.TrimSpace(key) 382 + if key == "" { 383 + return "" 384 + } 385 + if v, ok := claims[key].(string); ok { 386 + return strings.TrimSpace(v) 387 + } 388 + if auth, ok := claims["https://api.openai.com/auth"].(map[string]any); ok { 389 + if v, ok := auth[key].(string); ok { 390 + return strings.TrimSpace(v) 391 + } 392 + } 393 + return "" 394 + } 395 + 396 + func jwtClaims(token string) (map[string]any, bool) { 397 + parts := strings.Split(strings.TrimSpace(token), ".") 398 + if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { 399 + return nil, false 400 + } 401 + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 402 + if err != nil { 403 + if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { 404 + return nil, false 405 + } 406 + } 407 + var claims map[string]any 408 + if err := json.Unmarshal(payload, &claims); err != nil { 409 + return nil, false 410 + } 411 + return claims, true 412 + } 413 + 414 + func numberClaim(claims map[string]any, key string) (float64, bool) { 415 + switch v := claims[key].(type) { 416 + case float64: 417 + return v, true 418 + case int: 419 + return float64(v), true 420 + case int64: 421 + return float64(v), true 422 + case json.Number: 423 + f, err := v.Float64() 424 + return f, err == nil 425 + case string: 426 + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) 427 + return f, err == nil 428 + default: 429 + return 0, false 430 + } 431 + } 432 + 433 + func postJSON(ctx context.Context, client *http.Client, endpoint string, in any, out any) error { 434 + data, err := json.Marshal(in) 435 + if err != nil { 436 + return err 437 + } 438 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) 439 + if err != nil { 440 + return err 441 + } 442 + req.Header.Set("Content-Type", "application/json") 443 + resp, err := client.Do(req) 444 + if err != nil { 445 + return fmt.Errorf("codex oauth request failed: %w", err) 446 + } 447 + defer resp.Body.Close() 448 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 449 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 450 + return statusError(endpoint, resp.StatusCode, body) 451 + } 452 + if out == nil { 453 + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) 454 + return nil 455 + } 456 + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out); err != nil { 457 + return fmt.Errorf("decode codex oauth response: %w", err) 458 + } 459 + return nil 460 + } 461 + 462 + func statusError(endpoint string, status int, body []byte) error { 463 + message := parseErrorMessage(body) 464 + u, _ := url.Parse(endpoint) 465 + hostPath := endpoint 466 + if u != nil && u.Host != "" { 467 + hostPath = u.Host + u.Path 468 + } 469 + if message == "" { 470 + return fmt.Errorf("codex oauth request to %s failed with status %d", hostPath, status) 471 + } 472 + return fmt.Errorf("codex oauth request to %s failed with status %d: %s", hostPath, status, message) 473 + } 474 + 475 + func parseErrorMessage(body []byte) string { 476 + body = bytes.TrimSpace(body) 477 + if len(body) == 0 { 478 + return "" 479 + } 480 + var decoded map[string]any 481 + if err := json.Unmarshal(body, &decoded); err == nil { 482 + for _, key := range []string{"error_description", "message", "detail", "code"} { 483 + if v, ok := decoded[key].(string); ok && strings.TrimSpace(v) != "" { 484 + return strings.TrimSpace(v) 485 + } 486 + } 487 + if nested, ok := decoded["error"].(map[string]any); ok { 488 + for _, key := range []string{"message", "code"} { 489 + if v, ok := nested[key].(string); ok && strings.TrimSpace(v) != "" { 490 + return strings.TrimSpace(v) 491 + } 492 + } 493 + } 494 + if v, ok := decoded["error"].(string); ok && strings.TrimSpace(v) != "" { 495 + return strings.TrimSpace(v) 496 + } 497 + return "" 498 + } 499 + text := string(body) 500 + if len(text) > 240 { 501 + text = text[:240] 502 + } 503 + return strings.TrimSpace(text) 504 + } 505 + 506 + func isPendingBody(body []byte) bool { 507 + message := strings.ToLower(parseErrorMessage(body)) 508 + return strings.Contains(message, "authorization_pending") || 509 + strings.Contains(message, "pending") || 510 + strings.Contains(message, "slow_down") 511 + } 512 + 513 + func normalizeOAuthConfig(cfg OAuthConfig) OAuthConfig { 514 + cfg.Issuer = strings.TrimRight(strings.TrimSpace(cfg.Issuer), "/") 515 + if cfg.Issuer == "" { 516 + cfg.Issuer = DefaultIssuer 517 + } 518 + cfg.ClientID = strings.TrimSpace(cfg.ClientID) 519 + if cfg.ClientID == "" { 520 + cfg.ClientID = DefaultClientID 521 + } 522 + if cfg.HTTPClient == nil { 523 + cfg.HTTPClient = http.DefaultClient 524 + } 525 + return cfg 526 + } 527 + 528 + func (cfg OAuthConfig) now() time.Time { 529 + if cfg.Now != nil { 530 + return cfg.Now().UTC() 531 + } 532 + return time.Now().UTC() 533 + } 534 + 535 + func durationFromAny(raw any, fallback time.Duration) time.Duration { 536 + switch v := raw.(type) { 537 + case nil: 538 + return fallback 539 + case float64: 540 + if v <= 0 { 541 + return fallback 542 + } 543 + return time.Duration(v) * time.Second 544 + case int: 545 + if v <= 0 { 546 + return fallback 547 + } 548 + return time.Duration(v) * time.Second 549 + case int64: 550 + if v <= 0 { 551 + return fallback 552 + } 553 + return time.Duration(v) * time.Second 554 + case json.Number: 555 + i, err := v.Int64() 556 + if err != nil || i <= 0 { 557 + return fallback 558 + } 559 + return time.Duration(i) * time.Second 560 + case string: 561 + v = strings.TrimSpace(v) 562 + if v == "" { 563 + return fallback 564 + } 565 + if seconds, err := strconv.ParseInt(v, 10, 64); err == nil && seconds > 0 { 566 + return time.Duration(seconds) * time.Second 567 + } 568 + if d, err := time.ParseDuration(v); err == nil && d > 0 { 569 + return d 570 + } 571 + return fallback 572 + default: 573 + return fallback 574 + } 575 + } 576 + 577 + func firstNonEmpty(values ...string) string { 578 + for _, value := range values { 579 + if value = strings.TrimSpace(value); value != "" { 580 + return value 581 + } 582 + } 583 + return "" 584 + }
+276
internal/codexauth/oauth_test.go
··· 1 + package codexauth 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + "sync/atomic" 13 + "testing" 14 + "time" 15 + ) 16 + 17 + func TestDeviceCodeLoginFlow(t *testing.T) { 18 + now := time.Date(2026, 4, 24, 1, 2, 3, 0, time.UTC) 19 + accessToken := testJWT(t, map[string]any{ 20 + "exp": now.Add(time.Hour).Unix(), 21 + "https://api.openai.com/auth": map[string]any{ 22 + "chatgpt_account_id": "acc_123", 23 + "chatgpt_plan_type": "plus", 24 + }, 25 + }) 26 + mux := http.NewServeMux() 27 + mux.HandleFunc("/api/accounts/deviceauth/usercode", func(w http.ResponseWriter, r *http.Request) { 28 + if r.Method != http.MethodPost { 29 + t.Fatalf("method = %s", r.Method) 30 + } 31 + var body map[string]string 32 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 33 + t.Fatalf("decode usercode body: %v", err) 34 + } 35 + if body["client_id"] != DefaultClientID { 36 + t.Fatalf("client_id = %q", body["client_id"]) 37 + } 38 + writeTestJSON(w, map[string]any{ 39 + "device_auth_id": "dev_123", 40 + "user_code": "ABCD-EFGH", 41 + "interval": "1", 42 + }) 43 + }) 44 + mux.HandleFunc("/api/accounts/deviceauth/token", func(w http.ResponseWriter, r *http.Request) { 45 + var body tokenPollRequest 46 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 47 + t.Fatalf("decode poll body: %v", err) 48 + } 49 + if body.DeviceAuthID != "dev_123" || body.UserCode != "ABCD-EFGH" { 50 + t.Fatalf("poll body = %+v", body) 51 + } 52 + writeTestJSON(w, map[string]any{ 53 + "authorization_code": "code_123", 54 + "code_verifier": "verifier_123", 55 + }) 56 + }) 57 + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { 58 + if got := r.Header.Get("Content-Type"); !strings.Contains(got, "application/x-www-form-urlencoded") { 59 + t.Fatalf("content-type = %q", got) 60 + } 61 + if err := r.ParseForm(); err != nil { 62 + t.Fatalf("parse form: %v", err) 63 + } 64 + if r.Form.Get("grant_type") != "authorization_code" || r.Form.Get("code") != "code_123" { 65 + t.Fatalf("form = %v", r.Form) 66 + } 67 + writeTestJSON(w, map[string]any{ 68 + "access_token": accessToken, 69 + "refresh_token": "refresh_123", 70 + }) 71 + }) 72 + server := httptest.NewServer(mux) 73 + defer server.Close() 74 + 75 + cfg := OAuthConfig{Issuer: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} 76 + deviceCode, err := RequestDeviceCode(context.Background(), cfg) 77 + if err != nil { 78 + t.Fatalf("RequestDeviceCode() error = %v", err) 79 + } 80 + if deviceCode.VerificationURL != server.URL+"/codex/device" || deviceCode.UserCode != "ABCD-EFGH" { 81 + t.Fatalf("device code = %+v", deviceCode) 82 + } 83 + 84 + token, err := CompleteDeviceCodeLogin(context.Background(), cfg, deviceCode) 85 + if err != nil { 86 + t.Fatalf("CompleteDeviceCodeLogin() error = %v", err) 87 + } 88 + if token.AccessToken != accessToken || token.RefreshToken != "refresh_123" { 89 + t.Fatalf("token = %+v", token) 90 + } 91 + if token.AccountID != "acc_123" || token.PlanType != "plus" { 92 + t.Fatalf("claims not extracted: %+v", token) 93 + } 94 + } 95 + 96 + func TestResolveTokenRefreshesExpiredAccessToken(t *testing.T) { 97 + now := time.Date(2026, 4, 24, 2, 0, 0, 0, time.UTC) 98 + refreshedAccess := testJWT(t, map[string]any{"exp": now.Add(2 * time.Hour).Unix()}) 99 + mux := http.NewServeMux() 100 + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { 101 + if got := r.Header.Get("Content-Type"); !strings.Contains(got, "application/x-www-form-urlencoded") { 102 + t.Fatalf("content-type = %q", got) 103 + } 104 + if err := r.ParseForm(); err != nil { 105 + t.Fatalf("parse form: %v", err) 106 + } 107 + if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "refresh_old" { 108 + t.Fatalf("refresh form = %v", r.Form) 109 + } 110 + writeTestJSON(w, map[string]any{ 111 + "access_token": refreshedAccess, 112 + "refresh_token": "refresh_new", 113 + }) 114 + }) 115 + server := httptest.NewServer(mux) 116 + defer server.Close() 117 + 118 + stateDir := t.TempDir() 119 + if err := WriteToken(stateDir, Token{ 120 + AccessToken: testJWT(t, map[string]any{"exp": now.Add(-time.Hour).Unix()}), 121 + RefreshToken: "refresh_old", 122 + AccountID: "acc_old", 123 + CreatedAt: now.Add(-24 * time.Hour), 124 + UpdatedAt: now.Add(-24 * time.Hour), 125 + }); err != nil { 126 + t.Fatalf("WriteToken() error = %v", err) 127 + } 128 + 129 + cfg := OAuthConfig{Issuer: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} 130 + token, err := ResolveToken(context.Background(), stateDir, cfg) 131 + if err != nil { 132 + t.Fatalf("ResolveToken() error = %v", err) 133 + } 134 + if token.AccessToken != refreshedAccess || token.RefreshToken != "refresh_new" { 135 + t.Fatalf("token = %+v", token) 136 + } 137 + if token.AccountID != "acc_old" { 138 + t.Fatalf("account id = %q", token.AccountID) 139 + } 140 + } 141 + 142 + func TestResolveTokenSerializesConcurrentRefresh(t *testing.T) { 143 + now := time.Date(2026, 4, 24, 3, 0, 0, 0, time.UTC) 144 + refreshedAccess := testJWT(t, map[string]any{"exp": now.Add(2 * time.Hour).Unix()}) 145 + entered := make(chan struct{}, 1) 146 + release := make(chan struct{}) 147 + var refreshCount atomic.Int32 148 + mux := http.NewServeMux() 149 + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { 150 + refreshCount.Add(1) 151 + select { 152 + case entered <- struct{}{}: 153 + default: 154 + } 155 + select { 156 + case <-release: 157 + case <-r.Context().Done(): 158 + return 159 + } 160 + writeTestJSON(w, map[string]any{ 161 + "access_token": refreshedAccess, 162 + "refresh_token": "refresh_new", 163 + }) 164 + }) 165 + server := httptest.NewServer(mux) 166 + defer server.Close() 167 + 168 + stateDir := t.TempDir() 169 + if err := WriteToken(stateDir, Token{ 170 + AccessToken: testJWT(t, map[string]any{"exp": now.Add(-time.Hour).Unix()}), 171 + RefreshToken: "refresh_old", 172 + }); err != nil { 173 + t.Fatalf("WriteToken() error = %v", err) 174 + } 175 + 176 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 177 + defer cancel() 178 + cfg := OAuthConfig{Issuer: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} 179 + errCh := make(chan error, 2) 180 + go func() { 181 + _, err := ResolveToken(ctx, stateDir, cfg) 182 + errCh <- err 183 + }() 184 + 185 + select { 186 + case <-entered: 187 + case <-ctx.Done(): 188 + t.Fatalf("first refresh did not start: %v", ctx.Err()) 189 + } 190 + 191 + go func() { 192 + _, err := ResolveToken(ctx, stateDir, cfg) 193 + errCh <- err 194 + }() 195 + time.Sleep(50 * time.Millisecond) 196 + close(release) 197 + 198 + for i := 0; i < 2; i++ { 199 + select { 200 + case err := <-errCh: 201 + if err != nil { 202 + t.Fatalf("ResolveToken() error = %v", err) 203 + } 204 + case <-ctx.Done(): 205 + t.Fatalf("ResolveToken() timed out: %v", ctx.Err()) 206 + } 207 + } 208 + if got := refreshCount.Load(); got != 1 { 209 + t.Fatalf("refresh count = %d, want 1", got) 210 + } 211 + } 212 + 213 + func TestTokenStoreStatusAndPermissions(t *testing.T) { 214 + stateDir := t.TempDir() 215 + exp := time.Now().UTC().Add(time.Hour) 216 + if err := WriteToken(stateDir, Token{ 217 + AccessToken: testJWT(t, map[string]any{"exp": exp.Unix()}), 218 + RefreshToken: "refresh", 219 + }); err != nil { 220 + t.Fatalf("WriteToken() error = %v", err) 221 + } 222 + path := TokenPath(stateDir) 223 + info, err := os.Stat(path) 224 + if err != nil { 225 + t.Fatalf("stat token: %v", err) 226 + } 227 + if info.Mode().Perm() != 0o600 { 228 + t.Fatalf("token mode = %o, want 0600", info.Mode().Perm()) 229 + } 230 + 231 + status := ReadStatus(stateDir, time.Now().UTC()) 232 + if !status.LoggedIn || !status.FileModeOK || status.AccessTokenExpired { 233 + t.Fatalf("status = %+v", status) 234 + } 235 + if !strings.HasSuffix(filepath.ToSlash(TokenPath(stateDir)), "/auth/codex.json") { 236 + t.Fatalf("token path = %s", TokenPath(stateDir)) 237 + } 238 + } 239 + 240 + func TestTokenStoreStatusTreatsExpiredAccessOnlyTokenAsSignedOut(t *testing.T) { 241 + now := time.Date(2026, 4, 24, 4, 0, 0, 0, time.UTC) 242 + stateDir := t.TempDir() 243 + if err := WriteToken(stateDir, Token{ 244 + AccessToken: testJWT(t, map[string]any{"exp": now.Add(-time.Minute).Unix()}), 245 + }); err != nil { 246 + t.Fatalf("WriteToken() error = %v", err) 247 + } 248 + 249 + status := ReadStatus(stateDir, now) 250 + if status.LoggedIn { 251 + t.Fatalf("status should be signed out for expired access-only token: %+v", status) 252 + } 253 + if !status.AccessTokenPresent || status.RefreshTokenPresent || !status.AccessTokenExpired { 254 + t.Fatalf("status token fields = %+v", status) 255 + } 256 + } 257 + 258 + func testJWT(t *testing.T, claims map[string]any) string { 259 + t.Helper() 260 + header := map[string]any{"alg": "none", "typ": "JWT"} 261 + headerData, err := json.Marshal(header) 262 + if err != nil { 263 + t.Fatal(err) 264 + } 265 + claimsData, err := json.Marshal(claims) 266 + if err != nil { 267 + t.Fatal(err) 268 + } 269 + return base64.RawURLEncoding.EncodeToString(headerData) + "." + 270 + base64.RawURLEncoding.EncodeToString(claimsData) + ".sig" 271 + } 272 + 273 + func writeTestJSON(w http.ResponseWriter, value any) { 274 + w.Header().Set("Content-Type", "application/json") 275 + _ = json.NewEncoder(w).Encode(value) 276 + }
+153
internal/codexauth/store.go
··· 1 + package codexauth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "runtime" 9 + "strings" 10 + "time" 11 + 12 + "github.com/quailyquaily/mistermorph/internal/fsstore" 13 + "github.com/quailyquaily/mistermorph/internal/pathutil" 14 + ) 15 + 16 + type Status struct { 17 + LoggedIn bool `json:"logged_in"` 18 + AccessTokenPresent bool `json:"access_token_present"` 19 + RefreshTokenPresent bool `json:"refresh_token_present"` 20 + AccessTokenExpired bool `json:"access_token_expired"` 21 + ExpiresAt *time.Time `json:"expires_at,omitempty"` 22 + AccountID string `json:"account_id,omitempty"` 23 + PlanType string `json:"plan_type,omitempty"` 24 + FileModeOK bool `json:"file_mode_ok"` 25 + FileModeWarning string `json:"file_mode_warning,omitempty"` 26 + } 27 + 28 + func TokenPath(stateDir string) string { 29 + return filepath.Clean(filepath.Join(pathutil.ResolveStateDir(stateDir), "auth", "codex.json")) 30 + } 31 + 32 + func DisplayTokenPath() string { 33 + return "<file_state_dir>/auth/codex.json" 34 + } 35 + 36 + func ReadToken(stateDir string) (Token, bool, error) { 37 + var token Token 38 + ok, err := fsstore.ReadJSON(TokenPath(stateDir), &token) 39 + if err != nil || !ok { 40 + return Token{}, ok, err 41 + } 42 + return normalizeToken(token), true, nil 43 + } 44 + 45 + func WriteToken(stateDir string, token Token) error { 46 + now := time.Now().UTC() 47 + token = normalizeToken(token) 48 + if token.CreatedAt.IsZero() { 49 + token.CreatedAt = now 50 + } 51 + token.UpdatedAt = now 52 + return fsstore.WriteJSONAtomic(TokenPath(stateDir), token, fsstore.FileOptions{DirPerm: 0o700, FilePerm: 0o600}) 53 + } 54 + 55 + func DeleteToken(stateDir string) (bool, error) { 56 + path := TokenPath(stateDir) 57 + if info, err := os.Stat(path); err == nil && info.IsDir() { 58 + return false, fmt.Errorf("codex token path is a directory") 59 + } 60 + if err := os.Remove(path); err != nil { 61 + if errors.Is(err, os.ErrNotExist) { 62 + return false, nil 63 + } 64 + return false, err 65 + } 66 + return true, nil 67 + } 68 + 69 + func ReadStatus(stateDir string, now time.Time) Status { 70 + if now.IsZero() { 71 + now = time.Now().UTC() 72 + } 73 + path := TokenPath(stateDir) 74 + status := Status{ 75 + FileModeOK: true, 76 + } 77 + info, statErr := os.Stat(path) 78 + if statErr != nil { 79 + if errors.Is(statErr, os.ErrNotExist) { 80 + return status 81 + } 82 + status.FileModeOK = false 83 + status.FileModeWarning = "token file cannot be inspected" 84 + return status 85 + } 86 + if info.IsDir() { 87 + status.FileModeOK = false 88 + status.FileModeWarning = "token path is a directory" 89 + return status 90 + } 91 + if warning := fileModeWarning(info.Mode()); warning != "" { 92 + status.FileModeOK = false 93 + status.FileModeWarning = warning 94 + } 95 + 96 + token, ok, err := ReadToken(stateDir) 97 + if err != nil || !ok { 98 + status.FileModeOK = false 99 + status.FileModeWarning = firstNonEmpty(status.FileModeWarning, "token file cannot be decoded") 100 + return status 101 + } 102 + status.AccessTokenPresent = strings.TrimSpace(token.AccessToken) != "" 103 + status.RefreshTokenPresent = strings.TrimSpace(token.RefreshToken) != "" 104 + status.LoggedIn = token.IsAccessTokenUsable(now) || status.RefreshTokenPresent 105 + if !token.ExpiresAt.IsZero() { 106 + expiresAt := token.ExpiresAt 107 + status.ExpiresAt = &expiresAt 108 + } 109 + status.AccountID = token.AccountID 110 + status.PlanType = token.PlanType 111 + status.AccessTokenExpired = status.AccessTokenPresent && !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now.UTC()) 112 + return status 113 + } 114 + 115 + func normalizeToken(token Token) Token { 116 + token.IDToken = strings.TrimSpace(token.IDToken) 117 + token.AccessToken = strings.TrimSpace(token.AccessToken) 118 + token.RefreshToken = strings.TrimSpace(token.RefreshToken) 119 + token.AccountID = strings.TrimSpace(token.AccountID) 120 + token.PlanType = strings.TrimSpace(token.PlanType) 121 + token.CreatedAt = token.CreatedAt.UTC() 122 + token.UpdatedAt = token.UpdatedAt.UTC() 123 + token.ExpiresAt = token.ExpiresAt.UTC() 124 + if token.AccountID == "" { 125 + token.AccountID = firstNonEmpty( 126 + JWTStringClaim(token.IDToken, "chatgpt_account_id"), 127 + JWTStringClaim(token.AccessToken, "chatgpt_account_id"), 128 + JWTStringClaim(token.AccessToken, "account_id"), 129 + ) 130 + } 131 + if token.PlanType == "" { 132 + token.PlanType = firstNonEmpty( 133 + JWTStringClaim(token.AccessToken, "chatgpt_plan_type"), 134 + JWTStringClaim(token.IDToken, "chatgpt_plan_type"), 135 + ) 136 + } 137 + if token.ExpiresAt.IsZero() { 138 + if exp, ok := JWTExpiration(token.AccessToken); ok { 139 + token.ExpiresAt = exp 140 + } 141 + } 142 + return token 143 + } 144 + 145 + func fileModeWarning(mode os.FileMode) string { 146 + if runtime.GOOS == "windows" { 147 + return "" 148 + } 149 + if mode.Perm()&0o077 != 0 { 150 + return "token file permissions are wider than 0600" 151 + } 152 + return "" 153 + }
+25
internal/llmutil/llmutil.go
··· 7 7 "strings" 8 8 "time" 9 9 10 + "github.com/quailyquaily/mistermorph/internal/codexauth" 10 11 "github.com/quailyquaily/mistermorph/internal/llmconfig" 11 12 "github.com/quailyquaily/mistermorph/internal/pricingutil" 12 13 "github.com/quailyquaily/mistermorph/llm" 14 + codexProvider "github.com/quailyquaily/mistermorph/providers/codex" 13 15 uniaiProvider "github.com/quailyquaily/mistermorph/providers/uniai" 14 16 uniaiapi "github.com/quailyquaily/uniai" 15 17 "github.com/spf13/viper" ··· 35 37 ReasoningBudgetRaw string `config:"llm.reasoning_budget_tokens"` 36 38 PricingFile string `config:"llm.pricing_file"` 37 39 ConfigPath string `config:"config"` 40 + FileStateDir string `config:"file_state_dir"` 38 41 Profiles map[string]ProfileConfig 39 42 Routes RoutesConfig 40 43 ··· 68 71 ReasoningBudgetRaw: strings.TrimSpace(r.GetString("llm.reasoning_budget_tokens")), 69 72 PricingFile: strings.TrimSpace(r.GetString("llm.pricing_file")), 70 73 ConfigPath: strings.TrimSpace(r.GetString("config")), 74 + FileStateDir: strings.TrimSpace(r.GetString("file_state_dir")), 71 75 Profiles: loadLLMProfilesFromReader(r), 72 76 Routes: loadLLMRoutesFromReader(r), 73 77 BedrockAWSKey: firstNonEmpty(r.GetString("llm.bedrock.aws_key"), r.GetString("llm.aws.key")), ··· 97 101 func EndpointForProviderWithValues(provider string, values RuntimeValues) string { 98 102 provider = normalizeProvider(provider) 99 103 switch provider { 104 + case "openai_codex": 105 + return codexauth.DefaultAPIBase 100 106 case "cloudflare": 101 107 generic := strings.TrimSpace(values.Endpoint) 102 108 if generic != "" && generic != "https://api.openai.com" && generic != "https://api.openai.com/v1" { ··· 111 117 func APIKeyForProviderWithValues(provider string, values RuntimeValues) string { 112 118 provider = normalizeProvider(provider) 113 119 switch provider { 120 + case "openai_codex": 121 + return "" 114 122 case "cloudflare": 115 123 return firstNonEmpty( 116 124 values.CloudflareAPIToken, ··· 128 136 return firstNonEmpty( 129 137 values.AzureDeployment, 130 138 values.Model, 139 + ) 140 + case "openai_codex": 141 + return firstNonEmpty( 142 + values.Model, 143 + codexauth.DefaultModel, 131 144 ) 132 145 default: 133 146 return strings.TrimSpace(values.Model) ··· 168 181 uniaiProviderName = "openai" 169 182 } 170 183 switch provider { 184 + case "openai_codex": 185 + return codexProvider.New(codexProvider.Config{ 186 + Endpoint: strings.TrimSpace(cfg.Endpoint), 187 + Model: strings.TrimSpace(cfg.Model), 188 + Headers: cloneStringMap(cfg.Headers), 189 + Pricing: pricing, 190 + RequestTimeout: cfg.RequestTimeout, 191 + ToolsEmulationMode: toolsEmulationMode, 192 + Temperature: temperature, 193 + ReasoningEffort: reasoningEffort, 194 + StateDir: strings.TrimSpace(values.FileStateDir), 195 + }), nil 171 196 case "openai", "openai_resp", "openai_custom", "deepseek", "xai", "gemini", "azure", "anthropic", "bedrock", "susanoo", "cloudflare": 172 197 c, err := uniaiProvider.New(uniaiProvider.Config{ 173 198 Provider: uniaiProviderName,
+270
providers/codex/client.go
··· 1 + package codex 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + "unicode/utf8" 9 + 10 + "github.com/lyricat/goutils/structs" 11 + "github.com/quailyquaily/mistermorph/internal/codexauth" 12 + "github.com/quailyquaily/mistermorph/llm" 13 + uniaiProvider "github.com/quailyquaily/mistermorph/providers/uniai" 14 + uniaiapi "github.com/quailyquaily/uniai" 15 + ) 16 + 17 + type Config struct { 18 + Endpoint string 19 + Model string 20 + Headers map[string]string 21 + Pricing *uniaiapi.PricingCatalog 22 + 23 + RequestTimeout time.Duration 24 + Temperature *float64 25 + ReasoningEffort string 26 + ToolsEmulationMode string 27 + StateDir string 28 + OAuth codexauth.OAuthConfig 29 + } 30 + 31 + type Client struct { 32 + cfg Config 33 + } 34 + 35 + const codexInstructionsMaxBytes = 30 * 1024 36 + 37 + func New(cfg Config) *Client { 38 + cfg.Endpoint = strings.TrimRight(strings.TrimSpace(cfg.Endpoint), "/") 39 + if cfg.Endpoint == "" { 40 + cfg.Endpoint = codexauth.DefaultAPIBase 41 + } 42 + cfg.Model = strings.TrimSpace(cfg.Model) 43 + if cfg.Model == "" { 44 + cfg.Model = codexauth.DefaultModel 45 + } 46 + cfg.Headers = sanitizeHeaders(cfg.Headers) 47 + cfg.ReasoningEffort = strings.TrimSpace(cfg.ReasoningEffort) 48 + cfg.ToolsEmulationMode = strings.TrimSpace(cfg.ToolsEmulationMode) 49 + return &Client{cfg: cfg} 50 + } 51 + 52 + func (c *Client) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { 53 + if c == nil { 54 + return llm.Result{}, fmt.Errorf("codex provider is nil") 55 + } 56 + token, err := codexauth.ResolveToken(ctx, c.cfg.StateDir, c.cfg.OAuth) 57 + if err != nil { 58 + return llm.Result{}, err 59 + } 60 + req, err = prepareCodexRequest(req) 61 + if err != nil { 62 + return llm.Result{}, err 63 + } 64 + if strings.TrimSpace(req.Model) == "" { 65 + req.Model = c.cfg.Model 66 + } 67 + if req.OnStream == nil { 68 + req.OnStream = func(llm.StreamEvent) error { return nil } 69 + } 70 + 71 + headers := sanitizeHeaders(c.cfg.Headers) 72 + if accountID := strings.TrimSpace(token.AccountID); accountID != "" { 73 + headers["ChatGPT-Account-ID"] = accountID 74 + } 75 + 76 + base, err := uniaiProvider.New(uniaiProvider.Config{ 77 + Provider: "openai_resp", 78 + Endpoint: c.cfg.Endpoint, 79 + APIKey: token.AccessToken, 80 + Model: c.cfg.Model, 81 + Headers: headers, 82 + Pricing: c.cfg.Pricing, 83 + RequestTimeout: c.cfg.RequestTimeout, 84 + CacheTTL: "off", 85 + ToolsEmulationMode: c.cfg.ToolsEmulationMode, 86 + Temperature: c.cfg.Temperature, 87 + ReasoningEffort: c.cfg.ReasoningEffort, 88 + }) 89 + if err != nil { 90 + return llm.Result{}, err 91 + } 92 + result, err := base.Chat(ctx, req) 93 + if err != nil { 94 + return llm.Result{}, err 95 + } 96 + return result, nil 97 + } 98 + 99 + func prepareCodexRequest(req llm.Request) (llm.Request, error) { 100 + instructions, messages := splitInstructions(req.Messages) 101 + if strings.TrimSpace(instructions) == "" { 102 + return llm.Request{}, fmt.Errorf("openai_codex requires at least one system or developer message") 103 + } 104 + instructions, overflow := splitInstructionLimit(instructions, codexInstructionsMaxBytes) 105 + if overflow != "" { 106 + messages = append([]llm.Message{{ 107 + Role: "system", 108 + Content: "Additional system and developer instructions:\n\n" + overflow, 109 + }}, messages...) 110 + } 111 + req.Messages = messages 112 + params := cloneAnyMap(req.Parameters) 113 + delete(params, "max_tokens") 114 + openAIOptions := cloneOpenAIOptions(params["openai"]) 115 + delete(openAIOptions, "max_tokens") 116 + delete(openAIOptions, "max_output_tokens") 117 + openAIOptions["instructions"] = instructions 118 + openAIOptions["store"] = false 119 + if strings.TrimSpace(openAIOptions.GetString("prompt_cache_key")) == "" { 120 + openAIOptions["prompt_cache_key"] = "mistermorph" 121 + } 122 + if req.ForceJSON && strings.TrimSpace(openAIOptions.GetString("response_format")) == "" { 123 + openAIOptions["response_format"] = "json_object" 124 + } 125 + if usesJSONResponseFormat(openAIOptions["response_format"]) { 126 + req.Messages = ensureInputMentionsJSON(req.Messages) 127 + } 128 + params["openai"] = openAIOptions 129 + req.Parameters = params 130 + return req, nil 131 + } 132 + 133 + func usesJSONResponseFormat(value any) bool { 134 + switch v := value.(type) { 135 + case string: 136 + return responseFormatTypeRequiresJSON(v) 137 + case structs.JSONMap: 138 + return responseFormatMapRequiresJSON(v) 139 + case map[string]any: 140 + return responseFormatMapRequiresJSON(v) 141 + default: 142 + return false 143 + } 144 + } 145 + 146 + func responseFormatMapRequiresJSON(value map[string]any) bool { 147 + rawType, ok := value["type"] 148 + if !ok { 149 + return false 150 + } 151 + typeName, ok := rawType.(string) 152 + return ok && responseFormatTypeRequiresJSON(typeName) 153 + } 154 + 155 + func responseFormatTypeRequiresJSON(typeName string) bool { 156 + return strings.Contains(strings.ToLower(strings.TrimSpace(typeName)), "json") 157 + } 158 + 159 + func ensureInputMentionsJSON(messages []llm.Message) []llm.Message { 160 + if inputMentionsJSON(messages) { 161 + return messages 162 + } 163 + out := make([]llm.Message, 0, len(messages)+1) 164 + out = append(out, llm.Message{ 165 + Role: "user", 166 + Content: "JSON response format reminder: return a JSON object as instructed.", 167 + }) 168 + out = append(out, messages...) 169 + return out 170 + } 171 + 172 + func inputMentionsJSON(messages []llm.Message) bool { 173 + for _, msg := range messages { 174 + if strings.Contains(strings.ToLower(messageText(msg)), "json") { 175 + return true 176 + } 177 + } 178 + return false 179 + } 180 + 181 + func splitInstructionLimit(text string, maxBytes int) (string, string) { 182 + text = strings.TrimSpace(text) 183 + if maxBytes <= 0 || len(text) <= maxBytes { 184 + return text, "" 185 + } 186 + cut := maxBytes 187 + for cut > 0 && !utf8.RuneStart(text[cut]) { 188 + cut-- 189 + } 190 + if cut <= 0 { 191 + return text, "" 192 + } 193 + return strings.TrimSpace(text[:cut]), strings.TrimSpace(text[cut:]) 194 + } 195 + 196 + func splitInstructions(messages []llm.Message) (string, []llm.Message) { 197 + if len(messages) == 0 { 198 + return "", nil 199 + } 200 + instructions := make([]string, 0, 2) 201 + remaining := make([]llm.Message, 0, len(messages)) 202 + for _, msg := range messages { 203 + role := strings.ToLower(strings.TrimSpace(msg.Role)) 204 + if role != "system" && role != "developer" { 205 + remaining = append(remaining, msg) 206 + continue 207 + } 208 + text := strings.TrimSpace(messageText(msg)) 209 + if text != "" { 210 + instructions = append(instructions, text) 211 + } 212 + } 213 + return strings.Join(instructions, "\n\n"), remaining 214 + } 215 + 216 + func messageText(msg llm.Message) string { 217 + if strings.TrimSpace(msg.Content) != "" { 218 + return strings.TrimSpace(msg.Content) 219 + } 220 + if len(msg.Parts) == 0 { 221 + return "" 222 + } 223 + parts := make([]string, 0, len(msg.Parts)) 224 + for _, part := range msg.Parts { 225 + if strings.EqualFold(strings.TrimSpace(part.Type), llm.PartTypeText) && strings.TrimSpace(part.Text) != "" { 226 + parts = append(parts, strings.TrimSpace(part.Text)) 227 + } 228 + } 229 + return strings.Join(parts, "\n") 230 + } 231 + 232 + func cloneAnyMap(in map[string]any) map[string]any { 233 + out := map[string]any{} 234 + for key, value := range in { 235 + out[key] = value 236 + } 237 + return out 238 + } 239 + 240 + func cloneOpenAIOptions(raw any) structs.JSONMap { 241 + out := structs.JSONMap{} 242 + switch v := raw.(type) { 243 + case nil: 244 + return out 245 + case structs.JSONMap: 246 + for key, value := range v { 247 + out[key] = value 248 + } 249 + case map[string]any: 250 + for key, value := range v { 251 + out[key] = value 252 + } 253 + } 254 + return out 255 + } 256 + 257 + func sanitizeHeaders(headers map[string]string) map[string]string { 258 + if len(headers) == 0 { 259 + return map[string]string{} 260 + } 261 + out := make(map[string]string, len(headers)) 262 + for key, value := range headers { 263 + key = strings.TrimSpace(key) 264 + if key == "" || strings.EqualFold(key, "authorization") { 265 + continue 266 + } 267 + out[key] = value 268 + } 269 + return out 270 + }
+245
providers/codex/client_test.go
··· 1 + package codex 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/lyricat/goutils/structs" 12 + "github.com/quailyquaily/mistermorph/internal/codexauth" 13 + "github.com/quailyquaily/mistermorph/llm" 14 + ) 15 + 16 + func TestPrepareCodexRequestMovesSystemMessagesToInstructions(t *testing.T) { 17 + req := llm.Request{ 18 + Messages: []llm.Message{ 19 + {Role: "system", Content: "system prompt"}, 20 + {Role: "developer", Content: "developer prompt"}, 21 + {Role: "user", Content: "hello"}, 22 + }, 23 + Parameters: map[string]any{ 24 + "openai": structs.JSONMap{ 25 + "parallel_tool_calls": true, 26 + }, 27 + }, 28 + } 29 + 30 + got, err := prepareCodexRequest(req) 31 + if err != nil { 32 + t.Fatalf("prepareCodexRequest() error = %v", err) 33 + } 34 + if len(got.Messages) != 1 || got.Messages[0].Role != "user" { 35 + t.Fatalf("messages = %+v", got.Messages) 36 + } 37 + options, ok := got.Parameters["openai"].(structs.JSONMap) 38 + if !ok { 39 + t.Fatalf("openai options type = %T", got.Parameters["openai"]) 40 + } 41 + if options["instructions"] != "system prompt\n\ndeveloper prompt" { 42 + t.Fatalf("instructions = %q", options["instructions"]) 43 + } 44 + if options["store"] != false { 45 + t.Fatalf("store = %#v", options["store"]) 46 + } 47 + if options["prompt_cache_key"] != "mistermorph" { 48 + t.Fatalf("prompt_cache_key = %#v", options["prompt_cache_key"]) 49 + } 50 + if options["parallel_tool_calls"] != true { 51 + t.Fatalf("existing option lost: %#v", options["parallel_tool_calls"]) 52 + } 53 + } 54 + 55 + func TestPrepareCodexRequestForcesJSONFormat(t *testing.T) { 56 + got, err := prepareCodexRequest(llm.Request{ 57 + ForceJSON: true, 58 + Messages: []llm.Message{ 59 + {Role: "system", Content: "system prompt"}, 60 + {Role: "user", Content: "hello"}, 61 + }, 62 + Parameters: map[string]any{ 63 + "max_tokens": 1024, 64 + "openai": structs.JSONMap{ 65 + "max_output_tokens": 512, 66 + }, 67 + }, 68 + Tools: []llm.Tool{{ 69 + Name: "read_file", 70 + ParametersJSON: `{"type":"object","properties":{"path":{"type":"string"}}}`, 71 + }}, 72 + }) 73 + if err != nil { 74 + t.Fatalf("prepareCodexRequest() error = %v", err) 75 + } 76 + options, ok := got.Parameters["openai"].(structs.JSONMap) 77 + if !ok { 78 + t.Fatalf("openai options type = %T", got.Parameters["openai"]) 79 + } 80 + if options["response_format"] != "json_object" { 81 + t.Fatalf("response_format = %#v", options["response_format"]) 82 + } 83 + if _, ok := got.Parameters["max_tokens"]; ok { 84 + t.Fatalf("max_tokens should be removed for Codex: %#v", got.Parameters) 85 + } 86 + if _, ok := options["max_output_tokens"]; ok { 87 + t.Fatalf("max_output_tokens should be removed for Codex: %#v", options) 88 + } 89 + if len(got.Messages) != 2 { 90 + t.Fatalf("messages = %+v", got.Messages) 91 + } 92 + if !strings.Contains(strings.ToLower(got.Messages[0].Content), "json") { 93 + t.Fatalf("first input message should mention JSON: %+v", got.Messages[0]) 94 + } 95 + } 96 + 97 + func TestPrepareCodexRequestDoesNotDuplicateJSONReminder(t *testing.T) { 98 + got, err := prepareCodexRequest(llm.Request{ 99 + ForceJSON: true, 100 + Messages: []llm.Message{ 101 + {Role: "system", Content: "system prompt"}, 102 + {Role: "user", Content: "return json please"}, 103 + }, 104 + }) 105 + if err != nil { 106 + t.Fatalf("prepareCodexRequest() error = %v", err) 107 + } 108 + if len(got.Messages) != 1 { 109 + t.Fatalf("messages = %+v", got.Messages) 110 + } 111 + if got.Messages[0].Content != "return json please" { 112 + t.Fatalf("message = %+v", got.Messages[0]) 113 + } 114 + } 115 + 116 + func TestClientSendsBearerTokenAndCodexRequestShape(t *testing.T) { 117 + stateDir := t.TempDir() 118 + if err := codexauth.WriteToken(stateDir, codexauth.Token{ 119 + AccessToken: "access-token", 120 + AccountID: "acc_123", 121 + }); err != nil { 122 + t.Fatalf("WriteToken() error = %v", err) 123 + } 124 + 125 + var capturedAuth string 126 + var capturedAccount string 127 + var capturedBeta string 128 + var capturedBody string 129 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 + capturedAuth = r.Header.Get("Authorization") 131 + capturedAccount = r.Header.Get("ChatGPT-Account-ID") 132 + capturedBeta = r.Header.Get("OpenAI-Beta") 133 + body, err := io.ReadAll(r.Body) 134 + if err != nil { 135 + t.Fatalf("ReadAll() error = %v", err) 136 + } 137 + capturedBody = string(body) 138 + http.Error(w, `{"detail":"Bad Request"}`, http.StatusBadRequest) 139 + })) 140 + defer server.Close() 141 + 142 + client := New(Config{ 143 + Endpoint: server.URL + "/backend-api/codex", 144 + Model: "gpt-5.5", 145 + StateDir: stateDir, 146 + }) 147 + _, err := client.Chat(context.Background(), llm.Request{ 148 + ForceJSON: true, 149 + Messages: []llm.Message{ 150 + {Role: "system", Content: "system prompt"}, 151 + {Role: "user", Content: "hello"}, 152 + }, 153 + Parameters: map[string]any{ 154 + "max_tokens": 1024, 155 + "openai": structs.JSONMap{ 156 + "max_output_tokens": 512, 157 + }, 158 + }, 159 + Tools: []llm.Tool{{ 160 + Name: "read_file", 161 + ParametersJSON: `{"type":"object","properties":{"path":{"type":"string"}}}`, 162 + }}, 163 + }) 164 + if err == nil { 165 + t.Fatal("Chat() expected upstream error") 166 + } 167 + if capturedAuth != "Bearer access-token" { 168 + t.Fatalf("Authorization = %q", capturedAuth) 169 + } 170 + if capturedAccount != "acc_123" { 171 + t.Fatalf("ChatGPT-Account-ID = %q", capturedAccount) 172 + } 173 + if capturedBeta != "" { 174 + t.Fatalf("OpenAI-Beta should not be sent on HTTP responses request, got %q", capturedBeta) 175 + } 176 + for _, want := range []string{ 177 + `"instructions":"system prompt"`, 178 + `"store":false`, 179 + `"prompt_cache_key":"mistermorph"`, 180 + `"text":{"format":{"type":"json_object"}}`, 181 + `"text":"JSON response format reminder: return a JSON object as instructed."`, 182 + `"stream":true`, 183 + `"tools":[`, 184 + `"input":[`, 185 + } { 186 + if !strings.Contains(capturedBody, want) { 187 + t.Fatalf("request body missing %q: %s", want, capturedBody) 188 + } 189 + } 190 + if strings.Contains(capturedBody, "prompt_cache_retention") { 191 + t.Fatalf("request body should not include prompt_cache_retention: %s", capturedBody) 192 + } 193 + if strings.Contains(capturedBody, "max_output_tokens") || strings.Contains(capturedBody, "max_tokens") { 194 + t.Fatalf("request body should not include unsupported max token params: %s", capturedBody) 195 + } 196 + } 197 + 198 + func TestPrepareCodexRequestRequiresInstructions(t *testing.T) { 199 + _, err := prepareCodexRequest(llm.Request{ 200 + Messages: []llm.Message{{Role: "user", Content: "hello"}}, 201 + }) 202 + if err == nil || !strings.Contains(err.Error(), "requires") { 203 + t.Fatalf("error = %v", err) 204 + } 205 + } 206 + 207 + func TestPrepareCodexRequestCapsInstructions(t *testing.T) { 208 + longPrompt := strings.Repeat("a", codexInstructionsMaxBytes) + "尾部" 209 + got, err := prepareCodexRequest(llm.Request{ 210 + Messages: []llm.Message{ 211 + {Role: "system", Content: longPrompt}, 212 + {Role: "user", Content: "hello"}, 213 + }, 214 + }) 215 + if err != nil { 216 + t.Fatalf("prepareCodexRequest() error = %v", err) 217 + } 218 + options, ok := got.Parameters["openai"].(structs.JSONMap) 219 + if !ok { 220 + t.Fatalf("openai options type = %T", got.Parameters["openai"]) 221 + } 222 + instructions, _ := options["instructions"].(string) 223 + if len(instructions) > codexInstructionsMaxBytes { 224 + t.Fatalf("instructions length = %d, want <= %d", len(instructions), codexInstructionsMaxBytes) 225 + } 226 + if len(got.Messages) != 2 || got.Messages[0].Role != "system" { 227 + t.Fatalf("messages = %+v", got.Messages) 228 + } 229 + if !strings.Contains(got.Messages[0].Content, "尾部") { 230 + t.Fatalf("overflow message missing tail: %+v", got.Messages[0]) 231 + } 232 + } 233 + 234 + func TestSanitizeHeadersDropsAuthorization(t *testing.T) { 235 + got := sanitizeHeaders(map[string]string{ 236 + "Authorization": "Bearer bad", 237 + "X-Test": "ok", 238 + }) 239 + if _, ok := got["Authorization"]; ok { 240 + t.Fatalf("authorization header was not dropped: %#v", got) 241 + } 242 + if got["X-Test"] != "ok" { 243 + t.Fatalf("X-Test = %q", got["X-Test"]) 244 + } 245 + }
+17
providers/uniai/base_test.go
··· 1 + package uniai 2 + 3 + import "testing" 4 + 5 + func TestNormalizeOpenAIBaseKeepsCodexBackendPath(t *testing.T) { 6 + got := normalizeOpenAIBase("https://chatgpt.com/backend-api/codex/") 7 + if got != "https://chatgpt.com/backend-api/codex" { 8 + t.Fatalf("normalizeOpenAIBase() = %q", got) 9 + } 10 + } 11 + 12 + func TestNormalizeOpenAIBaseAddsV1ForOpenAICompatibleEndpoints(t *testing.T) { 13 + got := normalizeOpenAIBase("https://api.example.com") 14 + if got != "https://api.example.com/v1" { 15 + t.Fatalf("normalizeOpenAIBase() = %q", got) 16 + } 17 + }
+30
providers/uniai/client.go
··· 289 289 azureOptions["response_format"] = "json_object" 290 290 } 291 291 } 292 + if req.Parameters != nil { 293 + mergeProviderOptions(req.Parameters["openai"], openAIOptions) 294 + mergeProviderOptions(req.Parameters["azure"], azureOptions) 295 + } 292 296 if len(openAIOptions) > 0 { 293 297 opts = append(opts, uniaiapi.WithOpenAIOptions(openAIOptions)) 294 298 } ··· 325 329 } 326 330 327 331 return opts 332 + } 333 + 334 + func mergeProviderOptions(raw any, dst structs.JSONMap) { 335 + if dst == nil { 336 + return 337 + } 338 + switch values := raw.(type) { 339 + case nil: 340 + return 341 + case structs.JSONMap: 342 + for key, value := range values { 343 + if strings.TrimSpace(key) != "" { 344 + dst[key] = value 345 + } 346 + } 347 + case map[string]any: 348 + for key, value := range values { 349 + if strings.TrimSpace(key) != "" { 350 + dst[key] = value 351 + } 352 + } 353 + } 328 354 } 329 355 330 356 func supportsStreaming(provider string) bool { ··· 1314 1340 return "" 1315 1341 } 1316 1342 endpoint = strings.TrimRight(endpoint, "/") 1343 + if strings.Contains(endpoint, "/backend-api/codex") { 1344 + endpoint = strings.TrimSuffix(endpoint, "/v1") 1345 + return endpoint 1346 + } 1317 1347 if strings.HasSuffix(endpoint, "/v1") || strings.Contains(endpoint, "/v1/") { 1318 1348 return endpoint 1319 1349 }
+253
web/console/src/components/CodexAuthDialog.css
··· 1 + .codex-auth-dialog { 2 + --codex-auth-accent: var(--accent-1); 3 + width: min(560px, 100%); 4 + display: grid; 5 + gap: 12px; 6 + padding: 12px 0 0; 7 + } 8 + 9 + .codex-auth-intro, 10 + .codex-auth-hint p, 11 + .codex-auth-device-note { 12 + margin: 0; 13 + color: var(--text-2); 14 + font-size: 12px; 15 + line-height: 1.55; 16 + } 17 + 18 + .codex-auth-result { 19 + display: grid; 20 + gap: 8px; 21 + } 22 + 23 + .codex-auth-row { 24 + box-sizing: border-box; 25 + display: grid; 26 + min-height: 68px; 27 + border: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 28 + background: color-mix(in srgb, var(--surface-elevated, #fff) 92%, transparent); 29 + } 30 + 31 + .codex-auth-row.is-loading { 32 + opacity: 0.8; 33 + } 34 + 35 + .codex-auth-row.is-signed-in { 36 + border-color: color-mix(in srgb, var(--codex-auth-accent) 42%, var(--line-soft)); 37 + } 38 + 39 + .codex-auth-row.is-signed-out { 40 + border-color: color-mix(in srgb, var(--line-soft) 86%, transparent); 41 + } 42 + 43 + .codex-auth-row-summary { 44 + display: flex; 45 + justify-content: space-between; 46 + gap: 12px; 47 + padding: 12px; 48 + } 49 + 50 + .codex-auth-row-main { 51 + min-width: 0; 52 + display: grid; 53 + gap: 4px; 54 + align-content: start; 55 + } 56 + 57 + .codex-auth-row-title { 58 + margin: 0; 59 + font-size: 13px; 60 + line-height: 1.35; 61 + color: var(--text-0); 62 + } 63 + 64 + .codex-auth-row-detail, 65 + .codex-auth-row-status, 66 + .codex-auth-device-title, 67 + .codex-auth-device-link, 68 + .codex-auth-device-code span, 69 + .codex-auth-device-code strong { 70 + font-family: var(--font-mono); 71 + font-size: 12px; 72 + line-height: 1.5; 73 + } 74 + 75 + .codex-auth-row-detail { 76 + margin: 0; 77 + color: var(--text-2); 78 + overflow-wrap: anywhere; 79 + } 80 + 81 + .codex-auth-row-side { 82 + flex: 0 0 auto; 83 + min-width: 72px; 84 + display: inline-flex; 85 + justify-content: flex-end; 86 + align-items: start; 87 + padding-top: 1px; 88 + } 89 + 90 + .codex-auth-row-side .q-button { 91 + margin-top: -4px; 92 + } 93 + 94 + .codex-auth-row-status { 95 + font-weight: 600; 96 + color: var(--text-2); 97 + white-space: nowrap; 98 + } 99 + 100 + .codex-auth-row-status.is-signed-in { 101 + color: var(--codex-auth-accent); 102 + } 103 + 104 + .codex-auth-spinner { 105 + width: 14px; 106 + height: 14px; 107 + border-radius: 999px; 108 + border: 2px solid color-mix(in srgb, var(--codex-auth-accent) 18%, transparent); 109 + border-top-color: var(--codex-auth-accent); 110 + animation: codex-auth-spin 0.8s linear infinite; 111 + } 112 + 113 + .codex-auth-hint { 114 + display: grid; 115 + padding: 0 2px; 116 + } 117 + 118 + .codex-auth-device { 119 + display: grid; 120 + grid-template-columns: minmax(104px, auto) minmax(0, 1fr); 121 + gap: 12px; 122 + padding: 12px; 123 + border: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 124 + background: color-mix(in srgb, var(--q-bg-paper) 90%, transparent); 125 + } 126 + 127 + .codex-auth-device-code { 128 + display: grid; 129 + align-content: center; 130 + justify-items: start; 131 + gap: 3px; 132 + padding-right: 12px; 133 + border-right: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 134 + } 135 + 136 + .codex-auth-device-code span { 137 + color: var(--text-2); 138 + font-size: 10px; 139 + letter-spacing: 0.1em; 140 + text-transform: uppercase; 141 + } 142 + 143 + .codex-auth-device-code strong { 144 + color: var(--text-0); 145 + font-size: 19px; 146 + font-weight: 600; 147 + letter-spacing: 0.12em; 148 + } 149 + 150 + .codex-auth-device-code-value { 151 + display: inline-flex; 152 + align-items: center; 153 + gap: 6px; 154 + } 155 + 156 + .codex-auth-device-copy { 157 + flex: 0 0 auto; 158 + min-width: 28px; 159 + min-height: 28px; 160 + color: var(--codex-auth-accent); 161 + } 162 + 163 + .codex-auth-device-main { 164 + min-width: 0; 165 + display: grid; 166 + gap: 5px; 167 + } 168 + 169 + .codex-auth-device-title { 170 + margin: 0; 171 + color: var(--text-0); 172 + font-weight: 600; 173 + } 174 + 175 + .codex-auth-device-link { 176 + appearance: none; 177 + width: fit-content; 178 + max-width: 100%; 179 + padding: 0; 180 + border: 0; 181 + background: none; 182 + color: var(--codex-auth-accent); 183 + text-align: left; 184 + font-weight: 600; 185 + cursor: pointer; 186 + } 187 + 188 + .codex-auth-device-link:hover, 189 + .codex-auth-device-link:focus-visible { 190 + color: var(--text-0); 191 + } 192 + 193 + .codex-auth-device-link:focus-visible { 194 + outline: 1px solid color-mix(in srgb, var(--codex-auth-accent) 50%, transparent); 195 + outline-offset: 2px; 196 + } 197 + 198 + .codex-auth-actions { 199 + display: flex; 200 + flex-wrap: wrap; 201 + align-items: center; 202 + justify-content: space-between; 203 + gap: 8px; 204 + } 205 + 206 + .codex-auth-actions-left { 207 + min-width: 0; 208 + } 209 + 210 + .codex-auth-usage { 211 + margin-left: auto; 212 + } 213 + 214 + @keyframes codex-auth-spin { 215 + from { 216 + transform: translateY(2px) rotate(0deg); 217 + } 218 + 219 + to { 220 + transform: translateY(2px) rotate(360deg); 221 + } 222 + } 223 + 224 + @media (max-width: 760px) { 225 + .codex-auth-row-summary { 226 + flex-direction: column; 227 + align-items: stretch; 228 + } 229 + 230 + .codex-auth-row-side { 231 + justify-content: flex-start; 232 + } 233 + 234 + .codex-auth-device { 235 + grid-template-columns: 1fr; 236 + } 237 + 238 + .codex-auth-device-code { 239 + padding-right: 0; 240 + padding-bottom: 10px; 241 + border-right: 0; 242 + border-bottom: 1px solid color-mix(in srgb, var(--line-soft) 86%, transparent); 243 + } 244 + 245 + .codex-auth-actions { 246 + align-items: stretch; 247 + } 248 + 249 + .codex-auth-actions-left, 250 + .codex-auth-actions .q-button { 251 + width: 100%; 252 + } 253 + }
+233
web/console/src/components/CodexAuthDialog.js
··· 1 + import { computed } from "vue"; 2 + import { translate } from "../core/context"; 3 + import "./CodexAuthDialog.css"; 4 + 5 + const CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage"; 6 + 7 + const CodexAuthDialog = { 8 + props: { 9 + modelValue: Boolean, 10 + loading: Boolean, 11 + busy: Boolean, 12 + error: { 13 + type: String, 14 + default: "", 15 + }, 16 + status: { 17 + type: Object, 18 + default: () => ({}), 19 + }, 20 + summary: { 21 + type: String, 22 + default: "", 23 + }, 24 + loginSession: { 25 + type: String, 26 + default: "", 27 + }, 28 + verificationURL: { 29 + type: String, 30 + default: "", 31 + }, 32 + userCode: { 33 + type: String, 34 + default: "", 35 + }, 36 + loginExpiresLabel: { 37 + type: String, 38 + default: "", 39 + }, 40 + }, 41 + emits: ["update:modelValue", "logout"], 42 + setup(props, { emit }) { 43 + const t = translate; 44 + const loggedIn = computed(() => props.status?.logged_in === true); 45 + const accountID = computed(() => String(props.status?.account_id || "").trim()); 46 + const introText = computed(() => 47 + accountID.value ? t("settings_codex_auth_account_intro", { account: accountID.value }) : "", 48 + ); 49 + const statusClass = computed(() => { 50 + if (props.loading) { 51 + return "is-loading"; 52 + } 53 + return loggedIn.value ? "is-signed-in" : "is-signed-out"; 54 + }); 55 + 56 + function close() { 57 + emit("update:modelValue", false); 58 + } 59 + 60 + function openVerificationURL() { 61 + const url = String(props.verificationURL || "").trim(); 62 + if (url) { 63 + window.open(url, "_blank", "noopener,noreferrer"); 64 + } 65 + } 66 + 67 + function openCodexUsage() { 68 + window.open(CODEX_USAGE_URL, "_blank", "noopener,noreferrer"); 69 + } 70 + 71 + async function copyUserCode() { 72 + const text = String(props.userCode || "").trim(); 73 + if (!text) { 74 + return; 75 + } 76 + try { 77 + if (navigator?.clipboard?.writeText) { 78 + await navigator.clipboard.writeText(text); 79 + return; 80 + } 81 + } catch {} 82 + const textarea = document.createElement("textarea"); 83 + textarea.value = text; 84 + textarea.setAttribute("readonly", "true"); 85 + textarea.style.position = "fixed"; 86 + textarea.style.opacity = "0"; 87 + textarea.style.pointerEvents = "none"; 88 + document.body.appendChild(textarea); 89 + textarea.select(); 90 + try { 91 + document.execCommand("copy"); 92 + } finally { 93 + document.body.removeChild(textarea); 94 + } 95 + } 96 + 97 + return { 98 + t, 99 + loggedIn, 100 + introText, 101 + statusClass, 102 + close, 103 + openVerificationURL, 104 + openCodexUsage, 105 + copyUserCode, 106 + }; 107 + }, 108 + template: ` 109 + <QDialog 110 + :modelValue="modelValue" 111 + width="560px" 112 + @update:modelValue="$emit('update:modelValue', $event)" 113 + @close="close" 114 + > 115 + <template #header> 116 + <header class="app-dialog-header"> 117 + <div class="app-dialog-copy"> 118 + <h3 class="app-dialog-title">{{ t("settings_codex_auth_title") }}</h3> 119 + </div> 120 + <QButton 121 + type="button" 122 + class="icon border-radius-none app-dialog-close" 123 + :title="t('action_close')" 124 + :aria-label="t('action_close')" 125 + :disabled="busy" 126 + @click="close" 127 + > 128 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 129 + <path d="M4 4l8 8M12 4l-8 8" /> 130 + </svg> 131 + </QButton> 132 + </header> 133 + </template> 134 + 135 + <section class="codex-auth-dialog"> 136 + <p v-if="introText" class="codex-auth-intro">{{ introText }}</p> 137 + 138 + <QFence 139 + v-if="error" 140 + type="danger" 141 + icon="QIconCloseCircle" 142 + :text="error" 143 + /> 144 + 145 + <div class="codex-auth-result"> 146 + <article :class="['codex-auth-row', statusClass]"> 147 + <div class="codex-auth-row-summary"> 148 + <div class="codex-auth-row-main"> 149 + <p class="codex-auth-row-title">{{ t("settings_codex_auth_session") }}</p> 150 + <p class="codex-auth-row-detail"> 151 + {{ loggedIn ? t("settings_codex_auth_status_ready") : t("settings_codex_auth_status_needs_login") }} 152 + </p> 153 + </div> 154 + <div class="codex-auth-row-side"> 155 + <span v-if="loading || busy" class="codex-auth-spinner" aria-hidden="true"></span> 156 + <strong v-else :class="['codex-auth-row-status', statusClass]">{{ summary }}</strong> 157 + </div> 158 + </div> 159 + </article> 160 + </div> 161 + 162 + <QFence 163 + v-if="status?.file_mode_ok === false" 164 + type="danger" 165 + icon="QIconCloseCircle" 166 + :text="status?.file_mode_warning || ''" 167 + /> 168 + 169 + <div v-if="!loggedIn && !loginSession" class="codex-auth-hint"> 170 + <p>{{ t("settings_codex_auth_set_default_note") }}</p> 171 + </div> 172 + 173 + <div v-if="loginSession" class="codex-auth-device"> 174 + <div class="codex-auth-device-code"> 175 + <span>{{ t("settings_codex_auth_user_code") }}</span> 176 + <div class="codex-auth-device-code-value"> 177 + <strong>{{ userCode }}</strong> 178 + <QButton 179 + type="button" 180 + class="plain xs icon codex-auth-device-copy" 181 + :title="t('action_copy')" 182 + :aria-label="t('action_copy')" 183 + :disabled="!userCode" 184 + @click="copyUserCode" 185 + > 186 + <QIconCopy class="icon" /> 187 + </QButton> 188 + </div> 189 + </div> 190 + <div class="codex-auth-device-main"> 191 + <p class="codex-auth-device-title">{{ t("settings_codex_auth_login_pending") }}</p> 192 + <button 193 + type="button" 194 + class="codex-auth-device-link" 195 + :title="verificationURL" 196 + :aria-label="t('settings_codex_auth_open_verification')" 197 + @click="openVerificationURL" 198 + > 199 + {{ t("settings_codex_auth_open_verification") }} 200 + </button> 201 + <p class="codex-auth-device-note">{{ t("settings_codex_auth_login_expires", { time: loginExpiresLabel }) }}</p> 202 + </div> 203 + </div> 204 + 205 + <div class="codex-auth-actions"> 206 + <div class="codex-auth-actions-left"> 207 + <QButton 208 + v-if="loggedIn" 209 + class="plain xs" 210 + :loading="busy" 211 + :disabled="busy || loading" 212 + @click="$emit('logout')" 213 + > 214 + {{ t("action_logout") }} 215 + </QButton> 216 + </div> 217 + <QButton 218 + type="button" 219 + class="plain xs codex-auth-usage" 220 + :title="t('settings_codex_auth_usage')" 221 + :aria-label="t('settings_codex_auth_usage')" 222 + @click="openCodexUsage" 223 + > 224 + {{ t("settings_codex_auth_usage") }} 225 + <QIconArrowUpRight class="icon" /> 226 + </QButton> 227 + </div> 228 + </section> 229 + </QDialog> 230 + `, 231 + }; 232 + 233 + export default CodexAuthDialog;
+58 -13
web/console/src/components/LLMConfigForm.js
··· 14 14 resolveSetupAPIKeyHelp, 15 15 SETUP_PROVIDER_BEDROCK, 16 16 SETUP_PROVIDER_CLOUDFLARE, 17 + SETUP_PROVIDER_OPENAI_CODEX, 17 18 setupProviderSupportsModelLookup, 18 19 } from "../core/setup-contract"; 19 20 ··· 53 54 enableModelPicker: Boolean, 54 55 showTestAction: Boolean, 55 56 testActionDisabled: Boolean, 57 + showCodexAuthAction: Boolean, 58 + codexAuthState: { 59 + type: String, 60 + default: "signed-out", 61 + }, 62 + codexAuthTitle: { 63 + type: String, 64 + default: "", 65 + }, 56 66 }, 57 - emits: ["update-field", "open-api-base-picker", "open-model-picker", "open-test"], 67 + emits: ["update-field", "open-api-base-picker", "open-model-picker", "open-test", "open-codex-auth"], 58 68 setup(props, { emit }) { 59 69 const t = translate; 60 70 ··· 90 100 return normalizeSetupProviderChoice(props.defaultProvider, { allowEmpty: true }); 91 101 }); 92 102 const showCloudflareAccountField = computed(() => effectiveProviderChoice.value === SETUP_PROVIDER_CLOUDFLARE); 103 + const showCodexOAuthFields = computed(() => effectiveProviderChoice.value === SETUP_PROVIDER_OPENAI_CODEX); 93 104 const showBedrockFields = computed(() => effectiveProviderChoice.value === SETUP_PROVIDER_BEDROCK); 94 - const showEndpointField = computed(() => !showCloudflareAccountField.value && !showBedrockFields.value); 105 + const showEndpointField = computed( 106 + () => !showCloudflareAccountField.value && !showBedrockFields.value && !showCodexOAuthFields.value, 107 + ); 95 108 const credentialLabelKey = computed(() => 96 109 showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_label" : "settings_agent_api_key_label", 97 110 ); ··· 123 136 setupProviderSupportsModelLookup(effectiveProviderChoice.value) && 124 137 (props.enableAPIBasePicker || props.enableModelPicker), 125 138 ); 139 + const codexAuthNeedsLogin = computed(() => ["signed-out", "expired"].includes(String(props.codexAuthState || "").trim())); 140 + const codexAuthActionClass = computed(() => 141 + [ 142 + "outlined", 143 + codexAuthNeedsLogin.value ? "" : "icon", 144 + "settings-field-action", 145 + "settings-codex-auth-button", 146 + codexAuthNeedsLogin.value ? "is-login" : "", 147 + `is-${String(props.codexAuthState || "signed-out").trim() || "signed-out"}`, 148 + ] 149 + .filter(Boolean) 150 + .join(" "), 151 + ); 126 152 const modelLookupDisabled = computed( 127 153 () => 128 154 props.busy || ··· 212 238 providerItem, 213 239 effectiveProviderChoice, 214 240 showCloudflareAccountField, 241 + showCodexOAuthFields, 215 242 showBedrockFields, 216 243 showEndpointField, 217 244 credentialLabelKey, ··· 220 247 reasoningEffortItem, 221 248 toolsEmulationItem, 222 249 showOpenAICompatibleHelpers, 250 + codexAuthNeedsLogin, 251 + codexAuthActionClass, 223 252 modelLookupDisabled, 224 253 credentialHelp, 225 254 credentialHelpParts, ··· 236 265 }, 237 266 template: ` 238 267 <div class="settings-form-grid"> 239 - <label class="settings-field is-wide"> 268 + <div class="settings-field is-wide"> 240 269 <span class="settings-field-label">{{ t("settings_agent_provider_label") }}</span> 241 270 <div v-if="isFieldEnvManaged('provider')" class="settings-env-managed"> 242 271 <code class="settings-env-managed-env">{{ fieldManagedHeadline("provider") }}</code> 243 272 <p class="settings-env-managed-body">{{ t("settings_env_managed_body") }}</p> 244 273 </div> 245 - <QDropdownMenu 246 - v-else 247 - :key="String(config.provider || '') || 'provider'" 248 - :items="providerItems" 249 - :initialItem="providerItem" 250 - :placeholder="t(providerPlaceholderKey)" 251 - @change="onProviderChange" 252 - /> 253 - </label> 274 + <div v-else class="settings-field-control"> 275 + <QDropdownMenu 276 + :key="String(config.provider || '') || 'provider'" 277 + :items="providerItems" 278 + :initialItem="providerItem" 279 + :placeholder="t(providerPlaceholderKey)" 280 + @change="onProviderChange" 281 + /> 282 + <QButton 283 + v-if="showCodexAuthAction && showCodexOAuthFields" 284 + type="button" 285 + :class="codexAuthActionClass" 286 + :title="codexAuthTitle" 287 + :aria-label="codexAuthTitle" 288 + :disabled="busy" 289 + @click.prevent="$emit('open-codex-auth')" 290 + > 291 + <QIconRefresh v-if="codexAuthState === 'loading'" class="icon" /> 292 + <QIconCheckCircle v-else-if="codexAuthState === 'signed-in'" class="icon" /> 293 + <QIconRefresh v-else-if="codexAuthState === 'refreshable'" class="icon" /> 294 + <template v-else-if="codexAuthNeedsLogin">{{ t("settings_codex_auth_login_codex") }}</template> 295 + <QIconCloseCircle v-else class="icon" /> 296 + </QButton> 297 + </div> 298 + </div> 254 299 255 300 <label v-if="showEndpointField" class="settings-field is-wide"> 256 301 <span class="settings-field-label">{{ t("settings_agent_endpoint_label") }}</span> ··· 356 401 /> 357 402 </label> 358 403 359 - <label v-if="!showBedrockFields" class="settings-field is-wide"> 404 + <label v-if="!showBedrockFields && !showCodexOAuthFields" class="settings-field is-wide"> 360 405 <span class="settings-field-label">{{ t(credentialLabelKey) }}</span> 361 406 <div 362 407 v-if="showCloudflareAccountField ? isFieldEnvManaged('cloudflare_api_token') : isFieldEnvManaged('api_key')"
-19
web/console/src/components/RawJsonDialog.css
··· 2 2 background: var(--q-c-dark); 3 3 } 4 4 5 - .raw-json-head { 6 - display: flex; 7 - align-items: center; 8 - min-height: 44px; 9 - padding-left: 1rem; 10 - } 11 - 12 - .raw-json-title { 13 - margin: 0; 14 - font-family: var(--font-mono); 15 - font-size: 11px; 16 - line-height: 1; 17 - letter-spacing: 0.05em; 18 - text-transform: uppercase; 19 - color: var(--q-c-dark); 20 - font-variant-numeric: tabular-nums; 21 - font-feature-settings: "zero" 1; 22 - } 23 - 24 5 .raw-json-dialog { 25 6 max-height: min(72vh, 720px); 26 7 padding: 0;
+15 -2
web/console/src/components/RawJsonDialog.js
··· 64 64 @close="close" 65 65 > 66 66 <template #header> 67 - <header class="raw-json-head"> 68 - <h3 class="raw-json-title">{{ title || 'RAW JSON' }}</h3> 67 + <header class="app-dialog-header"> 68 + <div class="app-dialog-copy"> 69 + <h3 class="app-dialog-title">{{ title || 'RAW JSON' }}</h3> 70 + </div> 71 + <QButton 72 + type="button" 73 + class="icon border-radius-none app-dialog-close" 74 + :title="t('action_close')" 75 + :aria-label="t('action_close')" 76 + @click="close" 77 + > 78 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 79 + <path d="M4 4l8 8M12 4l-8 8" /> 80 + </svg> 81 + </QButton> 69 82 </header> 70 83 </template> 71 84
+4 -19
web/console/src/components/RawTextEditorDialog.css
··· 14 14 width: min(920px, 100%); 15 15 max-height: min(86vh, 820px); 16 16 display: grid; 17 - grid-template-rows: auto auto minmax(0, 1fr); 17 + grid-template-rows: auto auto minmax(0, 1fr) auto; 18 18 gap: 12px; 19 - padding: 14px; 19 + padding: 0 14px 14px; 20 20 overflow: hidden; 21 21 } 22 22 23 23 .raw-text-head { 24 - display: flex; 25 - align-items: start; 26 - justify-content: space-between; 27 - gap: 12px; 28 - } 29 - 30 - .raw-text-copy { 31 - min-width: 0; 32 - display: grid; 33 - gap: 6px; 34 - } 35 - 36 - .raw-text-title { 37 - margin: 0; 38 - font-size: 16px; 39 - line-height: 1.2; 40 - color: var(--text-0); 24 + margin: 0 -14px; 41 25 } 42 26 43 27 .raw-text-path { ··· 52 36 .raw-text-actions { 53 37 display: flex; 54 38 align-items: center; 39 + justify-content: flex-end; 55 40 gap: 8px; 56 41 } 57 42
+19 -8
web/console/src/components/RawTextEditorDialog.js
··· 57 57 template: ` 58 58 <div v-if="open" class="raw-text-overlay" @click.self="close"> 59 59 <section class="raw-text-dialog frame"> 60 - <header class="raw-text-head"> 61 - <div class="raw-text-copy"> 62 - <h3 class="raw-text-title">{{ resolvedTitle }}</h3> 63 - <code v-if="path" class="raw-text-path">{{ path }}</code> 60 + <header class="raw-text-head app-dialog-header"> 61 + <div class="app-dialog-copy"> 62 + <h3 class="app-dialog-title">{{ resolvedTitle }}</h3> 64 63 </div> 65 - <div class="raw-text-actions"> 66 - <QButton class="plain sm" :disabled="saving" @click="close">{{ t("action_close") }}</QButton> 67 - <QButton class="primary sm" :loading="saving" :disabled="loading" @click="save">{{ t("action_save") }}</QButton> 68 - </div> 64 + <QButton 65 + type="button" 66 + class="icon border-radius-none app-dialog-close" 67 + :title="t('action_close')" 68 + :aria-label="t('action_close')" 69 + :disabled="saving" 70 + @click="close" 71 + > 72 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 73 + <path d="M4 4l8 8M12 4l-8 8" /> 74 + </svg> 75 + </QButton> 69 76 </header> 77 + <code v-if="path" class="raw-text-path">{{ path }}</code> 70 78 <QProgress v-if="loading" :infinite="true" /> 71 79 <QTextarea 72 80 v-else ··· 76 84 :disabled="saving" 77 85 @update:modelValue="onInput" 78 86 /> 87 + <div class="raw-text-actions"> 88 + <QButton class="primary sm" :loading="saving" :disabled="loading" @click="save">{{ t("action_save") }}</QButton> 89 + </div> 79 90 </section> 80 91 </div> 81 92 `,
+2 -14
web/console/src/components/SetupConnectionTestDialog.css
··· 3 3 width: min(560px, 100%); 4 4 display: grid; 5 5 gap: 12px; 6 - padding: 16px 0 0; 7 - } 8 - 9 - .connection-test-head { 10 - display: grid; 11 - gap: 6px; 6 + padding: 12px 0 0; 12 7 } 13 8 14 - .connection-test-copy { 9 + .connection-test-context { 15 10 display: grid; 16 11 gap: 6px; 17 - } 18 - 19 - .connection-test-title { 20 - margin: 0; 21 - font-size: 16px; 22 - line-height: 1.2; 23 - color: var(--text-0); 24 12 } 25 13 26 14 .connection-test-intro {
+30 -14
web/console/src/components/SetupConnectionTestDialog.js
··· 188 188 @update:modelValue="$emit('update:modelValue', $event)" 189 189 @close="close" 190 190 > 191 + <template #header> 192 + <header class="app-dialog-header"> 193 + <div class="app-dialog-copy"> 194 + <h3 class="app-dialog-title">{{ t("setup_llm_test_title") }}</h3> 195 + </div> 196 + <QButton 197 + type="button" 198 + class="icon border-radius-none app-dialog-close" 199 + :title="t('action_close')" 200 + :aria-label="t('action_close')" 201 + @click="close" 202 + > 203 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 204 + <path d="M4 4l8 8M12 4l-8 8" /> 205 + </svg> 206 + </QButton> 207 + </header> 208 + </template> 209 + 191 210 <section class="connection-test-dialog"> 192 - <header class="connection-test-head"> 193 - <div class="connection-test-copy"> 194 - <h3 class="connection-test-title">{{ t("setup_llm_test_title") }}</h3> 195 - <div v-if="showTarget" class="connection-test-intro"> 196 - <div v-if="targetHost" class="connection-test-target-row"> 197 - <QIconCompass class="connection-test-target-icon icon" /> 198 - <span class="connection-test-target-text">{{ targetHost }}</span> 199 - </div> 200 - <div v-if="targetModel" class="connection-test-target-row"> 201 - <QIconCpuChip class="connection-test-target-icon icon" /> 202 - <span class="connection-test-target-text">{{ targetModel }}</span> 203 - </div> 211 + <div v-if="showTarget || showIntro" class="connection-test-context"> 212 + <div v-if="showTarget" class="connection-test-intro"> 213 + <div v-if="targetHost" class="connection-test-target-row"> 214 + <QIconCompass class="connection-test-target-icon icon" /> 215 + <span class="connection-test-target-text">{{ targetHost }}</span> 204 216 </div> 205 - <p v-else-if="showIntro" class="connection-test-intro">{{ t("setup_llm_test_intro") }}</p> 217 + <div v-if="targetModel" class="connection-test-target-row"> 218 + <QIconCpuChip class="connection-test-target-icon icon" /> 219 + <span class="connection-test-target-text">{{ targetModel }}</span> 220 + </div> 206 221 </div> 207 - </header> 222 + <p v-else class="connection-test-intro">{{ t("setup_llm_test_intro") }}</p> 223 + </div> 208 224 209 225 <QFence 210 226 v-if="error"
+1 -1
web/console/src/components/SetupPickerDialog.css
··· 1 1 .setup-picker-dialog { 2 2 display: grid; 3 3 gap: 12px; 4 - padding-top: 16px; 4 + padding-top: 12px; 5 5 } 6 6 7 7 .setup-picker-filter {
+29
web/console/src/components/SetupPickerDialog.js
··· 1 1 import { computed, ref, watch } from "vue"; 2 + import { translate } from "../core/context"; 2 3 import "./SetupPickerDialog.css"; 3 4 4 5 const SetupPickerDialog = { ··· 13 14 type: String, 14 15 default: "", 15 16 }, 17 + title: { 18 + type: String, 19 + default: "", 20 + }, 16 21 filterPlaceholder: { 17 22 type: String, 18 23 default: "", ··· 28 33 }, 29 34 emits: ["update:modelValue", "select"], 30 35 setup(props, { emit }) { 36 + const t = translate; 31 37 const query = ref(""); 38 + const resolvedTitle = computed(() => String(props.title || "").trim()); 32 39 33 40 const filteredItems = computed(() => { 34 41 const needle = String(query.value || "").trim().toLowerCase(); ··· 63 70 ); 64 71 65 72 return { 73 + t, 66 74 query, 75 + resolvedTitle, 67 76 filteredItems, 68 77 close, 69 78 selectItem, ··· 76 85 @update:modelValue="$emit('update:modelValue', $event)" 77 86 @close="close" 78 87 > 88 + <template #header> 89 + <header class="app-dialog-header"> 90 + <div class="app-dialog-copy"> 91 + <h3 class="app-dialog-title">{{ resolvedTitle }}</h3> 92 + </div> 93 + <QButton 94 + type="button" 95 + class="icon border-radius-none app-dialog-close" 96 + :title="t('action_close')" 97 + :aria-label="t('action_close')" 98 + :disabled="loading" 99 + @click="close" 100 + > 101 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 102 + <path d="M4 4l8 8M12 4l-8 8" /> 103 + </svg> 104 + </QButton> 105 + </header> 106 + </template> 107 + 79 108 <section class="setup-picker-dialog"> 80 109 <QInput 81 110 v-model="query"
+13 -1
web/console/src/core/setup-contract.js
··· 11 11 const SETUP_PROVIDER_ANTHROPIC = "anthropic"; 12 12 const SETUP_PROVIDER_BEDROCK = "bedrock"; 13 13 const SETUP_PROVIDER_CLOUDFLARE = "cloudflare"; 14 + const SETUP_PROVIDER_OPENAI_CODEX = "openai_codex"; 14 15 15 16 const SETUP_PROVIDER_OPTIONS = [ 16 17 { title: "OpenAI Compatible", value: SETUP_PROVIDER_OPENAI_COMPATIBLE }, 18 + { title: "OpenAI Codex OAuth", value: SETUP_PROVIDER_OPENAI_CODEX }, 17 19 { title: "Gemini", value: SETUP_PROVIDER_GEMINI }, 18 20 { title: "Anthropic", value: SETUP_PROVIDER_ANTHROPIC }, 19 21 { title: "Bedrock", value: SETUP_PROVIDER_BEDROCK }, ··· 132 134 return SETUP_PROVIDER_BEDROCK; 133 135 case SETUP_PROVIDER_CLOUDFLARE: 134 136 return SETUP_PROVIDER_CLOUDFLARE; 137 + case SETUP_PROVIDER_OPENAI_CODEX: 138 + return SETUP_PROVIDER_OPENAI_CODEX; 135 139 default: 136 140 return SETUP_PROVIDER_OPENAI_COMPATIBLE; 137 141 } ··· 147 151 return ""; 148 152 case SETUP_PROVIDER_CLOUDFLARE: 149 153 return "https://api.cloudflare.com/client/v4"; 154 + case SETUP_PROVIDER_OPENAI_CODEX: 155 + return ""; 150 156 default: 151 157 return "https://api.openai.com"; 152 158 } ··· 163 169 return SETUP_PROVIDER_BEDROCK; 164 170 case SETUP_PROVIDER_CLOUDFLARE: 165 171 return SETUP_PROVIDER_CLOUDFLARE; 172 + case SETUP_PROVIDER_OPENAI_CODEX: 173 + return SETUP_PROVIDER_OPENAI_CODEX; 166 174 default: 167 175 return "openai"; 168 176 } ··· 174 182 175 183 function setupProviderRequiresAPIKey(choice) { 176 184 const provider = normalizeSetupProviderChoice(choice, { allowEmpty: true }); 177 - return provider !== SETUP_PROVIDER_CLOUDFLARE && provider !== SETUP_PROVIDER_BEDROCK; 185 + return ![SETUP_PROVIDER_CLOUDFLARE, SETUP_PROVIDER_BEDROCK, SETUP_PROVIDER_OPENAI_CODEX].includes(provider); 178 186 } 179 187 180 188 function findOpenAICompatibleAPIBaseOption(endpoint) { ··· 202 210 if (normalizedChoice === SETUP_PROVIDER_GEMINI || normalizedChoice === SETUP_PROVIDER_ANTHROPIC || normalizedChoice === SETUP_PROVIDER_CLOUDFLARE) { 203 211 return DIRECT_PROVIDER_API_KEY_HELP[normalizedChoice] || null; 204 212 } 213 + if (normalizedChoice === SETUP_PROVIDER_OPENAI_CODEX) { 214 + return null; 215 + } 205 216 const item = findOpenAICompatibleAPIBaseOption(endpoint); 206 217 if (item) { 207 218 return { title: item.title, url: item.dashboardURL }; ··· 227 238 SETUP_PROVIDER_BEDROCK, 228 239 SETUP_PROVIDER_CLOUDFLARE, 229 240 SETUP_PROVIDER_GEMINI, 241 + SETUP_PROVIDER_OPENAI_CODEX, 230 242 SETUP_PROVIDER_OPENAI_COMPATIBLE, 231 243 SETUP_PROVIDER_OPTIONS, 232 244 SETUP_REQUIRED_MARKDOWN_FILES,
+54
web/console/src/i18n/index.js
··· 486 486 settings_agent_cloudflare_account_placeholder: "your-account-id", 487 487 settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 488 488 settings_agent_cloudflare_api_token_placeholder: "Your Cloudflare API Token", 489 + settings_codex_auth_title: "OpenAI Codex OAuth", 490 + settings_codex_auth_account_intro: "Account ID: {account}", 491 + settings_codex_auth_loading: "Checking", 492 + settings_codex_auth_signed_out: "Signed out", 493 + settings_codex_auth_signed_in: "Signed in", 494 + settings_codex_auth_refreshable: "Refreshable", 495 + settings_codex_auth_expired: "Expired", 496 + settings_codex_auth_login_codex: "Login Codex", 497 + settings_codex_auth_session: "Authorization", 498 + settings_codex_auth_status_ready: "Codex OAuth is ready for LLM calls.", 499 + settings_codex_auth_status_needs_login: "Sign in to use openai_codex.", 500 + settings_codex_auth_set_default_note: "After login, the default LLM provider will be set to openai_codex and API key fields will be cleared.", 501 + settings_codex_auth_sign_in: "Sign in with OpenAI Codex", 502 + settings_codex_auth_login_pending: "Finish authorization in the browser with this code.", 503 + settings_codex_auth_login_expires: "Login code expires at {time}.", 504 + settings_codex_auth_open_verification: "Open verification page", 505 + settings_codex_auth_user_code: "Code", 506 + settings_codex_auth_usage: "View Codex Usage", 489 507 settings_agent_primary_title: "Default Route", 490 508 settings_agent_primary_note: "This is the primary LLM route used by the main console agent.", 491 509 settings_agent_profiles_title: "Profiles", ··· 1171 1189 settings_agent_cloudflare_account_placeholder: "你的 account_id", 1172 1190 settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 1173 1191 settings_agent_cloudflare_api_token_placeholder: "你的 Cloudflare API Token", 1192 + settings_codex_auth_title: "OpenAI Codex OAuth", 1193 + settings_codex_auth_account_intro: "Account ID:{account}", 1194 + settings_codex_auth_loading: "检查中", 1195 + settings_codex_auth_signed_out: "未登录", 1196 + settings_codex_auth_signed_in: "已登录", 1197 + settings_codex_auth_refreshable: "可刷新", 1198 + settings_codex_auth_expired: "已过期", 1199 + settings_codex_auth_login_codex: "登录 Codex", 1200 + settings_codex_auth_session: "授权状态", 1201 + settings_codex_auth_status_ready: "Codex OAuth 已可用于 LLM 调用。", 1202 + settings_codex_auth_status_needs_login: "登录后才能使用 openai_codex。", 1203 + settings_codex_auth_set_default_note: "登录完成后,默认 LLM provider 会设为 openai_codex,并清空 API key 相关字段。", 1204 + settings_codex_auth_sign_in: "登录 OpenAI Codex", 1205 + settings_codex_auth_login_pending: "在浏览器里输入下面的 code 完成授权。", 1206 + settings_codex_auth_login_expires: "登录 code 过期时间:{time}。", 1207 + settings_codex_auth_open_verification: "打开验证页面", 1208 + settings_codex_auth_user_code: "代码", 1209 + settings_codex_auth_usage: "查看 Codex Usage", 1174 1210 settings_agent_primary_title: "默认路由", 1175 1211 settings_agent_primary_note: "这是 Console 主 Agent 默认使用的 LLM 路由。", 1176 1212 settings_agent_profiles_title: "Profiles", ··· 1846 1882 settings_agent_cloudflare_account_placeholder: "account_id を入力", 1847 1883 settings_agent_cloudflare_api_token_label: "Cloudflare API Token", 1848 1884 settings_agent_cloudflare_api_token_placeholder: "Cloudflare API Token を入力", 1885 + settings_codex_auth_title: "OpenAI Codex OAuth", 1886 + settings_codex_auth_account_intro: "Account ID: {account}", 1887 + settings_codex_auth_loading: "確認中", 1888 + settings_codex_auth_signed_out: "未ログイン", 1889 + settings_codex_auth_signed_in: "ログイン済み", 1890 + settings_codex_auth_refreshable: "更新可能", 1891 + settings_codex_auth_expired: "期限切れ", 1892 + settings_codex_auth_login_codex: "Codex にログイン", 1893 + settings_codex_auth_session: "認証状態", 1894 + settings_codex_auth_status_ready: "Codex OAuth は LLM 呼び出しに使えます。", 1895 + settings_codex_auth_status_needs_login: "openai_codex を使うにはログインしてください。", 1896 + settings_codex_auth_set_default_note: "ログイン後、既定の LLM provider は openai_codex に設定され、API key 関連フィールドは空になります。", 1897 + settings_codex_auth_sign_in: "OpenAI Codex でログイン", 1898 + settings_codex_auth_login_pending: "ブラウザでこの code を入力して認可を完了してください。", 1899 + settings_codex_auth_login_expires: "ログイン code の期限: {time}", 1900 + settings_codex_auth_open_verification: "確認ページを開く", 1901 + settings_codex_auth_user_code: "コード", 1902 + settings_codex_auth_usage: "Codex Usage を表示", 1849 1903 settings_agent_primary_title: "デフォルトルート", 1850 1904 settings_agent_primary_note: "Console のメイン Agent が使う主 LLM ルートです。", 1851 1905 settings_agent_profiles_title: "Profiles",
+77
web/console/src/styles/base.css
··· 54 54 pre { 55 55 font-family: var(--font-mono); 56 56 } 57 + 58 + .q-dialog .q-dialog-header:has(.app-dialog-header) { 59 + display: block; 60 + min-height: 44px; 61 + max-height: none; 62 + padding: 0; 63 + } 64 + 65 + .app-dialog-header { 66 + display: grid; 67 + grid-template-columns: minmax(0, 1fr) 44px; 68 + align-items: center; 69 + min-height: 44px; 70 + height: 44px; 71 + } 72 + 73 + .app-dialog-copy { 74 + min-width: 0; 75 + height: 44px; 76 + display: flex; 77 + align-items: center; 78 + padding-left: 12px; 79 + } 80 + 81 + .app-dialog-title { 82 + margin: 0; 83 + overflow: hidden; 84 + font-family: var(--q-card-title-font-family); 85 + font-size: 15px; 86 + font-weight: 600; 87 + line-height: 1.2; 88 + letter-spacing: -0.02em; 89 + text-overflow: ellipsis; 90 + white-space: nowrap; 91 + color: var(--text-0); 92 + } 93 + 94 + .app-dialog-close, 95 + .q-button.touchable.border-radius-none.app-dialog-close { 96 + width: 44px; 97 + height: 44px; 98 + min-width: 44px; 99 + min-height: 44px; 100 + padding: 0 !important; 101 + border: 0 !important; 102 + background: color-mix(in srgb, var(--q-c-red) 10%, transparent) !important; 103 + border-radius: 0 !important; 104 + box-shadow: none !important; 105 + color: var(--q-c-red); 106 + corner-shape: initial !important; 107 + } 108 + 109 + .app-dialog-close:hover, 110 + .app-dialog-close:focus-visible, 111 + .q-button.touchable.border-radius-none.app-dialog-close:hover, 112 + .q-button.touchable.border-radius-none.app-dialog-close:focus-visible { 113 + background: color-mix(in srgb, var(--q-c-red) 16%, transparent) !important; 114 + color: var(--q-c-red); 115 + } 116 + 117 + .app-dialog-close::before, 118 + .q-button.touchable.border-radius-none.app-dialog-close::before { 119 + border-radius: 0 !important; 120 + corner-shape: initial !important; 121 + } 122 + 123 + .app-dialog-close .icon, 124 + .q-button.touchable.app-dialog-close .icon { 125 + width: 14px; 126 + min-width: 14px; 127 + height: 14px; 128 + color: var(--q-c-red) !important; 129 + fill: none; 130 + stroke: var(--q-c-red) !important; 131 + stroke-linecap: square; 132 + stroke-width: 1.8; 133 + }
-18
web/console/src/views/ChatView.css
··· 846 846 overflow: hidden; 847 847 } 848 848 849 - .chat-workspace-dialog-head { 850 - display: grid; 851 - align-content: center; 852 - gap: 4px; 853 - min-height: 44px; 854 - padding: 12px 16px; 855 - } 856 - 857 - .chat-workspace-dialog-title { 858 - margin: 0; 859 - font-size: 16px; 860 - line-height: 1.2; 861 - } 862 - 863 849 .chat-workspace-dialog-shell { 864 850 flex: 1 1 auto; 865 851 min-height: 0; ··· 943 929 .q-dialog .q-dialog-body:has(.chat-workspace-dialog) { 944 930 min-height: 0; 945 931 overflow: hidden; 946 - padding: 0; 947 - } 948 - 949 - .q-dialog .q-dialog-header:has(.chat-workspace-dialog-head) { 950 932 padding: 0; 951 933 } 952 934
+16 -2
web/console/src/views/ChatView.js
··· 3778 3778 @close="closeWorkspaceBrowser" 3779 3779 > 3780 3780 <template #header> 3781 - <header class="chat-workspace-dialog-head"> 3782 - <h3 class="chat-workspace-dialog-title">{{ t("chat_workspace_dialog_title") }}</h3> 3781 + <header class="app-dialog-header"> 3782 + <div class="app-dialog-copy"> 3783 + <h3 class="app-dialog-title">{{ t("chat_workspace_dialog_title") }}</h3> 3784 + </div> 3785 + <QButton 3786 + type="button" 3787 + class="icon border-radius-none app-dialog-close" 3788 + :title="t('action_close')" 3789 + :aria-label="t('action_close')" 3790 + :disabled="workspaceSaving" 3791 + @click="closeWorkspaceBrowser" 3792 + > 3793 + <svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"> 3794 + <path d="M4 4l8 8M12 4l-8 8" /> 3795 + </svg> 3796 + </QButton> 3783 3797 </header> 3784 3798 </template> 3785 3799
+40
web/console/src/views/SettingsView.css
··· 425 425 min-width: 148px; 426 426 } 427 427 428 + .settings-codex-auth-button { 429 + min-width: 38px; 430 + min-height: 38px; 431 + } 432 + 433 + .settings-codex-auth-button.is-login { 434 + min-width: auto; 435 + padding-inline: 12px !important; 436 + white-space: nowrap; 437 + } 438 + 439 + .settings-codex-auth-button.is-signed-in, 440 + .settings-codex-auth-button.is-refreshable, 441 + .settings-codex-auth-button.is-login { 442 + border-color: color-mix(in srgb, var(--accent-1) 44%, transparent); 443 + background: color-mix(in srgb, var(--accent-1) 8%, var(--q-bg-paper)); 444 + color: var(--accent-1); 445 + } 446 + 447 + .settings-codex-auth-button.is-expired:not(.is-login), 448 + .settings-codex-auth-button.is-signed-out:not(.is-login) { 449 + border-color: color-mix(in srgb, var(--danger) 34%, transparent); 450 + background: color-mix(in srgb, var(--danger) 6%, var(--q-bg-paper)); 451 + color: var(--danger); 452 + } 453 + 454 + .settings-codex-auth-button.is-loading .icon { 455 + animation: settings-codex-spin 0.8s linear infinite; 456 + } 457 + 458 + @keyframes settings-codex-spin { 459 + from { 460 + transform: rotate(0deg); 461 + } 462 + 463 + to { 464 + transform: rotate(360deg); 465 + } 466 + } 467 + 428 468 .settings-toggle-list, 429 469 .settings-console-list { 430 470 display: grid;
+327 -3
web/console/src/views/SettingsView.js
··· 4 4 5 5 import AppKicker from "../components/AppKicker"; 6 6 import AppPage from "../components/AppPage"; 7 + import CodexAuthDialog from "../components/CodexAuthDialog"; 7 8 import LLMConfigForm from "../components/LLMConfigForm"; 8 9 import SetupConnectionTestDialog from "../components/SetupConnectionTestDialog"; 9 10 import SetupPickerDialog from "../components/SetupPickerDialog"; ··· 12 13 applyLanguageChange, 13 14 clearAuth, 14 15 endpointState, 16 + formatTime, 15 17 loadEndpoints, 16 18 localeState, 17 19 runtimeEndpointByRef, ··· 30 32 normalizeSetupProviderForSave, 31 33 SETUP_PROVIDER_BEDROCK, 32 34 SETUP_PROVIDER_CLOUDFLARE, 35 + SETUP_PROVIDER_OPENAI_CODEX, 33 36 SETUP_PROVIDER_OPTIONS, 34 37 setupProviderRequiresAPIKey, 35 38 } from "../core/setup-contract"; ··· 302 305 components: { 303 306 AppKicker, 304 307 AppPage, 308 + CodexAuthDialog, 305 309 LLMConfigForm, 306 310 SetupConnectionTestDialog, 307 311 SetupPickerDialog, ··· 356 360 model: "", 357 361 }); 358 362 const testConnectionTargetProfileKey = ref(""); 363 + const codexAuthLoading = ref(false); 364 + const codexAuthBusy = ref(false); 365 + const codexAuthError = ref(""); 366 + const codexAuthDialogOpen = ref(false); 367 + const codexLoginSession = ref(""); 368 + const codexLoginVerificationURL = ref(""); 369 + const codexLoginUserCode = ref(""); 370 + const codexLoginExpiresAt = ref(""); 371 + let codexLoginPollTimer = 0; 372 + const codexAuthStatus = reactive({ 373 + logged_in: false, 374 + access_token_present: false, 375 + refresh_token_present: false, 376 + access_token_expired: false, 377 + expires_at: "", 378 + account_id: "", 379 + file_mode_ok: true, 380 + file_mode_warning: "", 381 + }); 359 382 360 383 const state = reactive({ 361 384 llm: { ··· 526 549 const defaultProviderChoice = computed(() => 527 550 normalizeSetupProviderChoice(profileBaseProvider.value, { allowEmpty: true }) 528 551 ); 552 + const defaultIsCodexProvider = computed(() => defaultProviderChoice.value === SETUP_PROVIDER_OPENAI_CODEX); 553 + const showCodexAuthCard = computed(() => { 554 + if (defaultIsCodexProvider.value) { 555 + return true; 556 + } 557 + return state.llm.profiles.some((profile) => effectiveProfileProviderChoice(profile) === SETUP_PROVIDER_OPENAI_CODEX); 558 + }); 559 + const codexAuthSummary = computed(() => { 560 + if (codexAuthLoading.value) { 561 + return t("settings_codex_auth_loading"); 562 + } 563 + if (!codexAuthStatus.logged_in) { 564 + return t("settings_codex_auth_signed_out"); 565 + } 566 + if (codexAuthStatus.access_token_expired && codexAuthStatus.refresh_token_present) { 567 + return t("settings_codex_auth_refreshable"); 568 + } 569 + if (codexAuthStatus.access_token_expired) { 570 + return t("settings_codex_auth_expired"); 571 + } 572 + return t("settings_codex_auth_signed_in"); 573 + }); 574 + const codexAuthButtonState = computed(() => { 575 + if (codexAuthLoading.value) { 576 + return "loading"; 577 + } 578 + if (!codexAuthStatus.logged_in) { 579 + return "signed-out"; 580 + } 581 + if (codexAuthStatus.access_token_expired && codexAuthStatus.refresh_token_present) { 582 + return "refreshable"; 583 + } 584 + if (codexAuthStatus.access_token_expired) { 585 + return "expired"; 586 + } 587 + return "signed-in"; 588 + }); 589 + const codexAuthNeedsLogin = computed(() => ["signed-out", "expired"].includes(codexAuthButtonState.value)); 590 + const codexAuthButtonTitle = computed(() => `${t("settings_codex_auth_title")}: ${codexAuthSummary.value}`); 591 + const codexLoginExpiresLabel = computed(() => 592 + codexLoginExpiresAt.value ? formatTime(codexLoginExpiresAt.value) : t("ttl_unknown") 593 + ); 529 594 const defaultShowCloudflareAccountField = computed(() => defaultProviderChoice.value === SETUP_PROVIDER_CLOUDFLARE); 530 595 const defaultShowBedrockFields = computed(() => defaultProviderChoice.value === SETUP_PROVIDER_BEDROCK); 531 596 const defaultCredentialFieldName = computed(() => ··· 600 665 agentSaving.value || 601 666 !hasLLMFieldValue(state.llm, llmEnvManaged.value, "provider") || 602 667 !hasLLMFieldValue(state.llm, llmEnvManaged.value, "model") || 668 + (defaultIsCodexProvider.value && !codexAuthStatus.logged_in) || 603 669 (setupProviderRequiresAPIKey(defaultProviderChoice.value) && 604 670 !hasLLMFieldValue(state.llm, llmEnvManaged.value, defaultCredentialFieldName.value)) || 605 671 (defaultShowBedrockFields.value && ··· 625 691 agentSaving.value || 626 692 !hasLLMFieldValue(state.llm, llmEnvManaged.value, "provider") || 627 693 !llmDirty.value || 694 + (defaultIsCodexProvider.value && !codexAuthStatus.logged_in) || 695 + (setupProviderRequiresAPIKey(defaultProviderChoice.value) && 696 + !hasLLMFieldValue(state.llm, llmEnvManaged.value, defaultCredentialFieldName.value)) || 697 + (defaultShowBedrockFields.value && 698 + !hasLLMFieldValue(state.llm, llmEnvManaged.value, "bedrock_aws_key")) || 699 + (defaultShowBedrockFields.value && 700 + !hasLLMFieldValue(state.llm, llmEnvManaged.value, "bedrock_aws_secret")) || 701 + (defaultShowBedrockFields.value && 702 + !hasLLMFieldValue(state.llm, llmEnvManaged.value, "bedrock_region")) || 628 703 (defaultShowCloudflareAccountField.value && 629 704 !hasLLMFieldValue(state.llm, llmEnvManaged.value, "cloudflare_api_token")) || 630 705 (defaultShowCloudflareAccountField.value && ··· 851 926 llmFieldEnvRawValue(envManaged, "provider") || 852 927 normalizeProviderForSave(profile.provider, profile.endpoint, true), 853 928 endpoint: 854 - effectiveProvider === SETUP_PROVIDER_BEDROCK 929 + effectiveProvider === SETUP_PROVIDER_OPENAI_CODEX || effectiveProvider === SETUP_PROVIDER_BEDROCK 855 930 ? "" 856 931 : llmFieldEnvRawValue(envManaged, "endpoint") || trimText(profile.endpoint), 857 932 model: llmFieldEnvRawValue(envManaged, "model") || trimText(profile.model), ··· 882 957 payload.api_key = ""; 883 958 payload.cloudflare_api_token = ""; 884 959 payload.cloudflare_account_id = ""; 960 + } else if (effectiveProvider === SETUP_PROVIDER_OPENAI_CODEX) { 961 + payload.api_key = ""; 962 + payload.cloudflare_api_token = ""; 963 + payload.cloudflare_account_id = ""; 964 + payload.bedrock_aws_key = ""; 965 + payload.bedrock_aws_secret = ""; 966 + payload.bedrock_region = ""; 967 + payload.bedrock_model_arn = ""; 885 968 } else { 886 969 payload.api_key = llmFieldEnvRawValue(envManaged, "api_key") || trimText(profile.api_key); 887 970 payload.bedrock_aws_key = ""; ··· 904 987 payload.provider = normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint); 905 988 } 906 989 const endpointRaw = llmFieldEnvRawValue(llmEnvManaged.value, "endpoint"); 907 - if (endpointRaw !== "") { 990 + if (provider === SETUP_PROVIDER_OPENAI_CODEX || provider === SETUP_PROVIDER_BEDROCK) { 991 + payload.endpoint = ""; 992 + } else if (endpointRaw !== "") { 908 993 payload.endpoint = endpointRaw; 909 994 } else if (!isLLMFieldEnvManaged(llmEnvManaged.value, "endpoint")) { 910 995 const endpoint = trimText(state.llm.endpoint); ··· 995 1080 payload.cloudflare_account_id = accountID; 996 1081 } 997 1082 } 1083 + } else if (provider === SETUP_PROVIDER_OPENAI_CODEX) { 1084 + payload.api_key = ""; 1085 + payload.cloudflare_api_token = ""; 1086 + payload.cloudflare_account_id = ""; 1087 + payload.bedrock_aws_key = ""; 1088 + payload.bedrock_aws_secret = ""; 1089 + payload.bedrock_region = ""; 1090 + payload.bedrock_model_arn = ""; 998 1091 } else { 999 1092 const apiKeyRaw = llmFieldEnvRawValue(llmEnvManaged.value, "api_key"); 1000 1093 if (apiKeyRaw !== "") { ··· 1017 1110 const data = await apiFetch("/settings/agent"); 1018 1111 llmConfigPath.value = typeof data.config_path === "string" ? data.config_path : ""; 1019 1112 applyPayload(data); 1113 + if (showCodexAuthCard.value) { 1114 + await loadCodexAuthStatus(); 1115 + } 1020 1116 } catch (e) { 1021 1117 agentErr.value = e.message || t("msg_load_failed"); 1022 1118 } finally { ··· 1024 1120 } 1025 1121 } 1026 1122 1123 + function applyCodexAuthStatus(payload) { 1124 + const status = payload && typeof payload.status === "object" ? payload.status : payload; 1125 + codexAuthStatus.logged_in = status?.logged_in === true; 1126 + codexAuthStatus.access_token_present = status?.access_token_present === true; 1127 + codexAuthStatus.refresh_token_present = status?.refresh_token_present === true; 1128 + codexAuthStatus.access_token_expired = status?.access_token_expired === true; 1129 + codexAuthStatus.expires_at = typeof status?.expires_at === "string" ? status.expires_at : ""; 1130 + codexAuthStatus.account_id = typeof status?.account_id === "string" ? status.account_id : ""; 1131 + codexAuthStatus.file_mode_ok = status?.file_mode_ok !== false; 1132 + codexAuthStatus.file_mode_warning = typeof status?.file_mode_warning === "string" ? status.file_mode_warning : ""; 1133 + } 1134 + 1135 + async function loadCodexAuthStatus() { 1136 + codexAuthLoading.value = true; 1137 + codexAuthError.value = ""; 1138 + try { 1139 + const payload = await apiFetch("/auth/codex/status"); 1140 + applyCodexAuthStatus(payload); 1141 + } catch (e) { 1142 + codexAuthError.value = e.message || t("msg_load_failed"); 1143 + } finally { 1144 + codexAuthLoading.value = false; 1145 + } 1146 + } 1147 + 1148 + function openCodexAuthDialog() { 1149 + const shouldStartLogin = codexAuthNeedsLogin.value && !codexLoginSession.value && !codexAuthBusy.value; 1150 + let authWindow = null; 1151 + if (shouldStartLogin) { 1152 + try { 1153 + // Open synchronously from the click event so popup blockers allow the auth tab. 1154 + authWindow = window.open("about:blank", "_blank"); 1155 + if (authWindow) { 1156 + authWindow.opener = null; 1157 + } 1158 + } catch {} 1159 + } 1160 + codexAuthDialogOpen.value = true; 1161 + void loadCodexAuthStatus(); 1162 + if (shouldStartLogin) { 1163 + void startCodexLogin(authWindow); 1164 + } 1165 + } 1166 + 1167 + function clearCodexLoginTimer() { 1168 + if (codexLoginPollTimer) { 1169 + clearTimeout(codexLoginPollTimer); 1170 + codexLoginPollTimer = 0; 1171 + } 1172 + } 1173 + 1174 + function resetCodexLoginSession() { 1175 + clearCodexLoginTimer(); 1176 + codexLoginSession.value = ""; 1177 + codexLoginVerificationURL.value = ""; 1178 + codexLoginUserCode.value = ""; 1179 + codexLoginExpiresAt.value = ""; 1180 + } 1181 + 1182 + function scheduleCodexLoginPoll(intervalSeconds = 5) { 1183 + clearCodexLoginTimer(); 1184 + const delay = Math.max(2, Number(intervalSeconds) || 5) * 1000; 1185 + codexLoginPollTimer = window.setTimeout(() => { 1186 + void pollCodexLogin(); 1187 + }, delay); 1188 + } 1189 + 1190 + async function startCodexLogin(authWindow = null) { 1191 + if (codexAuthBusy.value) { 1192 + if (authWindow && !authWindow.closed) { 1193 + authWindow.close(); 1194 + } 1195 + return; 1196 + } 1197 + codexAuthBusy.value = true; 1198 + codexAuthError.value = ""; 1199 + resetCodexLoginSession(); 1200 + let authWindowUsed = false; 1201 + try { 1202 + const payload = await apiFetch("/auth/codex/login/start", { method: "POST" }); 1203 + codexLoginSession.value = String(payload?.session_id || "").trim(); 1204 + codexLoginVerificationURL.value = String(payload?.verification_url || "").trim(); 1205 + codexLoginUserCode.value = String(payload?.user_code || "").trim(); 1206 + codexLoginExpiresAt.value = String(payload?.expires_at || "").trim(); 1207 + if (codexLoginVerificationURL.value) { 1208 + if (authWindow && !authWindow.closed) { 1209 + authWindow.location.href = codexLoginVerificationURL.value; 1210 + authWindowUsed = true; 1211 + } else { 1212 + window.open(codexLoginVerificationURL.value, "_blank", "noopener,noreferrer"); 1213 + } 1214 + } 1215 + scheduleCodexLoginPoll(payload?.interval_seconds); 1216 + } catch (e) { 1217 + codexAuthError.value = e.message || t("msg_load_failed"); 1218 + } finally { 1219 + if (!authWindowUsed && authWindow && !authWindow.closed) { 1220 + authWindow.close(); 1221 + } 1222 + codexAuthBusy.value = false; 1223 + } 1224 + } 1225 + 1226 + async function pollCodexLogin() { 1227 + const sessionID = codexLoginSession.value; 1228 + if (!sessionID || codexAuthBusy.value) { 1229 + return; 1230 + } 1231 + codexAuthBusy.value = true; 1232 + codexAuthError.value = ""; 1233 + try { 1234 + const payload = await apiFetch("/auth/codex/login/poll", { 1235 + method: "POST", 1236 + body: { session_id: sessionID, set_default: true }, 1237 + }); 1238 + if (payload?.pending === true) { 1239 + scheduleCodexLoginPoll(5); 1240 + return; 1241 + } 1242 + applyCodexAuthStatus(payload); 1243 + resetCodexLoginSession(); 1244 + if (payload?.settings_updated === true) { 1245 + await loadAgentSettings(); 1246 + } 1247 + } catch (e) { 1248 + codexAuthError.value = e.message || t("msg_load_failed"); 1249 + } finally { 1250 + codexAuthBusy.value = false; 1251 + } 1252 + } 1253 + 1254 + async function logoutCodexAuth() { 1255 + if (codexAuthBusy.value) { 1256 + return; 1257 + } 1258 + codexAuthBusy.value = true; 1259 + codexAuthError.value = ""; 1260 + try { 1261 + const payload = await apiFetch("/auth/codex/logout", { method: "POST" }); 1262 + applyCodexAuthStatus(payload); 1263 + resetCodexLoginSession(); 1264 + } catch (e) { 1265 + codexAuthError.value = e.message || t("msg_delete_failed"); 1266 + } finally { 1267 + codexAuthBusy.value = false; 1268 + } 1269 + } 1270 + 1027 1271 function applyConsolePayload(data) { 1028 1272 const values = Array.isArray(data?.managed_runtimes) ? data.managed_runtimes : []; 1029 1273 const telegram = data?.telegram && typeof data.telegram === "object" ? data.telegram : {}; ··· 1119 1363 payload.provider = normalizeSetupProviderForSave(state.llm.provider, state.llm.endpoint); 1120 1364 } 1121 1365 if (!isLLMFieldEnvManaged(llmEnvManaged.value, "endpoint")) { 1122 - payload.endpoint = provider === SETUP_PROVIDER_BEDROCK ? "" : trimText(state.llm.endpoint); 1366 + payload.endpoint = 1367 + provider === SETUP_PROVIDER_OPENAI_CODEX || provider === SETUP_PROVIDER_BEDROCK 1368 + ? "" 1369 + : trimText(state.llm.endpoint); 1123 1370 } 1124 1371 if (!isLLMFieldEnvManaged(llmEnvManaged.value, "model")) { 1125 1372 payload.model = trimText(state.llm.model); ··· 1144 1391 if (!isLLMFieldEnvManaged(llmEnvManaged.value, "cloudflare_account_id")) { 1145 1392 payload.cloudflare_account_id = trimText(state.llm.cloudflare_account_id); 1146 1393 } 1394 + } else if (provider === SETUP_PROVIDER_OPENAI_CODEX) { 1395 + payload.api_key = ""; 1396 + payload.cloudflare_api_token = ""; 1397 + payload.cloudflare_account_id = ""; 1398 + payload.bedrock_aws_key = ""; 1399 + payload.bedrock_aws_secret = ""; 1400 + payload.bedrock_region = ""; 1401 + payload.bedrock_model_arn = ""; 1147 1402 } else if (!isLLMFieldEnvManaged(llmEnvManaged.value, "api_key")) { 1148 1403 payload.api_key = trimText(state.llm.api_key); 1149 1404 } ··· 1171 1426 allowEmpty: true, 1172 1427 }); 1173 1428 return explicitProvider || defaultProviderChoice.value; 1429 + } 1430 + 1431 + function profileUsesCodexProvider(profile) { 1432 + return effectiveProfileProviderChoice(profile) === SETUP_PROVIDER_OPENAI_CODEX; 1174 1433 } 1175 1434 1176 1435 function effectiveProfileFieldValue(profile, field) { ··· 1209 1468 } 1210 1469 if (!hasEffectiveProfileFieldValue(profile, "model")) { 1211 1470 return true; 1471 + } 1472 + if (provider === SETUP_PROVIDER_OPENAI_CODEX) { 1473 + return !codexAuthStatus.logged_in; 1212 1474 } 1213 1475 if (provider === SETUP_PROVIDER_BEDROCK) { 1214 1476 return ( ··· 1655 1917 1656 1918 onUnmounted(() => { 1657 1919 window.removeEventListener("resize", refreshMobileMode); 1920 + clearCodexLoginTimer(); 1658 1921 }); 1659 1922 1660 1923 watch( ··· 1688 1951 deleteProfileTargetKey.value = ""; 1689 1952 } 1690 1953 }); 1954 + 1955 + watch(codexAuthDialogOpen, (open) => { 1956 + if (!open) { 1957 + resetCodexLoginSession(); 1958 + codexAuthError.value = ""; 1959 + } 1960 + }); 1961 + 1962 + watch( 1963 + showCodexAuthCard, 1964 + (visible) => { 1965 + if (visible) { 1966 + void loadCodexAuthStatus(); 1967 + } else { 1968 + resetCodexLoginSession(); 1969 + codexAuthError.value = ""; 1970 + } 1971 + }, 1972 + { immediate: false } 1973 + ); 1691 1974 1692 1975 return { 1693 1976 t, ··· 1745 2028 guardSaveDisabled, 1746 2029 testConnectionDisabled, 1747 2030 testConnectionDisabledForProfile, 2031 + showCodexAuthCard, 2032 + codexAuthLoading, 2033 + codexAuthBusy, 2034 + codexAuthError, 2035 + codexAuthDialogOpen, 2036 + codexAuthStatus, 2037 + codexAuthSummary, 2038 + codexAuthButtonState, 2039 + codexAuthButtonTitle, 2040 + codexLoginSession, 2041 + codexLoginVerificationURL, 2042 + codexLoginUserCode, 2043 + codexLoginExpiresLabel, 2044 + pollCodexLogin, 2045 + logoutCodexAuth, 2046 + loadCodexAuthStatus, 2047 + openCodexAuthDialog, 1748 2048 logout, 1749 2049 saveAgentSettings, 1750 2050 saveConsoleSettings, 1751 2051 updateDefaultLLMField, 1752 2052 updateProfileField, 1753 2053 llmProfileEnvManaged, 2054 + profileUsesCodexProvider, 1754 2055 addLLMProfile, 1755 2056 confirmRemoveLLMProfile, 1756 2057 removeLLMProfile, ··· 1904 2205 :enableModelPicker="true" 1905 2206 :showTestAction="true" 1906 2207 :testActionDisabled="testConnectionDisabled" 2208 + :showCodexAuthAction="true" 2209 + :codexAuthState="codexAuthButtonState" 2210 + :codexAuthTitle="codexAuthButtonTitle" 1907 2211 @update-field="updateDefaultLLMField" 1908 2212 @open-api-base-picker="openAPIBasePicker" 1909 2213 @open-model-picker="openModelPicker" 1910 2214 @open-test="openTestConnection" 2215 + @open-codex-auth="openCodexAuthDialog" 1911 2216 /> 1912 2217 </section> 1913 2218 ··· 1957 2262 :allowProviderInherit="true" 1958 2263 :showTestAction="true" 1959 2264 :testActionDisabled="testConnectionDisabledForProfile(profile)" 2265 + :showCodexAuthAction="profileUsesCodexProvider(profile)" 2266 + :codexAuthState="codexAuthButtonState" 2267 + :codexAuthTitle="codexAuthButtonTitle" 1960 2268 @update-field="updateProfileField(profile._key, $event)" 1961 2269 @open-test="openTestConnection(profile._key)" 2270 + @open-codex-auth="openCodexAuthDialog" 1962 2271 /> 1963 2272 </article> 1964 2273 ··· 2549 2858 :items="apiBasePickerItems" 2550 2859 :loading="false" 2551 2860 :error="''" 2861 + :title="t('setup_llm_api_base_picker_title')" 2552 2862 :filterPlaceholder="t('setup_llm_api_base_picker_filter_placeholder')" 2553 2863 :emptyText="t('setup_llm_api_base_picker_empty')" 2554 2864 @select="applyAPIBaseOption" ··· 2559 2869 :items="modelPickerItems" 2560 2870 :loading="modelPickerLoading" 2561 2871 :error="modelPickerError" 2872 + :title="t('setup_llm_model_picker_title')" 2562 2873 :filterPlaceholder="t('setup_llm_model_picker_filter_placeholder')" 2563 2874 :emptyText="t('setup_llm_model_picker_empty')" 2564 2875 :showValue="false" ··· 2575 2886 :model="testConnectionMeta.model" 2576 2887 :showIntro="false" 2577 2888 @retry="runConnectionTest" 2889 + /> 2890 + <CodexAuthDialog 2891 + v-model="codexAuthDialogOpen" 2892 + :loading="codexAuthLoading" 2893 + :busy="codexAuthBusy" 2894 + :error="codexAuthError" 2895 + :status="codexAuthStatus" 2896 + :summary="codexAuthSummary" 2897 + :loginSession="codexLoginSession" 2898 + :verificationURL="codexLoginVerificationURL" 2899 + :userCode="codexLoginUserCode" 2900 + :loginExpiresLabel="codexLoginExpiresLabel" 2901 + @logout="logoutCodexAuth" 2578 2902 /> 2579 2903 <QMessageDialog 2580 2904 v-model="deleteProfileDialogOpen"
+40
web/console/src/views/SetupView.css
··· 103 103 flex: 0 0 auto; 104 104 } 105 105 106 + .setup-codex-auth-button { 107 + min-width: 38px; 108 + min-height: 38px; 109 + } 110 + 111 + .setup-codex-auth-button.is-login { 112 + min-width: auto; 113 + padding-inline: 12px !important; 114 + white-space: nowrap; 115 + } 116 + 117 + .setup-codex-auth-button.is-signed-in, 118 + .setup-codex-auth-button.is-refreshable, 119 + .setup-codex-auth-button.is-login { 120 + border-color: color-mix(in srgb, var(--accent-1) 44%, transparent); 121 + background: color-mix(in srgb, var(--accent-1) 8%, var(--q-bg-paper)); 122 + color: var(--accent-1); 123 + } 124 + 125 + .setup-codex-auth-button.is-expired:not(.is-login), 126 + .setup-codex-auth-button.is-signed-out:not(.is-login) { 127 + border-color: color-mix(in srgb, var(--danger) 34%, transparent); 128 + background: color-mix(in srgb, var(--danger) 6%, var(--q-bg-paper)); 129 + color: var(--danger); 130 + } 131 + 132 + .setup-codex-auth-button.is-loading .icon { 133 + animation: setup-codex-spin 0.8s linear infinite; 134 + } 135 + 136 + @keyframes setup-codex-spin { 137 + from { 138 + transform: rotate(0deg); 139 + } 140 + 141 + to { 142 + transform: rotate(360deg); 143 + } 144 + } 145 + 106 146 .setup-field.is-wide { 107 147 grid-column: 1 / -1; 108 148 }
+370 -19
web/console/src/views/SetupView.js
··· 3 3 import "./SetupView.css"; 4 4 5 5 import MarkdownEditor from "../components/MarkdownEditor"; 6 + import CodexAuthDialog from "../components/CodexAuthDialog"; 6 7 import SetupConnectionTestDialog from "../components/SetupConnectionTestDialog"; 7 8 import SetupPickerDialog from "../components/SetupPickerDialog"; 8 9 import { 9 10 apiFetch, 11 + formatTime, 10 12 loadEndpoints, 11 13 runtimeApiFetchForEndpoint, 12 14 setSelectedEndpointRef, ··· 36 38 SETUP_PROVIDER_BEDROCK, 37 39 SETUP_PROVIDER_CLOUDFLARE, 38 40 SETUP_PROVIDER_OPENAI_COMPATIBLE, 41 + SETUP_PROVIDER_OPENAI_CODEX, 39 42 SETUP_PROVIDER_OPTIONS, 40 43 setupProviderRequiresAPIKey, 41 44 setupProviderSupportsModelLookup, ··· 307 310 const SetupView = { 308 311 components: { 309 312 MarkdownEditor, 313 + CodexAuthDialog, 310 314 SetupConnectionTestDialog, 311 315 SetupPickerDialog, 312 316 }, ··· 359 363 apiBase: "", 360 364 model: "", 361 365 }); 366 + const codexAuthLoading = ref(false); 367 + const codexAuthBusy = ref(false); 368 + const codexAuthError = ref(""); 369 + const codexAuthDialogOpen = ref(false); 370 + const codexLoginSession = ref(""); 371 + const codexLoginVerificationURL = ref(""); 372 + const codexLoginUserCode = ref(""); 373 + const codexLoginExpiresAt = ref(""); 374 + let codexLoginPollTimer = 0; 375 + const codexAuthStatus = reactive({ 376 + logged_in: false, 377 + access_token_present: false, 378 + refresh_token_present: false, 379 + access_token_expired: false, 380 + expires_at: "", 381 + account_id: "", 382 + file_mode_ok: true, 383 + file_mode_warning: "", 384 + }); 362 385 363 386 const routeStage = computed(() => normalizeStage(route.meta?.setupStage)); 364 387 const repairKey = computed(() => String(route.query?.repair || "").trim()); ··· 382 405 const providerItem = computed( 383 406 () => providerItems.value.find((item) => item.value === llmForm.provider) || null 384 407 ); 408 + const providerChoice = computed(() => normalizeSetupProviderChoice(llmFieldValue("provider"), { allowEmpty: true })); 385 409 const showCloudflareAccountField = computed( 386 - () => normalizeSetupProviderChoice(llmFieldValue("provider")) === SETUP_PROVIDER_CLOUDFLARE 410 + () => providerChoice.value === SETUP_PROVIDER_CLOUDFLARE 387 411 ); 412 + const showCodexOAuthFields = computed(() => providerChoice.value === SETUP_PROVIDER_OPENAI_CODEX); 388 413 const showBedrockFields = computed( 389 414 () => normalizeSetupProviderChoice(llmFieldValue("provider")) === SETUP_PROVIDER_BEDROCK 390 415 ); 391 - const showEndpointField = computed(() => !showCloudflareAccountField.value && !showBedrockFields.value); 416 + const showEndpointField = computed( 417 + () => !showCloudflareAccountField.value && !showBedrockFields.value && !showCodexOAuthFields.value 418 + ); 392 419 const credentialFieldName = computed(() => (showCloudflareAccountField.value ? "cloudflare_api_token" : "api_key")); 393 420 const credentialLabelKey = computed(() => 394 421 showCloudflareAccountField.value ? "settings_agent_cloudflare_api_token_label" : "settings_agent_api_key_label" ··· 402 429 const credentialHintPlainKey = computed(() => 403 430 showCloudflareAccountField.value ? "setup_llm_api_token_hint_plain" : "setup_llm_api_key_hint_plain" 404 431 ); 405 - const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(llmFieldValue("provider"))); 432 + const showOpenAICompatibleHelpers = computed(() => setupProviderSupportsModelLookup(providerChoice.value)); 433 + const codexAuthSummary = computed(() => { 434 + if (codexAuthLoading.value) { 435 + return t("settings_codex_auth_loading"); 436 + } 437 + if (!codexAuthStatus.logged_in) { 438 + return t("settings_codex_auth_signed_out"); 439 + } 440 + if (codexAuthStatus.access_token_expired && codexAuthStatus.refresh_token_present) { 441 + return t("settings_codex_auth_refreshable"); 442 + } 443 + if (codexAuthStatus.access_token_expired) { 444 + return t("settings_codex_auth_expired"); 445 + } 446 + return t("settings_codex_auth_signed_in"); 447 + }); 448 + const codexAuthButtonState = computed(() => { 449 + if (codexAuthLoading.value) { 450 + return "loading"; 451 + } 452 + if (!codexAuthStatus.logged_in) { 453 + return "signed-out"; 454 + } 455 + if (codexAuthStatus.access_token_expired && codexAuthStatus.refresh_token_present) { 456 + return "refreshable"; 457 + } 458 + if (codexAuthStatus.access_token_expired) { 459 + return "expired"; 460 + } 461 + return "signed-in"; 462 + }); 463 + const codexAuthNeedsLogin = computed(() => ["signed-out", "expired"].includes(codexAuthButtonState.value)); 464 + const codexAuthButtonTitle = computed(() => `${t("settings_codex_auth_title")}: ${codexAuthSummary.value}`); 465 + const codexAuthActionClass = computed(() => 466 + [ 467 + "outlined", 468 + codexAuthNeedsLogin.value ? "" : "icon", 469 + "setup-field-action", 470 + "setup-codex-auth-button", 471 + codexAuthNeedsLogin.value ? "is-login" : "", 472 + `is-${codexAuthButtonState.value}`, 473 + ] 474 + .filter(Boolean) 475 + .join(" ") 476 + ); 477 + const codexLoginExpiresLabel = computed(() => 478 + codexLoginExpiresAt.value ? formatTime(codexLoginExpiresAt.value) : t("ttl_unknown") 479 + ); 406 480 const modelLookupDisabled = computed( 407 481 () => 408 482 loading.value || ··· 460 534 saving.value || 461 535 !hasLLMFieldValue("provider") || 462 536 !hasLLMFieldValue("model") || 463 - (!showBedrockFields.value && !hasLLMFieldValue(credentialFieldName.value)) || 537 + (showCodexOAuthFields.value && !codexAuthStatus.logged_in) || 538 + (!showCodexOAuthFields.value && 539 + !showBedrockFields.value && 540 + setupProviderRequiresAPIKey(providerChoice.value) && 541 + !hasLLMFieldValue(credentialFieldName.value)) || 464 542 (showBedrockFields.value && !hasLLMFieldValue("bedrock_aws_key")) || 465 543 (showBedrockFields.value && !hasLLMFieldValue("bedrock_aws_secret")) || 466 544 (showBedrockFields.value && !hasLLMFieldValue("bedrock_region")) || ··· 473 551 testConnectionLoading.value || 474 552 !hasLLMFieldValue("provider") || 475 553 !hasLLMFieldValue("model") || 476 - (setupProviderRequiresAPIKey(llmFieldValue("provider")) && !hasLLMFieldValue(credentialFieldName.value)) || 554 + (showCodexOAuthFields.value && !codexAuthStatus.logged_in) || 555 + (!showCodexOAuthFields.value && 556 + setupProviderRequiresAPIKey(providerChoice.value) && 557 + !hasLLMFieldValue(credentialFieldName.value)) || 477 558 (showBedrockFields.value && !hasLLMFieldValue("bedrock_aws_key")) || 478 559 (showBedrockFields.value && !hasLLMFieldValue("bedrock_aws_secret")) || 479 560 (showBedrockFields.value && !hasLLMFieldValue("bedrock_region")) || ··· 684 765 } 685 766 } 686 767 768 + function applyCodexAuthStatus(payload) { 769 + const status = payload && typeof payload.status === "object" ? payload.status : payload; 770 + codexAuthStatus.logged_in = status?.logged_in === true; 771 + codexAuthStatus.access_token_present = status?.access_token_present === true; 772 + codexAuthStatus.refresh_token_present = status?.refresh_token_present === true; 773 + codexAuthStatus.access_token_expired = status?.access_token_expired === true; 774 + codexAuthStatus.expires_at = typeof status?.expires_at === "string" ? status.expires_at : ""; 775 + codexAuthStatus.account_id = typeof status?.account_id === "string" ? status.account_id : ""; 776 + codexAuthStatus.file_mode_ok = status?.file_mode_ok !== false; 777 + codexAuthStatus.file_mode_warning = typeof status?.file_mode_warning === "string" ? status.file_mode_warning : ""; 778 + } 779 + 780 + async function loadCodexAuthStatus() { 781 + codexAuthLoading.value = true; 782 + codexAuthError.value = ""; 783 + try { 784 + const payload = await apiFetch("/auth/codex/status"); 785 + applyCodexAuthStatus(payload); 786 + } catch (e) { 787 + codexAuthError.value = e.message || t("msg_load_failed"); 788 + } finally { 789 + codexAuthLoading.value = false; 790 + } 791 + } 792 + 793 + function openCodexAuthDialog() { 794 + const shouldStartLogin = codexAuthNeedsLogin.value && !codexLoginSession.value && !codexAuthBusy.value; 795 + let authWindow = null; 796 + if (shouldStartLogin) { 797 + try { 798 + // Open synchronously from the click event so popup blockers allow the auth tab. 799 + authWindow = window.open("about:blank", "_blank"); 800 + if (authWindow) { 801 + authWindow.opener = null; 802 + } 803 + } catch {} 804 + } 805 + codexAuthDialogOpen.value = true; 806 + void loadCodexAuthStatus(); 807 + if (shouldStartLogin) { 808 + void startCodexLogin(authWindow); 809 + } 810 + } 811 + 812 + function clearCodexLoginTimer() { 813 + if (codexLoginPollTimer) { 814 + clearTimeout(codexLoginPollTimer); 815 + codexLoginPollTimer = 0; 816 + } 817 + } 818 + 819 + function resetCodexLoginSession() { 820 + clearCodexLoginTimer(); 821 + codexLoginSession.value = ""; 822 + codexLoginVerificationURL.value = ""; 823 + codexLoginUserCode.value = ""; 824 + codexLoginExpiresAt.value = ""; 825 + } 826 + 827 + function scheduleCodexLoginPoll(intervalSeconds = 5) { 828 + clearCodexLoginTimer(); 829 + const delay = Math.max(2, Number(intervalSeconds) || 5) * 1000; 830 + codexLoginPollTimer = window.setTimeout(() => { 831 + void pollCodexLogin(); 832 + }, delay); 833 + } 834 + 835 + async function startCodexLogin(authWindow = null) { 836 + if (codexAuthBusy.value) { 837 + if (authWindow && !authWindow.closed) { 838 + authWindow.close(); 839 + } 840 + return; 841 + } 842 + codexAuthBusy.value = true; 843 + codexAuthError.value = ""; 844 + resetCodexLoginSession(); 845 + let authWindowUsed = false; 846 + try { 847 + const payload = await apiFetch("/auth/codex/login/start", { method: "POST" }); 848 + codexLoginSession.value = String(payload?.session_id || "").trim(); 849 + codexLoginVerificationURL.value = String(payload?.verification_url || "").trim(); 850 + codexLoginUserCode.value = String(payload?.user_code || "").trim(); 851 + codexLoginExpiresAt.value = String(payload?.expires_at || "").trim(); 852 + if (codexLoginVerificationURL.value) { 853 + if (authWindow && !authWindow.closed) { 854 + authWindow.location.href = codexLoginVerificationURL.value; 855 + authWindowUsed = true; 856 + } else { 857 + window.open(codexLoginVerificationURL.value, "_blank", "noopener,noreferrer"); 858 + } 859 + } 860 + scheduleCodexLoginPoll(payload?.interval_seconds); 861 + } catch (e) { 862 + codexAuthError.value = e.message || t("msg_load_failed"); 863 + } finally { 864 + if (!authWindowUsed && authWindow && !authWindow.closed) { 865 + authWindow.close(); 866 + } 867 + codexAuthBusy.value = false; 868 + } 869 + } 870 + 871 + async function pollCodexLogin() { 872 + const sessionID = codexLoginSession.value; 873 + if (!sessionID || codexAuthBusy.value) { 874 + return; 875 + } 876 + codexAuthBusy.value = true; 877 + codexAuthError.value = ""; 878 + try { 879 + const payload = await apiFetch("/auth/codex/login/poll", { 880 + method: "POST", 881 + body: { session_id: sessionID, set_default: true }, 882 + }); 883 + if (payload?.pending === true) { 884 + scheduleCodexLoginPoll(5); 885 + return; 886 + } 887 + applyCodexAuthStatus(payload); 888 + resetCodexLoginSession(); 889 + if (payload?.settings_updated === true) { 890 + await loadLLMForm(); 891 + } 892 + } catch (e) { 893 + codexAuthError.value = e.message || t("msg_load_failed"); 894 + } finally { 895 + codexAuthBusy.value = false; 896 + } 897 + } 898 + 899 + async function logoutCodexAuth() { 900 + if (codexAuthBusy.value) { 901 + return; 902 + } 903 + codexAuthBusy.value = true; 904 + codexAuthError.value = ""; 905 + try { 906 + const payload = await apiFetch("/auth/codex/logout", { method: "POST" }); 907 + applyCodexAuthStatus(payload); 908 + resetCodexLoginSession(); 909 + } catch (e) { 910 + codexAuthError.value = e.message || t("msg_delete_failed"); 911 + } finally { 912 + codexAuthBusy.value = false; 913 + } 914 + } 915 + 687 916 async function loadPersonaForm() { 688 917 loading.value = true; 689 918 err.value = ""; ··· 832 1061 payload.provider = normalizeSetupProviderForSave(llmForm.provider, llmForm.endpoint); 833 1062 } 834 1063 if (!isLLMFieldEnvManaged("endpoint")) { 835 - payload.endpoint = provider === SETUP_PROVIDER_BEDROCK ? "" : String(llmForm.endpoint || "").trim(); 1064 + payload.endpoint = 1065 + provider === SETUP_PROVIDER_OPENAI_CODEX || provider === SETUP_PROVIDER_BEDROCK 1066 + ? "" 1067 + : String(llmForm.endpoint || "").trim(); 836 1068 } 837 1069 if (!isLLMFieldEnvManaged("model")) { 838 1070 payload.model = String(llmForm.model || "").trim(); ··· 857 1089 if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 858 1090 payload.cloudflare_account_id = String(llmForm.cloudflare_account_id || "").trim(); 859 1091 } 1092 + } else if (provider === SETUP_PROVIDER_OPENAI_CODEX) { 1093 + if (!isLLMFieldEnvManaged("api_key")) { 1094 + payload.api_key = ""; 1095 + } 1096 + if (!isLLMFieldEnvManaged("cloudflare_api_token")) { 1097 + payload.cloudflare_api_token = ""; 1098 + } 1099 + if (!isLLMFieldEnvManaged("cloudflare_account_id")) { 1100 + payload.cloudflare_account_id = ""; 1101 + } 1102 + if (!isLLMFieldEnvManaged("bedrock_aws_key")) { 1103 + payload.bedrock_aws_key = ""; 1104 + } 1105 + if (!isLLMFieldEnvManaged("bedrock_aws_secret")) { 1106 + payload.bedrock_aws_secret = ""; 1107 + } 1108 + if (!isLLMFieldEnvManaged("bedrock_region")) { 1109 + payload.bedrock_region = ""; 1110 + } 1111 + if (!isLLMFieldEnvManaged("bedrock_model_arn")) { 1112 + payload.bedrock_model_arn = ""; 1113 + } 860 1114 } else if (!isLLMFieldEnvManaged("api_key")) { 861 1115 payload.api_key = String(llmForm.api_key || "").trim(); 862 1116 } ··· 871 1125 } 872 1126 if (!isLLMFieldEnvManaged("endpoint")) { 873 1127 const endpoint = String(llmForm.endpoint || "").trim(); 874 - if (endpoint !== "" && provider !== SETUP_PROVIDER_BEDROCK) { 1128 + if (provider === SETUP_PROVIDER_OPENAI_CODEX || provider === SETUP_PROVIDER_BEDROCK) { 1129 + payload.endpoint = ""; 1130 + } else if (endpoint !== "") { 875 1131 payload.endpoint = endpoint; 876 1132 } 877 1133 } ··· 919 1175 payload.cloudflare_account_id = accountID; 920 1176 } 921 1177 } 1178 + } else if (provider === SETUP_PROVIDER_OPENAI_CODEX) { 1179 + payload.api_key = ""; 1180 + payload.cloudflare_api_token = ""; 1181 + payload.cloudflare_account_id = ""; 1182 + payload.bedrock_aws_key = ""; 1183 + payload.bedrock_aws_secret = ""; 1184 + payload.bedrock_region = ""; 1185 + payload.bedrock_model_arn = ""; 922 1186 } else if (!isLLMFieldEnvManaged("api_key")) { 923 1187 const apiKey = String(llmForm.api_key || "").trim(); 924 1188 if (apiKey !== "") { ··· 1000 1264 if (currentEndpoint === "" || currentEndpoint === previousDefaultEndpoint) { 1001 1265 llmForm.endpoint = defaultEndpointForSetupProvider(nextProvider); 1002 1266 } 1267 + const normalizedProvider = normalizeSetupProviderChoice(nextProvider, { allowEmpty: true }); 1268 + if (normalizedProvider === SETUP_PROVIDER_OPENAI_CODEX) { 1269 + llmForm.endpoint = ""; 1270 + llmForm.api_key = ""; 1271 + llmForm.cloudflare_api_token = ""; 1272 + llmForm.cloudflare_account_id = ""; 1273 + llmForm.bedrock_aws_key = ""; 1274 + llmForm.bedrock_aws_secret = ""; 1275 + llmForm.bedrock_region = ""; 1276 + llmForm.bedrock_model_arn = ""; 1277 + } else if (normalizedProvider === SETUP_PROVIDER_BEDROCK) { 1278 + llmForm.api_key = ""; 1279 + llmForm.cloudflare_api_token = ""; 1280 + llmForm.cloudflare_account_id = ""; 1281 + } 1003 1282 } 1004 1283 1005 1284 function openExternal(url) { ··· 1194 1473 { immediate: true } 1195 1474 ); 1196 1475 1476 + watch(codexAuthDialogOpen, (open) => { 1477 + if (!open) { 1478 + resetCodexLoginSession(); 1479 + codexAuthError.value = ""; 1480 + } 1481 + }); 1482 + 1483 + watch( 1484 + showCodexOAuthFields, 1485 + (visible) => { 1486 + if (visible) { 1487 + void loadCodexAuthStatus(); 1488 + } else { 1489 + resetCodexLoginSession(); 1490 + codexAuthError.value = ""; 1491 + } 1492 + }, 1493 + { immediate: true } 1494 + ); 1495 + 1197 1496 onMounted(() => { 1198 1497 spriteTimer = window.setInterval(() => { 1199 1498 spriteTick.value = (spriteTick.value + 1) % 240; ··· 1204 1503 if (spriteTimer) { 1205 1504 window.clearInterval(spriteTimer); 1206 1505 } 1506 + clearCodexLoginTimer(); 1207 1507 }); 1208 1508 1209 1509 return { ··· 1237 1537 doneStatusItems, 1238 1538 providerItems, 1239 1539 providerItem, 1540 + providerChoice, 1240 1541 llmEnvManaged, 1241 1542 showCloudflareAccountField, 1543 + showCodexOAuthFields, 1242 1544 showBedrockFields, 1243 1545 showEndpointField, 1244 1546 showOpenAICompatibleHelpers, 1547 + codexAuthLoading, 1548 + codexAuthBusy, 1549 + codexAuthError, 1550 + codexAuthDialogOpen, 1551 + codexAuthStatus, 1552 + codexAuthSummary, 1553 + codexAuthButtonState, 1554 + codexAuthNeedsLogin, 1555 + codexAuthButtonTitle, 1556 + codexAuthActionClass, 1557 + codexLoginSession, 1558 + codexLoginVerificationURL, 1559 + codexLoginUserCode, 1560 + codexLoginExpiresLabel, 1245 1561 modelLookupDisabled, 1246 1562 apiBasePickerItems, 1247 1563 credentialLabelKey, ··· 1281 1597 applyModelOption, 1282 1598 openTestConnection, 1283 1599 runConnectionTest, 1600 + loadCodexAuthStatus, 1601 + openCodexAuthDialog, 1602 + pollCodexLogin, 1603 + logoutCodexAuth, 1284 1604 testConnectionOpen, 1285 1605 testConnectionLoading, 1286 1606 testConnectionError, ··· 1315 1635 class="setup-form setup-form-llm" 1316 1636 @submit.prevent="saveLLM" 1317 1637 > 1318 - <label class="setup-field is-wide"> 1638 + <div class="setup-field is-wide"> 1319 1639 <span class="setup-field-label">{{ t("settings_agent_provider_label") }}</span> 1320 1640 <div v-if="isLLMFieldEnvManaged('provider')" class="setup-env-managed"> 1321 1641 <code class="setup-env-managed-env">{{ llmFieldManagedHeadline("provider") }}</code> 1322 1642 <p class="setup-env-managed-body">{{ t("settings_env_managed_body") }}</p> 1323 1643 </div> 1324 - <QDropdownMenu 1325 - v-else 1326 - :key="llmForm.provider || 'provider'" 1327 - :items="providerItems" 1328 - :initialItem="providerItem" 1329 - :placeholder="t('settings_agent_provider_placeholder')" 1330 - :disabled="loading || saving" 1331 - @change="onProviderChange" 1332 - /> 1333 - </label> 1644 + <div v-else class="setup-field-control"> 1645 + <QDropdownMenu 1646 + :key="llmForm.provider || 'provider'" 1647 + :items="providerItems" 1648 + :initialItem="providerItem" 1649 + :placeholder="t('settings_agent_provider_placeholder')" 1650 + :disabled="loading || saving" 1651 + @change="onProviderChange" 1652 + /> 1653 + <QButton 1654 + v-if="showCodexOAuthFields" 1655 + type="button" 1656 + :class="codexAuthActionClass" 1657 + :title="codexAuthButtonTitle" 1658 + :aria-label="codexAuthButtonTitle" 1659 + :disabled="loading || saving" 1660 + @click.prevent="openCodexAuthDialog" 1661 + > 1662 + <QIconRefresh v-if="codexAuthButtonState === 'loading'" class="icon" /> 1663 + <QIconCheckCircle v-else-if="codexAuthButtonState === 'signed-in'" class="icon" /> 1664 + <QIconRefresh v-else-if="codexAuthButtonState === 'refreshable'" class="icon" /> 1665 + <template v-else-if="codexAuthNeedsLogin">{{ t("settings_codex_auth_login_codex") }}</template> 1666 + <QIconCloseCircle v-else class="icon" /> 1667 + </QButton> 1668 + </div> 1669 + </div> 1334 1670 1335 1671 <label v-if="showEndpointField" class="setup-field is-wide"> 1336 1672 <span class="setup-field-label">{{ t("settings_agent_endpoint_label") }}</span> ··· 1429 1765 /> 1430 1766 </label> 1431 1767 1432 - <label v-if="!showBedrockFields" class="setup-field is-wide"> 1768 + <label v-if="!showBedrockFields && !showCodexOAuthFields" class="setup-field is-wide"> 1433 1769 <span class="setup-field-label">{{ t(credentialLabelKey) }}</span> 1434 1770 <div v-if="showCloudflareAccountField ? isLLMFieldEnvManaged('cloudflare_api_token') : isLLMFieldEnvManaged('api_key')" class="setup-env-managed"> 1435 1771 <code class="setup-env-managed-env">{{ llmFieldManagedHeadline(showCloudflareAccountField ? "cloudflare_api_token" : "api_key") }}</code> ··· 1685 2021 :items="apiBasePickerItems" 1686 2022 :loading="false" 1687 2023 :error="''" 2024 + :title="t('setup_llm_api_base_picker_title')" 1688 2025 :filterPlaceholder="t('setup_llm_api_base_picker_filter_placeholder')" 1689 2026 :emptyText="t('setup_llm_api_base_picker_empty')" 1690 2027 @select="applyAPIBaseOption" ··· 1695 2032 :items="modelPickerItems" 1696 2033 :loading="modelPickerLoading" 1697 2034 :error="modelPickerError" 2035 + :title="t('setup_llm_model_picker_title')" 1698 2036 :filterPlaceholder="t('setup_llm_model_picker_filter_placeholder')" 1699 2037 :emptyText="t('setup_llm_model_picker_empty')" 1700 2038 :showValue="false" ··· 1711 2049 :model="testConnectionMeta.model" 1712 2050 :showIntro="false" 1713 2051 @retry="runConnectionTest" 2052 + /> 2053 + <CodexAuthDialog 2054 + v-model="codexAuthDialogOpen" 2055 + :loading="codexAuthLoading" 2056 + :busy="codexAuthBusy" 2057 + :error="codexAuthError" 2058 + :status="codexAuthStatus" 2059 + :summary="codexAuthSummary" 2060 + :loginSession="codexLoginSession" 2061 + :verificationURL="codexLoginVerificationURL" 2062 + :userCode="codexLoginUserCode" 2063 + :loginExpiresLabel="codexLoginExpiresLabel" 2064 + @logout="logoutCodexAuth" 1714 2065 /> 1715 2066 </QCard> 1716 2067 </section>