Merge branch 'main' into feat/youtube-visual-extraction

This commit is contained in:
Kayvan Sylvan 2026-04-05 09:17:24 -07:00
commit aeb709e7e8
58 changed files with 1181 additions and 841 deletions

5
.gitignore vendored
View file

@ -328,6 +328,11 @@ tmp/
# Claude MEMORY directory
MEMORY/
<<<<<<< HEAD
# Maestro artifacts
.maestro
=======
# Maestro directory
.maestro/
>>>>>>> main

View file

@ -1,5 +1,15 @@
# Changelog
## v1.4.442 (2026-03-25)
### PR [#2075](https://github.com/danielmiessler/Fabric/pull/2075) by [ksylvan](https://github.com/ksylvan) and [mikaelpr](https://github.com/mikaelpr): refactor: extract OAuth and auth logic from Codex client module
- Refactored the Codex client module by extracting all OAuth and authentication logic into a dedicated module, improving separation of concerns.
- Removed the OAuth flow, PKCE handling, and token refresh logic from `codex.go`, streamlining the client's core responsibilities.
- Removed the auth transport round-trip retry logic, simplifying the HTTP transport layer.
- Removed JWT parsing and token expiry utilities, along with unused OAuth types and helper structs, reducing dead code in the package.
- Added a test for `SendStream` HTTP error mapping and channel close behavior, improving test coverage for the client module.
## v1.4.441 (2026-03-22)
### PR [#2068](https://github.com/danielmiessler/Fabric/pull/2068) by [dependabot](https://github.com/apps/dependabot) and [ksylvan](https://github.com/ksylvan): chore(deps): bump google.golang.org/grpc from 1.79.0 to 1.79.3 in the go_modules group across 1 directory

View file

@ -18,10 +18,10 @@
# `fabric`
![Static Badge](https://img.shields.io/badge/mission-human_flourishing_via_AI_augmentation-purple)
[![Static Badge](https://img.shields.io/badge/mission-human_flourishing_via_AI_augmentation-purple)](https://github.com/danielmiessler/fabric)
<br />
![GitHub top language](https://img.shields.io/github/languages/top/danielmiessler/fabric)
![GitHub last commit](https://img.shields.io/github/last-commit/danielmiessler/fabric)
[![GitHub top language](https://img.shields.io/github/languages/top/danielmiessler/fabric)](https://github.com/danielmiessler/fabric)
[![GitHub last commit](https://img.shields.io/github/last-commit/danielmiessler/fabric)](https://github.com/danielmiessler/fabric/commits/main)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/danielmiessler/fabric)

View file

@ -1,3 +1,3 @@
package main
var version = "v1.4.441"
var version = "v1.4.442"

Binary file not shown.

View file

@ -1,6 +1,7 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
@ -88,7 +89,7 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
chatOptions.AudioFormat = "wav" // Default to WAV format
}
if session, err = chatter.Send(chatReq, chatOptions); err != nil {
if session, err = chatter.Send(context.Background(), chatReq, chatOptions); err != nil {
return
}

View file

@ -57,7 +57,7 @@ func joinPromptSections(parts ...string) string {
}
// Send processes a chat request and applies file changes for create_coding_feature pattern
func (o *Chatter) Send(request *domain.ChatRequest, opts *domain.ChatOptions) (session *fsdb.Session, err error) {
func (o *Chatter) Send(ctx context.Context, request *domain.ChatRequest, opts *domain.ChatOptions) (session *fsdb.Session, err error) {
// Use o.model (normalized) for NeedsRawMode check instead of opts.Model
// This ensures case-insensitive model names work correctly (e.g., "GPT-5" → "gpt-5")
if o.vendor.NeedsRawMode(o.model) {
@ -107,7 +107,7 @@ func (o *Chatter) Send(request *domain.ChatRequest, opts *domain.ChatOptions) (s
go func() {
defer close(done)
if streamErr := o.vendor.SendStream(session.GetVendorMessages(), opts, responseChan); streamErr != nil {
if streamErr := o.vendor.SendStream(ctx, session.GetVendorMessages(), opts, responseChan); streamErr != nil {
recordFirstStreamError(errChan, streamErr)
}
}()
@ -168,7 +168,7 @@ func (o *Chatter) Send(request *domain.ChatRequest, opts *domain.ChatOptions) (s
// No errors, continue
}
} else {
if message, err = o.vendor.Send(context.Background(), session.GetVendorMessages(), opts); err != nil {
if message, err = o.vendor.Send(ctx, session.GetVendorMessages(), opts); err != nil {
return
}
if debuglog.GetLevel() >= debuglog.Wire {

View file

@ -44,11 +44,11 @@ func (m *mockVendor) Setup() error {
func (m *mockVendor) SetupFillEnvFileContent(*bytes.Buffer) {
}
func (m *mockVendor) ListModels() ([]string, error) {
func (m *mockVendor) ListModels(context.Context) ([]string, error) {
return []string{"test-model"}, nil
}
func (m *mockVendor) SendStream(messages []*chat.ChatCompletionMessage, opts *domain.ChatOptions, responseChan chan domain.StreamUpdate) error {
func (m *mockVendor) SendStream(_ context.Context, messages []*chat.ChatCompletionMessage, opts *domain.ChatOptions, responseChan chan domain.StreamUpdate) error {
// Send chunks if provided (for successful streaming test)
if m.streamChunks != nil {
for _, chunk := range m.streamChunks {
@ -180,7 +180,7 @@ func TestChatter_Send_SuppressThink(t *testing.T) {
return "<think>hidden</think> visible", nil
}
session, err := chatter.Send(request, opts)
session, err := chatter.Send(context.Background(), request, opts)
if err != nil {
t.Fatalf("Send returned error: %v", err)
}
@ -296,7 +296,7 @@ func TestChatter_Send_StreamingErrorPropagation(t *testing.T) {
}
// Call Send and expect it to return the streaming error
session, err := chatter.Send(request, opts)
session, err := chatter.Send(context.Background(), request, opts)
// Verify that the error from SendStream is propagated
if err == nil {
@ -353,7 +353,7 @@ func TestChatter_Send_StreamingErrorUpdateAndReturnDoesNotDeadlock(t *testing.T)
done := make(chan sendResult, 1)
go func() {
session, err := chatter.Send(request, opts)
session, err := chatter.Send(context.Background(), request, opts)
done <- sendResult{session: session, err: err}
}()
@ -411,7 +411,7 @@ func TestChatter_Send_StreamingSuccessfulAggregation(t *testing.T) {
}
// Call Send and expect successful aggregation
session, err := chatter.Send(request, opts)
session, err := chatter.Send(context.Background(), request, opts)
// Verify no error occurred
if err != nil {
@ -494,7 +494,7 @@ func TestChatter_Send_StreamingMetadataPropagation(t *testing.T) {
}
// Call Send
_, err := chatter.Send(request, opts)
_, err := chatter.Send(context.Background(), request, opts)
if err != nil {
t.Fatalf("Expected no error, but got: %v", err)
}

View file

@ -36,14 +36,14 @@ type testVendor struct {
models []string
}
func (m *testVendor) GetName() string { return m.name }
func (m *testVendor) GetSetupDescription() string { return m.name }
func (m *testVendor) IsConfigured() bool { return true }
func (m *testVendor) Configure() error { return nil }
func (m *testVendor) Setup() error { return nil }
func (m *testVendor) SetupFillEnvFileContent(*bytes.Buffer) {}
func (m *testVendor) ListModels() ([]string, error) { return m.models, nil }
func (m *testVendor) SendStream([]*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error {
func (m *testVendor) GetName() string { return m.name }
func (m *testVendor) GetSetupDescription() string { return m.name }
func (m *testVendor) IsConfigured() bool { return true }
func (m *testVendor) Configure() error { return nil }
func (m *testVendor) Setup() error { return nil }
func (m *testVendor) SetupFillEnvFileContent(*bytes.Buffer) {}
func (m *testVendor) ListModels(context.Context) ([]string, error) { return m.models, nil }
func (m *testVendor) SendStream(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error {
return nil
}
func (m *testVendor) Send(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions) (string, error) {

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Wähle ein Muster aus den verfügbaren Mustern",
"choose_session_from_available": "Wähle eine Sitzung aus den verfügbaren Sitzungen",
"choose_strategy_from_available": "Strategie aus den verfügbaren Strategien wählen",
"codex_auth_base_url_invalid": "Ungültige Codex-Authentifizierungs-Basis-URL: %w",
"codex_browser_open_fallback": "Falls Ihr Browser sich nicht geöffnet hat, navigieren Sie zu dieser URL zur Authentifizierung:",
"codex_decode_models_response_failed": "Codex-Modell-Antwort konnte nicht dekodiert werden: %w",
"codex_decode_refresh_response_failed": "Aktualisierte Codex-Token-Antwort konnte nicht dekodiert werden: %w",
"codex_decode_token_response_failed": "Codex-Token-Austausch-Antwort konnte nicht dekodiert werden: %w",
"codex_image_file_not_supported": "Der Codex-Anbieter unterstützt --image-file nicht. Verwenden Sie stattdessen einen Bildanhang.",
"codex_login_account_changed": "Die Codex-Anmeldung ist mit einem anderen ChatGPT-Konto verknüpft als in der gespeicherten Konfiguration. Bitte führen Sie 'fabric --setup' erneut aus.",
"codex_login_completed": "Codex-Anmeldung abgeschlossen",
"codex_login_failed": "Codex-Anmeldung fehlgeschlagen: %s",
"codex_login_invalid": "Ihre Codex-Anmeldung ist nicht mehr gültig. Bitte führen Sie 'fabric --setup' erneut aus.",
"codex_login_missing_account_claim": "Die Codex-Anmeldung enthielt keine ChatGPT-Konto-ID. Dieser Anmeldezustand wird nicht unterstützt.",
"codex_login_missing_auth_code": "Die Codex-Anmeldung hat keinen Autorisierungscode zurückgegeben.",
"codex_login_missing_tokens": "Die Codex-Anmeldung hat die erforderlichen Zugangs- und Aktualisierungstoken nicht zurückgegeben.",
"codex_login_refresh_failed": "Die Codex-Anmeldung konnte nicht aktualisiert werden. Bitte führen Sie 'fabric --setup' erneut aus.",
"codex_login_return_to_fabric": "Zurück zu Fabric.",
"codex_login_revoked": "Die Codex-Anmeldung ist abgelaufen oder wurde widerrufen. Bitte führen Sie 'fabric --setup' erneut aus.",
"codex_login_server_stopped": "Der Codex-Anmelde-Callback-Server wurde gestoppt, bevor die Authentifizierung abgeschlossen war.",
"codex_login_state_mismatch": "Die Codex-Anmeldung konnte nicht verifiziert werden, da der OAuth-Status nicht übereinstimmte.",
"codex_login_timed_out": "Zeitüberschreitung bei der Codex-Anmeldung vor Abschluss der Authentifizierung.",
"codex_oauth_missing_auth_code": "Fehlender Autorisierungscode",
"codex_oauth_random_state_failed": "Sicherer zufälliger OAuth-Status konnte nicht generiert werden: %w",
"codex_oauth_server_start_failed": "Lokaler OAuth-Callback-Server konnte nicht gestartet werden: %w",
"codex_oauth_state_mismatch": "Status stimmt nicht überein",
"codex_refresh_failed_status": "Codex-Anmeldung konnte nicht aktualisiert werden (Status %d)",
"codex_refresh_login_failed": "Codex-Anmeldung konnte nicht aktualisiert werden: %w",
"codex_refresh_token_required": "Codex-Aktualisierungstoken ist erforderlich. Bitte führen Sie 'fabric --setup' erneut aus.",
"codex_replay_body_unavailable": "Anfragekörper kann für Codex-Reauthentifizierungswiederholung nicht wiedergegeben werden",
"codex_request_failed_status": "Codex-Anfrage fehlgeschlagen mit Status %d",
"codex_starting_browser_login": "Starte browserbasierte OpenAI-Anmeldung für Codex.",
"codex_token_exchange_failed": "Codex-Token-Austausch fehlgeschlagen: %w",
"codex_token_refresh_missing_access_token": "Die Codex-Token-Aktualisierung hat kein Zugriffstoken zurückgegeben.",
"codex_usage_limit_reached": "Codex-Nutzungslimit erreicht",
"command_completed_successfully": "Befehl erfolgreich abgeschlossen",
"compression_level_jpeg_webp": "Komprimierungslevel 0-100 für JPEG/WebP-Formate (Standard: nicht gesetzt)",
"config_file_not_found": "Konfigurationsdatei nicht gefunden: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Choose a pattern from the available patterns",
"choose_session_from_available": "Choose a session from the available sessions",
"choose_strategy_from_available": "Choose a strategy from the available strategies",
"codex_auth_base_url_invalid": "invalid codex auth base url: %w",
"codex_browser_open_fallback": "If your browser did not open, navigate to this URL to authenticate:",
"codex_decode_models_response_failed": "failed to decode codex models response: %w",
"codex_decode_refresh_response_failed": "failed to decode refreshed Codex token response: %w",
"codex_decode_token_response_failed": "failed to decode codex token exchange response: %w",
"codex_image_file_not_supported": "Codex vendor does not support --image-file. Use an image attachment instead.",
"codex_login_account_changed": "Codex login is linked to a different ChatGPT account than the stored configuration. Please rerun 'fabric --setup'.",
"codex_login_completed": "Codex login completed",
"codex_login_failed": "Codex login failed: %s",
"codex_login_invalid": "Codex login is no longer valid. Please rerun 'fabric --setup'.",
"codex_login_missing_account_claim": "Codex login did not include a ChatGPT account ID. This login state is not supported.",
"codex_login_missing_auth_code": "Codex login did not return an authorization code.",
"codex_login_missing_tokens": "Codex login did not return the required access and refresh tokens.",
"codex_login_refresh_failed": "Codex login could not be refreshed. Please rerun 'fabric --setup'.",
"codex_login_return_to_fabric": "Return to Fabric.",
"codex_login_revoked": "Codex login has expired or been revoked. Please rerun 'fabric --setup'.",
"codex_login_server_stopped": "Codex login callback server stopped before authentication completed.",
"codex_login_state_mismatch": "Codex login could not be verified because the OAuth state did not match.",
"codex_login_timed_out": "Codex login timed out before authentication completed.",
"codex_oauth_missing_auth_code": "Missing authorization code",
"codex_oauth_random_state_failed": "failed to generate secure random oauth state: %w",
"codex_oauth_server_start_failed": "failed to start local oauth callback server: %w",
"codex_oauth_state_mismatch": "State mismatch",
"codex_refresh_failed_status": "failed to refresh codex login (status %d)",
"codex_refresh_login_failed": "failed to refresh Codex login: %w",
"codex_refresh_token_required": "Codex refresh token is required. Please rerun 'fabric --setup'.",
"codex_replay_body_unavailable": "request body cannot be replayed for Codex re-authentication retry",
"codex_request_failed_status": "codex request failed with status %d",
"codex_starting_browser_login": "Starting browser-based OpenAI login for Codex.",
"codex_token_exchange_failed": "codex token exchange failed: %w",
"codex_token_refresh_missing_access_token": "Codex token refresh did not return an access token.",
"codex_usage_limit_reached": "codex usage limit reached",
"command_completed_successfully": "Command completed successfully",
"compression_level_jpeg_webp": "Compression level 0-100 for JPEG/WebP formats (default: not set)",
"config_file_not_found": "config file not found: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Elige un patrón de los patrones disponibles",
"choose_session_from_available": "Elige una sesión de las sesiones disponibles",
"choose_strategy_from_available": "Elegir una estrategia de las estrategias disponibles",
"codex_auth_base_url_invalid": "URL base de autenticación de Codex no válida: %w",
"codex_browser_open_fallback": "Si su navegador no se abrió, navegue a esta URL para autenticarse:",
"codex_decode_models_response_failed": "No se pudo decodificar la respuesta de modelos de Codex: %w",
"codex_decode_refresh_response_failed": "No se pudo decodificar la respuesta de token actualizado de Codex: %w",
"codex_decode_token_response_failed": "No se pudo decodificar la respuesta de intercambio de token de Codex: %w",
"codex_image_file_not_supported": "El proveedor Codex no admite --image-file. Use un archivo adjunto de imagen en su lugar.",
"codex_login_account_changed": "El inicio de sesión de Codex está vinculado a una cuenta de ChatGPT diferente a la configuración almacenada. Ejecute 'fabric --setup' de nuevo.",
"codex_login_completed": "Inicio de sesión de Codex completado",
"codex_login_failed": "Error en el inicio de sesión de Codex: %s",
"codex_login_invalid": "Su inicio de sesión de Codex ya no es válido. Ejecute 'fabric --setup' de nuevo.",
"codex_login_missing_account_claim": "El inicio de sesión de Codex no incluyó un ID de cuenta de ChatGPT. Este estado de inicio de sesión no es compatible.",
"codex_login_missing_auth_code": "El inicio de sesión de Codex no devolvió un código de autorización.",
"codex_login_missing_tokens": "El inicio de sesión de Codex no devolvió los tokens de acceso y actualización requeridos.",
"codex_login_refresh_failed": "No se pudo actualizar el inicio de sesión de Codex. Ejecute 'fabric --setup' de nuevo.",
"codex_login_return_to_fabric": "Volver a Fabric.",
"codex_login_revoked": "El inicio de sesión de Codex ha expirado o fue revocado. Ejecute 'fabric --setup' de nuevo.",
"codex_login_server_stopped": "El servidor de callback de inicio de sesión de Codex se detuvo antes de completar la autenticación.",
"codex_login_state_mismatch": "No se pudo verificar el inicio de sesión de Codex porque el estado OAuth no coincidió.",
"codex_login_timed_out": "El inicio de sesión de Codex agotó el tiempo de espera antes de completar la autenticación.",
"codex_oauth_missing_auth_code": "Código de autorización faltante",
"codex_oauth_random_state_failed": "No se pudo generar un estado OAuth aleatorio seguro: %w",
"codex_oauth_server_start_failed": "No se pudo iniciar el servidor local de callback OAuth: %w",
"codex_oauth_state_mismatch": "El estado no coincide",
"codex_refresh_failed_status": "No se pudo actualizar el inicio de sesión de Codex (estado %d)",
"codex_refresh_login_failed": "No se pudo actualizar el inicio de sesión de Codex: %w",
"codex_refresh_token_required": "Se requiere el token de actualización de Codex. Ejecute 'fabric --setup' de nuevo.",
"codex_replay_body_unavailable": "El cuerpo de la solicitud no se puede reproducir para el reintento de reautenticación de Codex",
"codex_request_failed_status": "La solicitud de Codex falló con estado %d",
"codex_starting_browser_login": "Iniciando inicio de sesión de OpenAI basado en navegador para Codex.",
"codex_token_exchange_failed": "El intercambio de token de Codex falló: %w",
"codex_token_refresh_missing_access_token": "La actualización del token de Codex no devolvió un token de acceso.",
"codex_usage_limit_reached": "Límite de uso de Codex alcanzado",
"command_completed_successfully": "Comando completado exitosamente",
"compression_level_jpeg_webp": "Nivel de compresión 0-100 para formatos JPEG/WebP (predeterminado: no establecido)",
"config_file_not_found": "archivo de configuración no encontrado: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "الگویی از الگوهای موجود انتخاب کنید",
"choose_session_from_available": "جلسه‌ای از جلسات موجود انتخاب کنید",
"choose_strategy_from_available": "انتخاب استراتژی از استراتژی‌های موجود",
"codex_auth_base_url_invalid": "آدرس پایه احراز هویت Codex نامعتبر است: %w",
"codex_browser_open_fallback": "اگر مرورگر شما باز نشد، برای احراز هویت به این آدرس بروید:",
"codex_decode_models_response_failed": "رمزگشایی پاسخ مدل‌های Codex ناموفق بود: %w",
"codex_decode_refresh_response_failed": "رمزگشایی پاسخ توکن بازنشانی‌شده Codex ناموفق بود: %w",
"codex_decode_token_response_failed": "رمزگشایی پاسخ تبادل توکن Codex ناموفق بود: %w",
"codex_image_file_not_supported": "ارائه‌دهنده Codex از --image-file پشتیبانی نمی‌کند. به جای آن از پیوست تصویر استفاده کنید.",
"codex_login_account_changed": "ورود Codex به حساب ChatGPT متفاوتی از پیکربندی ذخیره‌شده متصل است. لطفاً 'fabric --setup' را دوباره اجرا کنید.",
"codex_login_completed": "ورود Codex تکمیل شد",
"codex_login_failed": "ورود Codex ناموفق بود: %s",
"codex_login_invalid": "ورود Codex شما دیگر معتبر نیست. لطفاً 'fabric --setup' را دوباره اجرا کنید.",
"codex_login_missing_account_claim": "ورود Codex شامل شناسه حساب ChatGPT نبود. این وضعیت ورود پشتیبانی نمی‌شود.",
"codex_login_missing_auth_code": "ورود Codex کد مجوز را برنگرداند.",
"codex_login_missing_tokens": "ورود Codex توکن‌های دسترسی و بازنشانی مورد نیاز را برنگرداند.",
"codex_login_refresh_failed": "بازنشانی ورود Codex امکان‌پذیر نبود. لطفاً 'fabric --setup' را دوباره اجرا کنید.",
"codex_login_return_to_fabric": "بازگشت به Fabric.",
"codex_login_revoked": "ورود Codex منقضی شده یا لغو شده است. لطفاً 'fabric --setup' را دوباره اجرا کنید.",
"codex_login_server_stopped": "سرور بازگشت ورود Codex قبل از تکمیل احراز هویت متوقف شد.",
"codex_login_state_mismatch": "ورود Codex قابل تأیید نبود زیرا وضعیت OAuth مطابقت نداشت.",
"codex_login_timed_out": "زمان ورود Codex قبل از تکمیل احراز هویت به پایان رسید.",
"codex_oauth_missing_auth_code": "کد مجوز موجود نیست",
"codex_oauth_random_state_failed": "تولید وضعیت تصادفی امن OAuth ناموفق بود: %w",
"codex_oauth_server_start_failed": "راه‌اندازی سرور محلی بازگشت OAuth ناموفق بود: %w",
"codex_oauth_state_mismatch": "وضعیت مطابقت ندارد",
"codex_refresh_failed_status": "بازنشانی ورود Codex ناموفق بود (وضعیت %d)",
"codex_refresh_login_failed": "بازنشانی ورود Codex ناموفق بود: %w",
"codex_refresh_token_required": "توکن بازنشانی Codex مورد نیاز است. لطفاً 'fabric --setup' را دوباره اجرا کنید.",
"codex_replay_body_unavailable": "بدنه درخواست برای تلاش مجدد احراز هویت Codex قابل بازپخش نیست",
"codex_request_failed_status": "درخواست Codex با وضعیت %d ناموفق بود",
"codex_starting_browser_login": "شروع ورود مبتنی بر مرورگر OpenAI برای Codex.",
"codex_token_exchange_failed": "تبادل توکن Codex ناموفق بود: %w",
"codex_token_refresh_missing_access_token": "بازنشانی توکن Codex توکن دسترسی را برنگرداند.",
"codex_usage_limit_reached": "محدودیت استفاده Codex به حداکثر رسیده است",
"command_completed_successfully": "دستور با موفقیت تکمیل شد",
"compression_level_jpeg_webp": "سطح فشرده‌سازی 0-100 برای فرمت‌های JPEG/WebP (پیش‌فرض: تنظیم نشده)",
"config_file_not_found": "فایل پیکربندی یافت نشد: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Choisissez un motif parmi les motifs disponibles",
"choose_session_from_available": "Choisissez une session parmi les sessions disponibles",
"choose_strategy_from_available": "Choisir une stratégie parmi les stratégies disponibles",
"codex_auth_base_url_invalid": "URL de base d'authentification Codex invalide : %w",
"codex_browser_open_fallback": "Si votre navigateur ne s'est pas ouvert, accédez à cette URL pour vous authentifier :",
"codex_decode_models_response_failed": "Échec du décodage de la réponse des modèles Codex : %w",
"codex_decode_refresh_response_failed": "Échec du décodage de la réponse du jeton Codex rafraîchi : %w",
"codex_decode_token_response_failed": "Échec du décodage de la réponse d'échange de jeton Codex : %w",
"codex_image_file_not_supported": "Le fournisseur Codex ne prend pas en charge --image-file. Utilisez une pièce jointe image à la place.",
"codex_login_account_changed": "La connexion Codex est liée à un compte ChatGPT différent de la configuration enregistrée. Veuillez relancer 'fabric --setup'.",
"codex_login_completed": "Connexion Codex terminée",
"codex_login_failed": "Échec de la connexion Codex : %s",
"codex_login_invalid": "Votre connexion Codex n'est plus valide. Veuillez relancer 'fabric --setup'.",
"codex_login_missing_account_claim": "La connexion Codex n'a pas inclus d'identifiant de compte ChatGPT. Cet état de connexion n'est pas pris en charge.",
"codex_login_missing_auth_code": "La connexion Codex n'a pas renvoyé de code d'autorisation.",
"codex_login_missing_tokens": "La connexion Codex n'a pas renvoyé les jetons d'accès et de rafraîchissement requis.",
"codex_login_refresh_failed": "Le rafraîchissement de la connexion Codex a échoué. Veuillez relancer 'fabric --setup'.",
"codex_login_return_to_fabric": "Retourner à Fabric.",
"codex_login_revoked": "La connexion Codex a expiré ou a été révoquée. Veuillez relancer 'fabric --setup'.",
"codex_login_server_stopped": "Le serveur de rappel de connexion Codex s'est arrêté avant la fin de l'authentification.",
"codex_login_state_mismatch": "La connexion Codex n'a pas pu être vérifiée car l'état OAuth ne correspondait pas.",
"codex_login_timed_out": "La connexion Codex a expiré avant la fin de l'authentification.",
"codex_oauth_missing_auth_code": "Code d'autorisation manquant",
"codex_oauth_random_state_failed": "Échec de la génération d'un état OAuth aléatoire sécurisé : %w",
"codex_oauth_server_start_failed": "Échec du démarrage du serveur local de rappel OAuth : %w",
"codex_oauth_state_mismatch": "L'état ne correspond pas",
"codex_refresh_failed_status": "Échec du rafraîchissement de la connexion Codex (statut %d)",
"codex_refresh_login_failed": "Échec du rafraîchissement de la connexion Codex : %w",
"codex_refresh_token_required": "Le jeton de rafraîchissement Codex est requis. Veuillez relancer 'fabric --setup'.",
"codex_replay_body_unavailable": "Le corps de la requête ne peut pas être rejoué pour la tentative de réauthentification Codex",
"codex_request_failed_status": "La requête Codex a échoué avec le statut %d",
"codex_starting_browser_login": "Démarrage de la connexion OpenAI par navigateur pour Codex.",
"codex_token_exchange_failed": "L'échange de jeton Codex a échoué : %w",
"codex_token_refresh_missing_access_token": "Le rafraîchissement du jeton Codex n'a pas renvoyé de jeton d'accès.",
"codex_usage_limit_reached": "Limite d'utilisation Codex atteinte",
"command_completed_successfully": "Commande terminée avec succès",
"compression_level_jpeg_webp": "Niveau de compression 0-100 pour les formats JPEG/WebP (par défaut : non défini)",
"config_file_not_found": "fichier de configuration non trouvé : %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Scegli un pattern dai pattern disponibili",
"choose_session_from_available": "Scegli una sessione dalle sessioni disponibili",
"choose_strategy_from_available": "Scegli una strategia dalle strategie disponibili",
"codex_auth_base_url_invalid": "URL base di autenticazione Codex non valido: %w",
"codex_browser_open_fallback": "Se il browser non si è aperto, navigare a questo URL per autenticarsi:",
"codex_decode_models_response_failed": "Decodifica della risposta dei modelli Codex non riuscita: %w",
"codex_decode_refresh_response_failed": "Decodifica della risposta del token Codex aggiornato non riuscita: %w",
"codex_decode_token_response_failed": "Decodifica della risposta di scambio token Codex non riuscita: %w",
"codex_image_file_not_supported": "Il fornitore Codex non supporta --image-file. Utilizzare un allegato immagine.",
"codex_login_account_changed": "L'accesso Codex è collegato a un account ChatGPT diverso dalla configurazione salvata. Eseguire di nuovo 'fabric --setup'.",
"codex_login_completed": "Accesso Codex completato",
"codex_login_failed": "Accesso Codex non riuscito: %s",
"codex_login_invalid": "L'accesso Codex non è più valido. Eseguire di nuovo 'fabric --setup'.",
"codex_login_missing_account_claim": "L'accesso Codex non includeva un ID account ChatGPT. Questo stato di accesso non è supportato.",
"codex_login_missing_auth_code": "L'accesso Codex non ha restituito un codice di autorizzazione.",
"codex_login_missing_tokens": "L'accesso Codex non ha restituito i token di accesso e aggiornamento richiesti.",
"codex_login_refresh_failed": "Impossibile aggiornare l'accesso Codex. Eseguire di nuovo 'fabric --setup'.",
"codex_login_return_to_fabric": "Torna a Fabric.",
"codex_login_revoked": "L'accesso Codex è scaduto o è stato revocato. Eseguire di nuovo 'fabric --setup'.",
"codex_login_server_stopped": "Il server di callback dell'accesso Codex si è fermato prima del completamento dell'autenticazione.",
"codex_login_state_mismatch": "L'accesso Codex non è stato verificato perché lo stato OAuth non corrispondeva.",
"codex_login_timed_out": "L'accesso Codex è scaduto prima del completamento dell'autenticazione.",
"codex_oauth_missing_auth_code": "Codice di autorizzazione mancante",
"codex_oauth_random_state_failed": "Generazione dello stato OAuth casuale sicuro non riuscita: %w",
"codex_oauth_server_start_failed": "Avvio del server locale di callback OAuth non riuscito: %w",
"codex_oauth_state_mismatch": "Lo stato non corrisponde",
"codex_refresh_failed_status": "Aggiornamento dell'accesso Codex non riuscito (stato %d)",
"codex_refresh_login_failed": "Aggiornamento dell'accesso Codex non riuscito: %w",
"codex_refresh_token_required": "Il token di aggiornamento Codex è richiesto. Eseguire di nuovo 'fabric --setup'.",
"codex_replay_body_unavailable": "Il corpo della richiesta non può essere riprodotto per il tentativo di riautenticazione Codex",
"codex_request_failed_status": "La richiesta Codex è fallita con stato %d",
"codex_starting_browser_login": "Avvio dell'accesso OpenAI basato su browser per Codex.",
"codex_token_exchange_failed": "Lo scambio di token Codex è fallito: %w",
"codex_token_refresh_missing_access_token": "L'aggiornamento del token Codex non ha restituito un token di accesso.",
"codex_usage_limit_reached": "Limite di utilizzo Codex raggiunto",
"command_completed_successfully": "Comando completato con successo",
"compression_level_jpeg_webp": "Livello di compressione 0-100 per formati JPEG/WebP (predefinito: non impostato)",
"config_file_not_found": "file di configurazione non trovato: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "利用可能なパターンからパターンを選択",
"choose_session_from_available": "利用可能なセッションからセッションを選択",
"choose_strategy_from_available": "利用可能な戦略から戦略を選択",
"codex_auth_base_url_invalid": "Codex認証ベースURLが無効です: %w",
"codex_browser_open_fallback": "ブラウザが開かなかった場合は、このURLに移動して認証してください:",
"codex_decode_models_response_failed": "Codexモデルレスポンスのデコードに失敗しました: %w",
"codex_decode_refresh_response_failed": "更新されたCodexトークンレスポンスのデコードに失敗しました: %w",
"codex_decode_token_response_failed": "Codexトークン交換レスポンスのデコードに失敗しました: %w",
"codex_image_file_not_supported": "Codexベンダーは--image-fileをサポートしていません。代わりに画像添付を使用してください。",
"codex_login_account_changed": "Codexログインが保存された設定とは異なるChatGPTアカウントに紐づけられています。'fabric --setup'を再実行してください。",
"codex_login_completed": "Codexログインが完了しました",
"codex_login_failed": "Codexログインに失敗しました: %s",
"codex_login_invalid": "Codexログインは無効になりました。'fabric --setup'を再実行してください。",
"codex_login_missing_account_claim": "CodexログインにChatGPTアカウントIDが含まれていませんでした。このログイン状態はサポートされていません。",
"codex_login_missing_auth_code": "Codexログインが認証コードを返しませんでした。",
"codex_login_missing_tokens": "Codexログインが必要なアクセストークンとリフレッシュトークンを返しませんでした。",
"codex_login_refresh_failed": "Codexログインを更新できませんでした。'fabric --setup'を再実行してください。",
"codex_login_return_to_fabric": "Fabricに戻る。",
"codex_login_revoked": "Codexログインが期限切れまたは取り消されました。'fabric --setup'を再実行してください。",
"codex_login_server_stopped": "認証が完了する前にCodexログインコールバックサーバーが停止しました。",
"codex_login_state_mismatch": "OAuthステートが一致しなかったため、Codexログインを検証できませんでした。",
"codex_login_timed_out": "認証が完了する前にCodexログインがタイムアウトしました。",
"codex_oauth_missing_auth_code": "認証コードがありません",
"codex_oauth_random_state_failed": "安全なランダムOAuthステートの生成に失敗しました: %w",
"codex_oauth_server_start_failed": "ローカルOAuthコールバックサーバーの起動に失敗しました: %w",
"codex_oauth_state_mismatch": "ステートが一致しません",
"codex_refresh_failed_status": "Codexログインの更新に失敗しましたステータス %d",
"codex_refresh_login_failed": "Codexログインの更新に失敗しました: %w",
"codex_refresh_token_required": "Codexリフレッシュトークンが必要です。'fabric --setup'を再実行してください。",
"codex_replay_body_unavailable": "Codex再認証リトライのためにリクエストボディを再送できません",
"codex_request_failed_status": "Codexリクエストがステータス %d で失敗しました",
"codex_starting_browser_login": "Codex用のブラウザベースOpenAIログインを開始しています。",
"codex_token_exchange_failed": "Codexトークン交換に失敗しました: %w",
"codex_token_refresh_missing_access_token": "Codexトークンの更新がアクセストークンを返しませんでした。",
"codex_usage_limit_reached": "Codex使用量制限に達しました",
"command_completed_successfully": "コマンドが正常に完了しました",
"compression_level_jpeg_webp": "JPEG/WebP形式の圧縮レベル0-100デフォルト未設定",
"config_file_not_found": "設定ファイルが見つかりません: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Wybierz wzorzec spośród dostępnych wzorców",
"choose_session_from_available": "Wybierz sesję spośród dostępnych sesji",
"choose_strategy_from_available": "Wybierz strategię spośród dostępnych strategii",
"codex_auth_base_url_invalid": "Nieprawidłowy bazowy URL uwierzytelniania Codex: %w",
"codex_browser_open_fallback": "Jeśli przeglądarka się nie otworzyła, przejdź pod ten URL, aby się uwierzytelnić:",
"codex_decode_models_response_failed": "Nie udało się zdekodować odpowiedzi modeli Codex: %w",
"codex_decode_refresh_response_failed": "Nie udało się zdekodować odpowiedzi odświeżonego tokenu Codex: %w",
"codex_decode_token_response_failed": "Nie udało się zdekodować odpowiedzi wymiany tokenu Codex: %w",
"codex_image_file_not_supported": "Dostawca Codex nie obsługuje --image-file. Użyj załącznika graficznego.",
"codex_login_account_changed": "Logowanie Codex jest powiązane z innym kontem ChatGPT niż zapisana konfiguracja. Uruchom ponownie 'fabric --setup'.",
"codex_login_completed": "Logowanie Codex zakończone",
"codex_login_failed": "Logowanie Codex nie powiodło się: %s",
"codex_login_invalid": "Twoje logowanie Codex nie jest już ważne. Uruchom ponownie 'fabric --setup'.",
"codex_login_missing_account_claim": "Logowanie Codex nie zawierało identyfikatora konta ChatGPT. Ten stan logowania nie jest obsługiwany.",
"codex_login_missing_auth_code": "Logowanie Codex nie zwróciło kodu autoryzacji.",
"codex_login_missing_tokens": "Logowanie Codex nie zwróciło wymaganych tokenów dostępu i odświeżania.",
"codex_login_refresh_failed": "Nie udało się odświeżyć logowania Codex. Uruchom ponownie 'fabric --setup'.",
"codex_login_return_to_fabric": "Wróć do Fabric.",
"codex_login_revoked": "Logowanie Codex wygasło lub zostało unieważnione. Uruchom ponownie 'fabric --setup'.",
"codex_login_server_stopped": "Serwer zwrotny logowania Codex zatrzymał się przed zakończeniem uwierzytelniania.",
"codex_login_state_mismatch": "Nie można zweryfikować logowania Codex, ponieważ stan OAuth nie był zgodny.",
"codex_login_timed_out": "Upłynął limit czasu logowania Codex przed zakończeniem uwierzytelniania.",
"codex_oauth_missing_auth_code": "Brak kodu autoryzacji",
"codex_oauth_random_state_failed": "Nie udało się wygenerować bezpiecznego losowego stanu OAuth: %w",
"codex_oauth_server_start_failed": "Nie udało się uruchomić lokalnego serwera zwrotnego OAuth: %w",
"codex_oauth_state_mismatch": "Stan nie jest zgodny",
"codex_refresh_failed_status": "Nie udało się odświeżyć logowania Codex (status %d)",
"codex_refresh_login_failed": "Nie udało się odświeżyć logowania Codex: %w",
"codex_refresh_token_required": "Token odświeżania Codex jest wymagany. Uruchom ponownie 'fabric --setup'.",
"codex_replay_body_unavailable": "Treść żądania nie może być odtworzona dla ponownej próby uwierzytelnienia Codex",
"codex_request_failed_status": "Żądanie Codex nie powiodło się ze statusem %d",
"codex_starting_browser_login": "Uruchamianie logowania OpenAI przez przeglądarkę dla Codex.",
"codex_token_exchange_failed": "Wymiana tokenu Codex nie powiodła się: %w",
"codex_token_refresh_missing_access_token": "Odświeżenie tokenu Codex nie zwróciło tokenu dostępu.",
"codex_usage_limit_reached": "Osiągnięto limit użycia Codex",
"command_completed_successfully": "Polecenie zakończone pomyślnie",
"compression_level_jpeg_webp": "Poziom kompresji 0-100 dla formatów JPEG/WebP (domyślnie: nie ustawiony)",
"config_file_not_found": "plik konfiguracyjny nie został znaleziony: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Escolha um padrão entre os padrões disponíveis",
"choose_session_from_available": "Escolha uma sessão das sessões disponíveis",
"choose_strategy_from_available": "Escolher uma estratégia das estratégias disponíveis",
"codex_auth_base_url_invalid": "URL base de autenticação do Codex inválida: %w",
"codex_browser_open_fallback": "Se o navegador não abriu, navegue até esta URL para se autenticar:",
"codex_decode_models_response_failed": "Falha ao decodificar a resposta de modelos do Codex: %w",
"codex_decode_refresh_response_failed": "Falha ao decodificar a resposta do token atualizado do Codex: %w",
"codex_decode_token_response_failed": "Falha ao decodificar a resposta de troca de token do Codex: %w",
"codex_image_file_not_supported": "O provedor Codex não suporta --image-file. Use um anexo de imagem.",
"codex_login_account_changed": "O login do Codex está vinculado a uma conta ChatGPT diferente da configuração salva. Execute 'fabric --setup' novamente.",
"codex_login_completed": "Login do Codex concluído",
"codex_login_failed": "Falha no login do Codex: %s",
"codex_login_invalid": "Seu login do Codex não é mais válido. Execute 'fabric --setup' novamente.",
"codex_login_missing_account_claim": "O login do Codex não incluiu um ID de conta ChatGPT. Este estado de login não é suportado.",
"codex_login_missing_auth_code": "O login do Codex não retornou um código de autorização.",
"codex_login_missing_tokens": "O login do Codex não retornou os tokens de acesso e atualização necessários.",
"codex_login_refresh_failed": "Não foi possível atualizar o login do Codex. Execute 'fabric --setup' novamente.",
"codex_login_return_to_fabric": "Voltar para o Fabric.",
"codex_login_revoked": "O login do Codex expirou ou foi revogado. Execute 'fabric --setup' novamente.",
"codex_login_server_stopped": "O servidor de callback do login do Codex parou antes da autenticação ser concluída.",
"codex_login_state_mismatch": "O login do Codex não pôde ser verificado porque o estado OAuth não correspondeu.",
"codex_login_timed_out": "O login do Codex expirou antes da autenticação ser concluída.",
"codex_oauth_missing_auth_code": "Código de autorização ausente",
"codex_oauth_random_state_failed": "Falha ao gerar estado OAuth aleatório seguro: %w",
"codex_oauth_server_start_failed": "Falha ao iniciar o servidor local de callback OAuth: %w",
"codex_oauth_state_mismatch": "O estado não corresponde",
"codex_refresh_failed_status": "Falha ao atualizar o login do Codex (status %d)",
"codex_refresh_login_failed": "Falha ao atualizar o login do Codex: %w",
"codex_refresh_token_required": "O token de atualização do Codex é obrigatório. Execute 'fabric --setup' novamente.",
"codex_replay_body_unavailable": "O corpo da requisição não pode ser reproduzido para a tentativa de reautenticação do Codex",
"codex_request_failed_status": "A requisição do Codex falhou com status %d",
"codex_starting_browser_login": "Iniciando login OpenAI baseado em navegador para o Codex.",
"codex_token_exchange_failed": "A troca de token do Codex falhou: %w",
"codex_token_refresh_missing_access_token": "A atualização do token do Codex não retornou um token de acesso.",
"codex_usage_limit_reached": "Limite de uso do Codex atingido",
"command_completed_successfully": "Comando concluído com sucesso",
"compression_level_jpeg_webp": "Nível de compressão 0-100 para formatos JPEG/WebP (padrão: não definido)",
"config_file_not_found": "arquivo de configuração não encontrado: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "Escolha um padrão dos padrões disponíveis",
"choose_session_from_available": "Escolha uma sessão das sessões disponíveis",
"choose_strategy_from_available": "Escolher uma estratégia das estratégias disponíveis",
"codex_auth_base_url_invalid": "URL base de autenticação do Codex inválido: %w",
"codex_browser_open_fallback": "Se o navegador não abriu, navegue até este URL para se autenticar:",
"codex_decode_models_response_failed": "Falha ao descodificar a resposta de modelos do Codex: %w",
"codex_decode_refresh_response_failed": "Falha ao descodificar a resposta do token atualizado do Codex: %w",
"codex_decode_token_response_failed": "Falha ao descodificar a resposta de troca de token do Codex: %w",
"codex_image_file_not_supported": "O fornecedor Codex não suporta --image-file. Utilize um anexo de imagem.",
"codex_login_account_changed": "O início de sessão do Codex está associado a uma conta ChatGPT diferente da configuração guardada. Execute 'fabric --setup' novamente.",
"codex_login_completed": "Início de sessão do Codex concluído",
"codex_login_failed": "Falha no início de sessão do Codex: %s",
"codex_login_invalid": "O seu início de sessão do Codex já não é válido. Execute 'fabric --setup' novamente.",
"codex_login_missing_account_claim": "O início de sessão do Codex não incluiu um ID de conta ChatGPT. Este estado de início de sessão não é suportado.",
"codex_login_missing_auth_code": "O início de sessão do Codex não devolveu um código de autorização.",
"codex_login_missing_tokens": "O início de sessão do Codex não devolveu os tokens de acesso e atualização necessários.",
"codex_login_refresh_failed": "Não foi possível atualizar o início de sessão do Codex. Execute 'fabric --setup' novamente.",
"codex_login_return_to_fabric": "Voltar ao Fabric.",
"codex_login_revoked": "O início de sessão do Codex expirou ou foi revogado. Execute 'fabric --setup' novamente.",
"codex_login_server_stopped": "O servidor de retorno do início de sessão do Codex parou antes da autenticação ser concluída.",
"codex_login_state_mismatch": "O início de sessão do Codex não pôde ser verificado porque o estado OAuth não correspondeu.",
"codex_login_timed_out": "O início de sessão do Codex expirou antes da autenticação ser concluída.",
"codex_oauth_missing_auth_code": "Código de autorização em falta",
"codex_oauth_random_state_failed": "Falha ao gerar estado OAuth aleatório seguro: %w",
"codex_oauth_server_start_failed": "Falha ao iniciar o servidor local de retorno OAuth: %w",
"codex_oauth_state_mismatch": "O estado não corresponde",
"codex_refresh_failed_status": "Falha ao atualizar o início de sessão do Codex (estado %d)",
"codex_refresh_login_failed": "Falha ao atualizar o início de sessão do Codex: %w",
"codex_refresh_token_required": "O token de atualização do Codex é obrigatório. Execute 'fabric --setup' novamente.",
"codex_replay_body_unavailable": "O corpo do pedido não pode ser reproduzido para a tentativa de reautenticação do Codex",
"codex_request_failed_status": "O pedido do Codex falhou com estado %d",
"codex_starting_browser_login": "A iniciar início de sessão OpenAI baseado no navegador para o Codex.",
"codex_token_exchange_failed": "A troca de token do Codex falhou: %w",
"codex_token_refresh_missing_access_token": "A atualização do token do Codex não devolveu um token de acesso.",
"codex_usage_limit_reached": "Limite de utilização do Codex atingido",
"command_completed_successfully": "Comando concluído com sucesso",
"compression_level_jpeg_webp": "Nível de compressão 0-100 para formatos JPEG/WebP (por omissão: não definido)",
"config_file_not_found": "ficheiro de configuração não encontrado: %s",

View file

@ -110,21 +110,38 @@
"choose_pattern_from_available": "从可用模式中选择一个模式",
"choose_session_from_available": "从可用会话中选择一个会话",
"choose_strategy_from_available": "从可用策略中选择一个策略",
"codex_auth_base_url_invalid": "Codex 认证基础 URL 无效:%w",
"codex_browser_open_fallback": "如果浏览器未打开,请导航到此 URL 进行身份验证:",
"codex_decode_models_response_failed": "解码 Codex 模型响应失败:%w",
"codex_decode_refresh_response_failed": "解码刷新的 Codex 令牌响应失败:%w",
"codex_decode_token_response_failed": "解码 Codex 令牌交换响应失败:%w",
"codex_image_file_not_supported": "Codex 供应商不支持 --image-file。请改用图片附件。",
"codex_login_account_changed": "Codex 登录关联的 ChatGPT 账户与保存的配置不同。请重新运行 'fabric --setup'。",
"codex_login_completed": "Codex 登录完成",
"codex_login_failed": "Codex 登录失败:%s",
"codex_login_invalid": "您的 Codex 登录已失效。请重新运行 'fabric --setup'。",
"codex_login_missing_account_claim": "Codex 登录未包含 ChatGPT 账户 ID。此登录状态不受支持。",
"codex_login_missing_auth_code": "Codex 登录未返回授权码。",
"codex_login_missing_tokens": "Codex 登录未返回所需的访问令牌和刷新令牌。",
"codex_login_refresh_failed": "无法刷新 Codex 登录。请重新运行 'fabric --setup'。",
"codex_login_return_to_fabric": "返回 Fabric。",
"codex_login_revoked": "Codex 登录已过期或被撤销。请重新运行 'fabric --setup'。",
"codex_login_server_stopped": "Codex 登录回调服务器在身份验证完成前停止。",
"codex_login_state_mismatch": "由于 OAuth 状态不匹配,无法验证 Codex 登录。",
"codex_login_timed_out": "Codex 登录在身份验证完成前超时。",
"codex_oauth_missing_auth_code": "缺少授权码",
"codex_oauth_random_state_failed": "生成安全随机 OAuth 状态失败:%w",
"codex_oauth_server_start_failed": "启动本地 OAuth 回调服务器失败:%w",
"codex_oauth_state_mismatch": "状态不匹配",
"codex_refresh_failed_status": "刷新 Codex 登录失败(状态 %d",
"codex_refresh_login_failed": "刷新 Codex 登录失败:%w",
"codex_refresh_token_required": "需要 Codex 刷新令牌。请重新运行 'fabric --setup'。",
"codex_replay_body_unavailable": "请求正文无法重放用于 Codex 重新认证重试",
"codex_request_failed_status": "Codex 请求失败,状态 %d",
"codex_starting_browser_login": "正在启动基于浏览器的 OpenAI 登录以连接 Codex。",
"codex_token_exchange_failed": "Codex 令牌交换失败:%w",
"codex_token_refresh_missing_access_token": "Codex 令牌刷新未返回访问令牌。",
"codex_usage_limit_reached": "已达到 Codex 使用限制",
"command_completed_successfully": "命令执行成功",
"compression_level_jpeg_webp": "JPEG/WebP 格式的压缩级别 0-100默认未设置",
"config_file_not_found": "找不到配置文件:%s",

View file

@ -125,7 +125,7 @@ func (an *Client) configure() (err error) {
return
}
func (an *Client) ListModels() (ret []string, err error) {
func (an *Client) ListModels(context.Context) (ret []string, err error) {
return an.models, nil
}
@ -150,7 +150,7 @@ func parseThinking(level domain.ThinkingLevel) (anthropic.ThinkingConfigParamUni
}
func (an *Client) SendStream(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
) (err error) {
messages := an.toMessages(msgs)
if len(messages) == 0 {
@ -159,8 +159,6 @@ func (an *Client) SendStream(
return
}
ctx := context.Background()
params := an.buildMessageParams(messages, opts)
betas := an.modelBetas[opts.Model]
var reqOpts []option.RequestOption

View file

@ -1,6 +1,7 @@
package anthropic
import (
"context"
"strings"
"testing"
@ -34,7 +35,7 @@ func TestNewClient_DefaultInitialization(t *testing.T) {
func TestClientListModels(t *testing.T) {
client := NewClient()
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@ -52,7 +53,7 @@ func TestClientListModels(t *testing.T) {
func TestClient_ListModels_ReturnsCorrectModels(t *testing.T) {
client := NewClient()
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("Expected no error, got %v", err)

View file

@ -1,6 +1,7 @@
package azure
import (
"context"
"errors"
"strings"
@ -66,7 +67,7 @@ func (oi *Client) configure() error {
return nil
}
func (oi *Client) ListModels() (ret []string, err error) {
func (oi *Client) ListModels(context.Context) (ret []string, err error) {
ret = oi.apiDeployments
return
}

View file

@ -2,6 +2,7 @@ package azure
import (
"bytes"
"context"
"io"
"net/http"
"testing"
@ -78,7 +79,7 @@ func TestListModels(t *testing.T) {
client := NewClient()
client.apiDeployments = []string{"deployment1", "deployment2"}
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}

View file

@ -1,6 +1,7 @@
package azure_entra
import (
"context"
"errors"
"fmt"
"strings"
@ -71,7 +72,7 @@ func (c *Client) configure() error {
return nil
}
func (c *Client) ListModels() (ret []string, err error) {
func (c *Client) ListModels(context.Context) (ret []string, err error) {
ret = c.apiDeployments
return
}

View file

@ -1,6 +1,7 @@
package azure_entra
import (
"context"
"testing"
)
@ -47,7 +48,7 @@ func TestListModels(t *testing.T) {
client := NewClient()
client.apiDeployments = []string{"gpt-4o", "gpt-5"}
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}

View file

@ -35,7 +35,7 @@ var _ ai.Vendor = (*Client)(nil)
// are handled by the Client.
type Backend interface {
// ListModels returns the list of models available for this backend
ListModels() ([]string, error)
ListModels(context.Context) ([]string, error)
// BuildEndpoint constructs the full API endpoint URL for the given model
BuildEndpoint(baseURL, model string) string
@ -132,11 +132,11 @@ func (c *Client) IsConfigured() bool {
}
// ListModels delegates to the active backend
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
if c.backend == nil {
return nil, errors.New(i18n.T("azureaigateway_backend_not_initialized"))
}
return c.backend.ListModels()
return c.backend.ListModels(ctx)
}
// Send sends a non-streaming request through the APIM gateway.
@ -199,18 +199,13 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
}
// SendStream falls back to non-streaming (APIM gateway doesn't support SSE pass-through).
//
// NOTE: This method uses context.Background() because the ai.Vendor interface does not
// accept a context parameter for SendStream. If the caller disconnects, this request will
// continue until the gateway timeout (300s). A future update to the ai.Vendor interface
// should add context propagation to SendStream.
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
func (c *Client) SendStream(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
defer close(channel)
if c.backend == nil {
return errors.New(i18n.T("azureaigateway_backend_not_initialized"))
}
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
ctx, cancel := context.WithTimeout(ctx, gatewayTimeout)
defer cancel()
result, err := c.Send(ctx, msgs, opts)

View file

@ -60,7 +60,7 @@ func TestBedrockAuthHeader(t *testing.T) {
func TestBedrockListModels(t *testing.T) {
b := NewBedrockBackend("key")
models, err := b.ListModels()
models, err := b.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
@ -253,7 +253,7 @@ func TestAzureOpenAIAuthHeader(t *testing.T) {
func TestAzureOpenAIListModels(t *testing.T) {
b := NewAzureOpenAIBackend("key", "")
models, err := b.ListModels()
models, err := b.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
@ -336,7 +336,7 @@ func TestVertexAIAuthHeader(t *testing.T) {
func TestVertexAIListModels(t *testing.T) {
b := NewVertexAIBackend("key")
models, err := b.ListModels()
models, err := b.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
@ -549,7 +549,7 @@ func TestConfigureInvalidBackend(t *testing.T) {
func TestListModelsWithoutInit(t *testing.T) {
c := NewClient()
_, err := c.ListModels()
_, err := c.ListModels(context.Background())
if err == nil {
t.Error("ListModels() expected error when backend not initialized")
}
@ -848,7 +848,7 @@ func TestSendStreamWithoutBackendInit(t *testing.T) {
}
channel := make(chan domain.StreamUpdate, 1)
err := c.SendStream(msgs, opts, channel)
err := c.SendStream(context.Background(), msgs, opts, channel)
if err == nil {
t.Fatal("SendStream() expected error when backend not initialized")
}
@ -901,7 +901,7 @@ func TestSendStreamFallback(t *testing.T) {
}
channel := make(chan domain.StreamUpdate, 10)
err := c.SendStream(msgs, opts, channel)
err := c.SendStream(context.Background(), msgs, opts, channel)
if err != nil {
t.Fatalf("SendStream() error = %v", err)
}

View file

@ -2,6 +2,7 @@
package azureaigateway
import (
"context"
"encoding/json"
"errors"
"fmt"
@ -34,7 +35,7 @@ func NewAzureOpenAIBackend(subscriptionKey, apiVersion string) *AzureOpenAIBacke
// ListModels returns the list of models available through Azure OpenAI.
// These are deployment names that must exist in your Azure OpenAI resource.
func (b *AzureOpenAIBackend) ListModels() ([]string, error) {
func (b *AzureOpenAIBackend) ListModels(_ context.Context) ([]string, error) {
return []string{
"DeepSeek-R1",
"gpt-4o",

View file

@ -2,6 +2,7 @@
package azureaigateway
import (
"context"
"encoding/json"
"errors"
"fmt"
@ -27,7 +28,7 @@ func NewBedrockBackend(subscriptionKey string) *BedrockBackend {
}
// ListModels returns the list of available Bedrock inference profiles
func (b *BedrockBackend) ListModels() ([]string, error) {
func (b *BedrockBackend) ListModels(_ context.Context) ([]string, error) {
return []string{
"us.anthropic.claude-3-haiku-20240307-v1:0",
"us.anthropic.claude-3-opus-20240229-v1:0",

View file

@ -2,6 +2,7 @@
package azureaigateway
import (
"context"
"encoding/json"
"errors"
"fmt"
@ -26,7 +27,7 @@ func NewVertexAIBackend(subscriptionKey string) *VertexAIBackend {
}
// ListModels returns the list of Gemini models available through Vertex AI
func (b *VertexAIBackend) ListModels() ([]string, error) {
func (b *VertexAIBackend) ListModels(_ context.Context) ([]string, error) {
return []string{
"gemini-3-pro-preview",
"gemini-2.5-pro",

View file

@ -443,7 +443,7 @@ func (c *BedrockClient) configure() error {
// from AWS Bedrock that can be used with this plugin.
// When using bearer token auth, the API may not be accessible, so a static
// fallback list of common models is returned instead.
func (c *BedrockClient) ListModels() ([]string, error) {
func (c *BedrockClient) ListModels(_ context.Context) ([]string, error) {
models, err := c.listModelsFromAPI()
if err != nil && c.bedrockAPIKey.Value != "" {
// Bearer token auth may lack ListFoundationModels permissions;
@ -488,7 +488,7 @@ func (c *BedrockClient) listModelsFromAPI() ([]string, error) {
}
// SendStream sends the messages to the Bedrock ConverseStream API
func (c *BedrockClient) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
func (c *BedrockClient) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
// Ensure channel is closed on all exit paths to prevent goroutine leaks
defer func() {
if r := recover(); r != nil {

View file

@ -207,7 +207,7 @@ func TestListModels_NilClient_WithApiKey_ReturnsFallback(t *testing.T) {
client.bedrockAPIKey.Value = "test-absk-token"
// Don't call configure() — clients are nil
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
assert.NoError(t, err, "ListModels should not error when falling back to static list")
assert.Equal(t, defaultBedrockModels, models, "should return default models as fallback")
}
@ -216,7 +216,7 @@ func TestListModels_NilClient_NoApiKey_ReturnsError(t *testing.T) {
client := NewClient()
// Don't call configure() and no API key — should propagate error
_, err := client.ListModels()
_, err := client.ListModels(context.Background())
assert.Error(t, err, "ListModels should error when client is nil and no API key for fallback")
}
@ -227,7 +227,7 @@ func TestSendStream_NilClient_ReturnsError(t *testing.T) {
ch := make(chan domain.StreamUpdate, 10)
opts := &domain.ChatOptions{Model: "test-model", Temperature: 0.7, TopP: 0.9}
err := client.SendStream(nil, opts, ch)
err := client.SendStream(context.Background(), nil, opts, ch)
assert.Error(t, err, "SendStream should return error when client is nil")
assert.Contains(t, err.Error(), i18n.T("bedrock_client_not_initialized"))
}

View file

@ -0,0 +1,203 @@
package codex
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
plugins "github.com/danielmiessler/fabric/internal/plugins"
)
var errReplayBodyUnavailable = errors.New(i18n.T("codex_replay_body_unavailable"))
type authTransport struct {
client *Client
wrapped http.RoundTripper
}
func (c *Client) ensureAccessToken(ctx context.Context, forceRefresh bool) (string, string, error) {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
accessToken := strings.TrimSpace(c.AccessToken.Value)
accountID := strings.TrimSpace(c.AccountID.Value)
if !forceRefresh && accessToken != "" && !tokenNeedsRefresh(accessToken, time.Now()) {
if accountID == "" {
parsedAccountID, err := extractAccountIDFromJWT(accessToken)
if err == nil && parsedAccountID != "" {
accountID = parsedAccountID
c.setSettingValue(c.AccountID, accountID)
}
}
if accountID != "" {
return accessToken, accountID, nil
}
}
refreshed, err := c.refreshAccessToken(ctx)
if err != nil {
return "", "", err
}
refreshedAccountID, err := c.extractAccountID(refreshed.IDToken, refreshed.AccessToken)
if err != nil {
return "", "", err
}
if accountID != "" && refreshedAccountID != "" && !strings.EqualFold(accountID, refreshedAccountID) {
return "", "", errors.New(i18n.T("codex_login_account_changed"))
}
c.setSettingValue(c.AccessToken, refreshed.AccessToken)
if strings.TrimSpace(refreshed.RefreshToken) != "" {
c.setSettingValue(c.RefreshToken, refreshed.RefreshToken)
}
c.setSettingValue(c.AccountID, refreshedAccountID)
debuglog.Debug(debuglog.Detailed, "Codex access token refreshed for account=%s\n", refreshedAccountID)
return c.AccessToken.Value, c.AccountID.Value, nil
}
func (c *Client) refreshAccessToken(ctx context.Context) (oauthTokens, error) {
payload := refreshRequest{
ClientID: oauthClientID,
GrantType: "refresh_token",
RefreshToken: strings.TrimSpace(c.RefreshToken.Value),
}
body, err := json.Marshal(payload)
if err != nil {
return oauthTokens{}, err
}
tokenURL := strings.TrimRight(c.AuthBaseURL.Value, "/") + "/oauth/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
if err != nil {
return oauthTokens{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.authHTTPClient.Do(req)
if err != nil {
return oauthTokens{}, fmt.Errorf(i18n.T("codex_refresh_login_failed"), err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return oauthTokens{}, err
}
if resp.StatusCode != http.StatusOK {
return oauthTokens{}, c.refreshErrorFromResponse(resp.StatusCode, responseBody)
}
var refreshed refreshResponse
if err := json.Unmarshal(responseBody, &refreshed); err != nil {
return oauthTokens{}, fmt.Errorf(i18n.T("codex_decode_refresh_response_failed"), err)
}
if strings.TrimSpace(refreshed.AccessToken) == "" {
return oauthTokens{}, errors.New(i18n.T("codex_token_refresh_missing_access_token"))
}
return oauthTokens{
IDToken: strings.TrimSpace(refreshed.IDToken),
AccessToken: strings.TrimSpace(refreshed.AccessToken),
RefreshToken: strings.TrimSpace(refreshed.RefreshToken),
}, nil
}
func (c *Client) extractAccountID(idToken string, accessToken string) (string, error) {
if accountID, err := extractAccountIDFromJWT(idToken); err == nil && accountID != "" {
return accountID, nil
}
if accountID, err := extractAccountIDFromJWT(accessToken); err == nil && accountID != "" {
return accountID, nil
}
return "", errors.New(i18n.T("codex_login_missing_account_claim"))
}
func (c *Client) setSettingValue(setting *plugins.Setting, value string) {
setting.Value = value
if setting.EnvVariable != "" {
_ = os.Setenv(setting.EnvVariable, value)
}
}
// RoundTrip adds Codex authentication headers and retries once after a 401.
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.roundTrip(req, false)
}
func (t *authTransport) roundTrip(req *http.Request, retried bool) (*http.Response, error) {
token, accountID, err := t.client.ensureAccessToken(req.Context(), false)
if err != nil {
return nil, err
}
clone, err := cloneRequest(req)
if err != nil {
return nil, err
}
clone.Header.Set(http.CanonicalHeaderKey("originator"), defaultOriginator)
clone.Header.Set("User-Agent", defaultUserAgent)
clone.Header.Set("Authorization", "Bearer "+token)
clone.Header.Set("ChatGPT-Account-ID", accountID)
resp, err := t.roundTripper().RoundTrip(clone)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized || retried {
return resp, nil
}
drainAndClose(resp.Body)
debuglog.Debug(debuglog.Detailed, "Codex request returned 401; attempting token refresh and one retry\n")
if _, _, err := t.client.ensureAccessToken(req.Context(), true); err != nil {
return nil, err
}
return t.roundTrip(req, true)
}
func (t *authTransport) roundTripper() http.RoundTripper {
if t.wrapped != nil {
return t.wrapped
}
return http.DefaultTransport
}
func cloneRequest(req *http.Request) (*http.Request, error) {
clone := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody {
return clone, nil
}
// Codex retry logic assumes GetBody is available so the request can be replayed after refresh.
if req.GetBody == nil {
return nil, errReplayBodyUnavailable
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
clone.Body = body
return clone, nil
}
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, defaultRoundTripLimit))
_ = body.Close()
}

View file

@ -4,21 +4,11 @@ package codex
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"runtime/debug"
"slices"
"strings"
"sync"
"time"
@ -55,8 +45,7 @@ const (
const oauthScope = "openid profile email offline_access api.connectors.read api.connectors.invoke"
var errReplayBodyUnavailable = errors.New("request body cannot be replayed for Codex re-authentication retry")
// Client implements the Codex-backed AI vendor.
type Client struct {
*openaivendor.Client
@ -71,29 +60,6 @@ type Client struct {
tokenMu sync.Mutex
}
type oauthTokens struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type refreshRequest struct {
ClientID string `json:"client_id"`
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
}
type refreshResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type oauthResult struct {
tokens oauthTokens
err error
}
type modelInfo struct {
Slug string `json:"slug"`
SupportedInAPI bool `json:"supported_in_api"`
@ -104,29 +70,6 @@ type modelsResponse struct {
Models []modelInfo `json:"models"`
}
type tokenClaims struct {
Exp int64 `json:"exp"`
Auth tokenAuthClaims `json:"https://api.openai.com/auth"`
Profile tokenProfile `json:"https://api.openai.com/profile"`
Email string `json:"email"`
}
type tokenAuthClaims struct {
ChatGPTAccountID string `json:"chatgpt_account_id"`
ChatGPTPlanType string `json:"chatgpt_plan_type"`
UserID string `json:"user_id"`
ChatGPTUserID string `json:"chatgpt_user_id"`
}
type tokenProfile struct {
Email string `json:"email"`
}
type authTransport struct {
client *Client
wrapped http.RoundTripper
}
// NewClient creates a new Codex vendor client.
func NewClient() *Client {
client := &Client{}
@ -216,14 +159,14 @@ func (c *Client) configure() error {
}
// ListModels returns the Codex models available to the configured account.
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
if c.apiHTTPClient == nil {
if err := c.configure(); err != nil {
return nil, err
}
}
ctx, cancel := context.WithTimeout(context.Background(), modelsRequestTimeout)
ctx, cancel := context.WithTimeout(ctx, modelsRequestTimeout)
defer cancel()
modelsURL := strings.TrimRight(c.ApiBaseURL.Value, "/") + "/models"
@ -252,7 +195,7 @@ func (c *Client) ListModels() ([]string, error) {
var decoded modelsResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, fmt.Errorf("failed to decode Codex models response: %w", err)
return nil, fmt.Errorf(i18n.T("codex_decode_models_response_failed"), err)
}
models := make([]string, 0, len(decoded.Models))
@ -308,7 +251,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
// SendStream sends a request to Codex and streams the response text updates.
func (c *Client) SendStream(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
) error {
defer close(channel)
@ -322,15 +265,17 @@ func (c *Client) SendStream(
}
req := c.buildCodexResponseParams(msgs, opts)
stream := c.ApiClient.Responses.NewStreaming(context.Background(), req)
stream := c.ApiClient.Responses.NewStreaming(ctx, req)
defer stream.Close()
for stream.Next() {
event := stream.Current()
switch event.Type {
case string(constant.ResponseOutputTextDelta("").Default()):
channel <- domain.StreamUpdate{
if err := sendStreamUpdate(ctx, channel, domain.StreamUpdate{
Type: domain.StreamTypeContent,
Content: event.AsResponseOutputTextDelta().Delta,
}); err != nil {
return err
}
case string(constant.ResponseOutputTextDone("").Default()):
continue
@ -338,15 +283,26 @@ func (c *Client) SendStream(
}
if stream.Err() == nil {
channel <- domain.StreamUpdate{
if err := sendStreamUpdate(ctx, channel, domain.StreamUpdate{
Type: domain.StreamTypeContent,
Content: "\n",
}); err != nil {
return err
}
}
return c.mapRequestError(stream.Err())
}
func sendStreamUpdate(ctx context.Context, channel chan domain.StreamUpdate, update domain.StreamUpdate) error {
select {
case <-ctx.Done():
return ctx.Err()
case channel <- update:
return nil
}
}
func (c *Client) buildCodexResponseParams(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions,
) responses.ResponseNewParams {
@ -404,669 +360,3 @@ func codexMessageText(msg chat.ChatCompletionMessage) string {
return strings.Join(parts, "\n")
}
func (c *Client) runOAuthFlow(
ctx context.Context,
openBrowserFn func(string) error,
) (oauthTokens, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultCallbackPort))
if err != nil {
return oauthTokens{}, fmt.Errorf("failed to start local OAuth callback server: %w", err)
}
defer listener.Close()
debuglog.Debug(debuglog.Detailed, "Codex OAuth callback listener started on 127.0.0.1:%d\n", defaultCallbackPort)
pkce, err := generatePKCECodes()
if err != nil {
return oauthTokens{}, err
}
state, err := randomBase64URL(oauthStateBytes)
if err != nil {
return oauthTokens{}, err
}
callbackURL := (&url.URL{
Scheme: "http",
Host: fmt.Sprintf("localhost:%d", defaultCallbackPort),
Path: oauthCallbackPath,
}).String()
authURL, err := buildAuthorizeURL(c.AuthBaseURL.Value, callbackURL, pkce, state)
if err != nil {
return oauthTokens{}, err
}
results := make(chan oauthResult, 1)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.handleOAuthCallback(w, r, callbackURL, pkce, state, results)
}),
}
serveDone := make(chan error, 1)
go func() {
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serveDone <- err
return
}
serveDone <- nil
}()
if err := openBrowserFn(authURL); err != nil {
fmt.Printf("If your browser did not open, navigate to this URL to authenticate:\n%s\n", authURL)
}
select {
case result := <-results:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
<-serveDone
return result.tokens, result.err
case err := <-serveDone:
if err != nil {
return oauthTokens{}, err
}
return oauthTokens{}, errors.New(i18n.T("codex_login_server_stopped"))
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
<-serveDone
return oauthTokens{}, errors.New(i18n.T("codex_login_timed_out"))
}
}
func (c *Client) handleOAuthCallback(
w http.ResponseWriter,
r *http.Request,
callbackURL string,
pkce pkceCodes,
expectedState string,
results chan<- oauthResult,
) {
if r.URL.Path != oauthCallbackPath {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "State mismatch", http.StatusBadRequest)
c.publishOAuthResult(results, oauthResult{
err: errors.New(i18n.T("codex_login_state_mismatch")),
})
return
}
if callbackError := strings.TrimSpace(r.URL.Query().Get("error")); callbackError != "" {
description := strings.TrimSpace(r.URL.Query().Get("error_description"))
if description != "" {
http.Error(w, description, http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{
err: fmt.Errorf(i18n.T("codex_login_failed"), description),
})
return
}
http.Error(w, callbackError, http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{
err: fmt.Errorf(i18n.T("codex_login_failed"), callbackError),
})
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
http.Error(w, "Missing authorization code", http.StatusBadRequest)
c.publishOAuthResult(results, oauthResult{
err: errors.New(i18n.T("codex_login_missing_auth_code")),
})
return
}
tokens, err := c.exchangeCodeForTokens(r.Context(), callbackURL, pkce, code)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
c.publishOAuthResult(results, oauthResult{err: err})
return
}
if _, err := c.extractAccountID(tokens.IDToken, tokens.AccessToken); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{err: err})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<html><body><h1>Codex login completed</h1><p>Return to Fabric.</p></body></html>"))
c.publishOAuthResult(results, oauthResult{tokens: tokens})
}
func (c *Client) publishOAuthResult(results chan<- oauthResult, result oauthResult) {
select {
case results <- result:
default:
}
}
func (c *Client) exchangeCodeForTokens(
ctx context.Context,
callbackURL string,
pkce pkceCodes,
code string,
) (oauthTokens, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callbackURL)
form.Set("client_id", oauthClientID)
form.Set("code_verifier", pkce.CodeVerifier)
tokenURL := strings.TrimRight(c.AuthBaseURL.Value, "/") + "/oauth/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return oauthTokens{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.authHTTPClient.Do(req)
if err != nil {
return oauthTokens{}, fmt.Errorf("Codex token exchange failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return oauthTokens{}, err
}
if resp.StatusCode != http.StatusOK {
return oauthTokens{}, c.errorFromHTTPResponse(resp.StatusCode, body)
}
var tokens oauthTokens
if err := json.Unmarshal(body, &tokens); err != nil {
return oauthTokens{}, fmt.Errorf("failed to decode Codex token exchange response: %w", err)
}
if strings.TrimSpace(tokens.AccessToken) == "" || strings.TrimSpace(tokens.RefreshToken) == "" {
return oauthTokens{}, errors.New(i18n.T("codex_login_missing_tokens"))
}
return tokens, nil
}
func (c *Client) ensureAccessToken(ctx context.Context, forceRefresh bool) (string, string, error) {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
accessToken := strings.TrimSpace(c.AccessToken.Value)
accountID := strings.TrimSpace(c.AccountID.Value)
if !forceRefresh && accessToken != "" && !tokenNeedsRefresh(accessToken, time.Now()) {
if accountID == "" {
parsedAccountID, err := extractAccountIDFromJWT(accessToken)
if err == nil && parsedAccountID != "" {
accountID = parsedAccountID
c.setSettingValue(c.AccountID, accountID)
}
}
if accountID != "" {
return accessToken, accountID, nil
}
}
refreshed, err := c.refreshAccessToken(ctx)
if err != nil {
return "", "", err
}
refreshedAccountID, err := c.extractAccountID(refreshed.IDToken, refreshed.AccessToken)
if err != nil {
return "", "", err
}
if accountID != "" && refreshedAccountID != "" && !strings.EqualFold(accountID, refreshedAccountID) {
return "", "", errors.New(i18n.T("codex_login_account_changed"))
}
c.setSettingValue(c.AccessToken, refreshed.AccessToken)
if strings.TrimSpace(refreshed.RefreshToken) != "" {
c.setSettingValue(c.RefreshToken, refreshed.RefreshToken)
}
c.setSettingValue(c.AccountID, refreshedAccountID)
debuglog.Debug(debuglog.Detailed, "Codex access token refreshed for account=%s\n", refreshedAccountID)
return c.AccessToken.Value, c.AccountID.Value, nil
}
func (c *Client) refreshAccessToken(ctx context.Context) (oauthTokens, error) {
payload := refreshRequest{
ClientID: oauthClientID,
GrantType: "refresh_token",
RefreshToken: strings.TrimSpace(c.RefreshToken.Value),
}
body, err := json.Marshal(payload)
if err != nil {
return oauthTokens{}, err
}
tokenURL := strings.TrimRight(c.AuthBaseURL.Value, "/") + "/oauth/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
if err != nil {
return oauthTokens{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.authHTTPClient.Do(req)
if err != nil {
return oauthTokens{}, fmt.Errorf("failed to refresh Codex login: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return oauthTokens{}, err
}
if resp.StatusCode != http.StatusOK {
return oauthTokens{}, c.refreshErrorFromResponse(resp.StatusCode, responseBody)
}
var refreshed refreshResponse
if err := json.Unmarshal(responseBody, &refreshed); err != nil {
return oauthTokens{}, fmt.Errorf("failed to decode refreshed Codex token response: %w", err)
}
if strings.TrimSpace(refreshed.AccessToken) == "" {
return oauthTokens{}, errors.New(i18n.T("codex_token_refresh_missing_access_token"))
}
return oauthTokens{
IDToken: strings.TrimSpace(refreshed.IDToken),
AccessToken: strings.TrimSpace(refreshed.AccessToken),
RefreshToken: strings.TrimSpace(refreshed.RefreshToken),
}, nil
}
func (c *Client) extractAccountID(idToken string, accessToken string) (string, error) {
if accountID, err := extractAccountIDFromJWT(idToken); err == nil && accountID != "" {
return accountID, nil
}
if accountID, err := extractAccountIDFromJWT(accessToken); err == nil && accountID != "" {
return accountID, nil
}
return "", errors.New(i18n.T("codex_login_missing_account_claim"))
}
func (c *Client) setSettingValue(setting *plugins.Setting, value string) {
setting.Value = value
if setting.EnvVariable != "" {
_ = os.Setenv(setting.EnvVariable, value)
}
}
func (c *Client) errorFromHTTPResponse(statusCode int, body []byte) error {
message := extractErrorMessage(body)
if statusCode == http.StatusUnauthorized {
return errors.New(i18n.T("codex_login_invalid"))
}
if isUsageLimitMessage(message) {
return errors.New(message)
}
if message == "" {
message = fmt.Sprintf("Codex request failed with status %d", statusCode)
}
return errors.New(message)
}
func (c *Client) refreshErrorFromResponse(statusCode int, body []byte) error {
message := extractErrorMessage(body)
code := strings.ToLower(extractErrorCode(body))
if statusCode == http.StatusUnauthorized {
switch code {
case "refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated":
return errors.New(i18n.T("codex_login_revoked"))
default:
return errors.New(i18n.T("codex_login_refresh_failed"))
}
}
if message == "" {
message = fmt.Sprintf("failed to refresh Codex login (status %d)", statusCode)
}
return errors.New(message)
}
func (c *Client) mapRequestError(err error) error {
if err == nil {
return nil
}
var apiErr *openaiapi.Error
if errors.As(err, &apiErr) {
body := []byte(apiErr.RawJSON())
if len(body) == 0 {
body = readAPIErrorBody(apiErr)
}
return c.errorFromHTTPResponse(apiErr.StatusCode, body)
}
message := err.Error()
lower := strings.ToLower(message)
switch {
case strings.Contains(lower, "status code 401"),
strings.Contains(lower, "401 unauthorized"),
strings.Contains(lower, "refresh token"),
strings.Contains(lower, "chatgpt login"):
return errors.New(i18n.T("codex_login_invalid"))
case isUsageLimitMessage(message):
return errors.New(message)
default:
return err
}
}
func readAPIErrorBody(apiErr *openaiapi.Error) []byte {
if apiErr == nil || apiErr.Response == nil || apiErr.Response.Body == nil {
return nil
}
body, err := io.ReadAll(apiErr.Response.Body)
if err != nil {
return nil
}
apiErr.Response.Body = io.NopCloser(strings.NewReader(string(body)))
return body
}
// RoundTrip adds Codex authentication headers and retries once after a 401.
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.roundTrip(req, false)
}
func (t *authTransport) roundTrip(req *http.Request, retried bool) (*http.Response, error) {
token, accountID, err := t.client.ensureAccessToken(req.Context(), false)
if err != nil {
return nil, err
}
clone, err := cloneRequest(req)
if err != nil {
return nil, err
}
clone.Header.Set(http.CanonicalHeaderKey("originator"), defaultOriginator)
clone.Header.Set("User-Agent", defaultUserAgent)
clone.Header.Set("Authorization", "Bearer "+token)
clone.Header.Set("ChatGPT-Account-ID", accountID)
resp, err := t.roundTripper().RoundTrip(clone)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized || retried {
return resp, nil
}
drainAndClose(resp.Body)
debuglog.Debug(debuglog.Detailed, "Codex request returned 401; attempting token refresh and one retry\n")
if _, _, err := t.client.ensureAccessToken(req.Context(), true); err != nil {
return nil, err
}
return t.roundTrip(req, true)
}
func (t *authTransport) roundTripper() http.RoundTripper {
if t.wrapped != nil {
return t.wrapped
}
return http.DefaultTransport
}
func cloneRequest(req *http.Request) (*http.Request, error) {
clone := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody {
return clone, nil
}
// Codex retry logic assumes GetBody is available so the request can be replayed after refresh.
if req.GetBody == nil {
return nil, errReplayBodyUnavailable
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
clone.Body = body
return clone, nil
}
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, defaultRoundTripLimit))
_ = body.Close()
}
func buildAuthorizeURL(authBaseURL string, callbackURL string, pkce pkceCodes, state string) (string, error) {
issuer, err := url.Parse(strings.TrimRight(authBaseURL, "/"))
if err != nil {
return "", fmt.Errorf("invalid Codex auth base URL: %w", err)
}
issuer.Path = strings.TrimRight(issuer.Path, "/") + "/oauth/authorize"
query := issuer.Query()
query.Set("response_type", "code")
query.Set("client_id", oauthClientID)
query.Set("redirect_uri", callbackURL)
query.Set("scope", oauthScope)
query.Set("code_challenge", pkce.CodeChallenge)
query.Set("code_challenge_method", "S256")
query.Set("id_token_add_organizations", "true")
query.Set("codex_cli_simplified_flow", "true")
query.Set("state", state)
query.Set("originator", defaultOriginator)
issuer.RawQuery = query.Encode()
return issuer.String(), nil
}
type pkceCodes struct {
CodeVerifier string
CodeChallenge string
}
func generatePKCECodes() (pkceCodes, error) {
verifier, err := randomBase64URL(oauthVerifierBytes)
if err != nil {
return pkceCodes{}, err
}
sum := sha256.Sum256([]byte(verifier))
return pkceCodes{
CodeVerifier: verifier,
CodeChallenge: base64.RawURLEncoding.EncodeToString(sum[:]),
}, nil
}
func randomBase64URL(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("failed to generate secure random OAuth state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func tokenNeedsRefresh(jwt string, now time.Time) bool {
expiry, err := extractExpiryFromJWT(jwt)
if err != nil {
return true
}
return now.Add(tokenRefreshLeeway).After(expiry)
}
func extractExpiryFromJWT(jwt string) (time.Time, error) {
claims, err := parseTokenClaims(jwt)
if err != nil {
return time.Time{}, err
}
if claims.Exp == 0 {
return time.Time{}, errors.New("JWT did not include an exp claim")
}
return time.Unix(claims.Exp, 0), nil
}
func extractAccountIDFromJWT(jwt string) (string, error) {
claims, err := parseTokenClaims(jwt)
if err != nil {
return "", err
}
return strings.TrimSpace(claims.Auth.ChatGPTAccountID), nil
}
func parseTokenClaims(jwt string) (tokenClaims, error) {
parts := strings.Split(jwt, ".")
if len(parts) < 2 {
return tokenClaims{}, errors.New("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return tokenClaims{}, err
}
var claims tokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return tokenClaims{}, err
}
return claims, nil
}
func extractErrorMessage(body []byte) string {
if len(body) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return strings.TrimSpace(string(body))
}
if errorValue, ok := payload["error"]; ok {
switch typed := errorValue.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if message, ok := typed["message"].(string); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
if code, ok := typed["code"].(string); ok && strings.TrimSpace(code) != "" {
return strings.TrimSpace(code)
}
}
}
if message, ok := payload["message"].(string); ok {
return strings.TrimSpace(message)
}
if detail, ok := payload["detail"].(string); ok {
return strings.TrimSpace(detail)
}
return strings.TrimSpace(string(body))
}
func extractErrorCode(body []byte) string {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
if code, ok := payload["code"].(string); ok {
return strings.TrimSpace(code)
}
errorValue, ok := payload["error"]
if !ok {
return ""
}
switch typed := errorValue.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if code, ok := typed["code"].(string); ok {
return strings.TrimSpace(code)
}
}
return ""
}
func codexClientVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
if version := normalizeSemverLikeVersion(info.Main.Version); version != "" {
return version
}
}
return defaultClientVersion
}
func normalizeSemverLikeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
if version == "" || version == "(devel)" {
return ""
}
end := len(version)
for i, r := range version {
if (r < '0' || r > '9') && r != '.' {
end = i
break
}
}
version = strings.Trim(version[:end], ".")
if version == "" {
return ""
}
parts := strings.Split(version, ".")
if len(parts) < 3 {
return ""
}
if slices.Contains(parts[:3], "") {
return ""
}
return strings.Join(parts[:3], ".")
}
func isUsageLimitMessage(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
if lower == "" {
return false
}
return strings.Contains(lower, "usage limit") ||
strings.Contains(lower, "purchase more credits") ||
strings.Contains(lower, "upgrade to plus") ||
strings.Contains(lower, "upgrade to pro") ||
strings.Contains(lower, "plan and billing")
}
func openBrowser(targetURL string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", targetURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL)
default:
cmd = exec.Command("xdg-open", targetURL)
}
return cmd.Start()
}

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@ -176,7 +177,7 @@ func TestListModelsFiltersSupportedVisibleModels(t *testing.T) {
client := newConfiguredTestClient(t, modelsServer.URL, "acct_models", testJWT("acct_models", time.Now().Add(time.Hour)))
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
@ -220,8 +221,11 @@ func TestMapRequestErrorPreservesCodexAPIErrorMessage(t *testing.T) {
if err == nil {
t.Fatal("mapRequestError() returned nil")
}
if got := err.Error(); got != "The requested model is not supported." {
t.Fatalf("mapRequestError() = %q, want %q", got, "The requested model is not supported.")
if got := err.Error(); got != "codex request failed with status 400" {
t.Fatalf("mapRequestError() = %q, want %q", got, "codex request failed with status 400")
}
if unwrapped := errors.Unwrap(err); unwrapped == nil || !strings.Contains(unwrapped.Error(), "The requested model is not supported.") {
t.Fatalf("wrapped error = %v, want provider detail", unwrapped)
}
}
@ -238,8 +242,11 @@ func TestMapRequestErrorReadsAPIErrorResponseBodyWhenRawJSONMissing(t *testing.T
if err == nil {
t.Fatal("mapRequestError() returned nil")
}
if got := err.Error(); got != "The requested model is not supported for Codex." {
t.Fatalf("mapRequestError() = %q, want %q", got, "The requested model is not supported for Codex.")
if got := err.Error(); got != "codex request failed with status 400" {
t.Fatalf("mapRequestError() = %q, want %q", got, "codex request failed with status 400")
}
if unwrapped := errors.Unwrap(err); unwrapped == nil || !strings.Contains(unwrapped.Error(), "The requested model is not supported for Codex.") {
t.Fatalf("wrapped error = %v, want provider detail", unwrapped)
}
}
@ -469,7 +476,7 @@ func TestSendStreamReadsCodexSSE(t *testing.T) {
client := newConfiguredTestClient(t, apiServer.URL, "acct_stream", testJWT("acct_stream", time.Now().Add(time.Hour)))
updates := make(chan domain.StreamUpdate, 8)
err := client.SendStream([]*chat.ChatCompletionMessage{
err := client.SendStream(context.Background(), []*chat.ChatCompletionMessage{
{Role: chat.ChatMessageRoleSystem, Content: "Follow the system prompt"},
{Role: "user", Content: "Hello"},
}, &domain.ChatOptions{
@ -492,6 +499,37 @@ func TestSendStreamReadsCodexSSE(t *testing.T) {
}
}
func TestSendStreamClosesChannelAndMapsHTTPError(t *testing.T) {
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
http.NotFound(w, r)
return
}
http.Error(w, `{"error":{"message":"usage limit reached"}}`, http.StatusTooManyRequests)
}))
defer apiServer.Close()
client := newConfiguredTestClient(t, apiServer.URL, "acct_stream_error", testJWT("acct_stream_error", time.Now().Add(time.Hour)))
updates := make(chan domain.StreamUpdate, 1)
err := client.SendStream(context.Background(), []*chat.ChatCompletionMessage{
{Role: chat.ChatMessageRoleUser, Content: "Hello"},
}, &domain.ChatOptions{
Model: "gpt-5.4",
}, updates)
if err == nil {
t.Fatal("SendStream() error = nil, want mapped HTTP error")
}
if got := err.Error(); got != "codex usage limit reached" {
t.Fatalf("SendStream() error = %q, want %q", got, "codex usage limit reached")
}
update, ok := <-updates
if ok {
t.Fatalf("expected closed channel after stream error, got update %#v", update)
}
}
func newConfiguredTestClient(t *testing.T, apiBaseURL string, accountID string, accessToken string) *Client {
t.Helper()

View file

@ -0,0 +1,184 @@
package codex
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/danielmiessler/fabric/internal/i18n"
openaiapi "github.com/openai/openai-go"
)
type publicError struct {
message string
cause error
}
func (e *publicError) Error() string {
return e.message
}
func (e *publicError) Unwrap() error {
return e.cause
}
func (c *Client) errorFromHTTPResponse(statusCode int, body []byte) error {
message := extractErrorMessage(body)
if statusCode == http.StatusUnauthorized {
return errors.New(i18n.T("codex_login_invalid"))
}
if isUsageLimitMessage(message) {
return wrapPublicError(i18n.T("codex_usage_limit_reached"), statusCode, message)
}
return wrapPublicError(fmt.Sprintf(i18n.T("codex_request_failed_status"), statusCode), statusCode, message)
}
func (c *Client) refreshErrorFromResponse(statusCode int, body []byte) error {
message := extractErrorMessage(body)
code := strings.ToLower(extractErrorCode(body))
if statusCode == http.StatusUnauthorized {
switch code {
case "refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated":
return errors.New(i18n.T("codex_login_revoked"))
default:
return errors.New(i18n.T("codex_login_refresh_failed"))
}
}
return wrapPublicError(fmt.Sprintf(i18n.T("codex_refresh_failed_status"), statusCode), statusCode, message)
}
func (c *Client) mapRequestError(err error) error {
if err == nil {
return nil
}
var apiErr *openaiapi.Error
if errors.As(err, &apiErr) {
body := []byte(apiErr.RawJSON())
if len(body) == 0 {
body = readAPIErrorBody(apiErr)
}
return c.errorFromHTTPResponse(apiErr.StatusCode, body)
}
message := err.Error()
lower := strings.ToLower(message)
switch {
case strings.Contains(lower, "status code 401"),
strings.Contains(lower, "401 unauthorized"),
strings.Contains(lower, "refresh token"),
strings.Contains(lower, "chatgpt login"):
return errors.New(i18n.T("codex_login_invalid"))
case isUsageLimitMessage(message):
return &publicError{
message: i18n.T("codex_usage_limit_reached"),
cause: fmt.Errorf("codex request failed: %w", err),
}
default:
return err
}
}
func wrapPublicError(message string, statusCode int, providerMessage string) error {
if providerMessage == "" {
return errors.New(message)
}
return &publicError{
message: message,
cause: fmt.Errorf("codex provider error (status %d): %s", statusCode, providerMessage),
}
}
func readAPIErrorBody(apiErr *openaiapi.Error) []byte {
if apiErr == nil || apiErr.Response == nil || apiErr.Response.Body == nil {
return nil
}
body, err := io.ReadAll(apiErr.Response.Body)
if err != nil {
return nil
}
apiErr.Response.Body = io.NopCloser(strings.NewReader(string(body)))
return body
}
func extractErrorMessage(body []byte) string {
if len(body) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return strings.TrimSpace(string(body))
}
if errorValue, ok := payload["error"]; ok {
switch typed := errorValue.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if message, ok := typed["message"].(string); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
if code, ok := typed["code"].(string); ok && strings.TrimSpace(code) != "" {
return strings.TrimSpace(code)
}
}
}
if message, ok := payload["message"].(string); ok {
return strings.TrimSpace(message)
}
if detail, ok := payload["detail"].(string); ok {
return strings.TrimSpace(detail)
}
return strings.TrimSpace(string(body))
}
func extractErrorCode(body []byte) string {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
if code, ok := payload["code"].(string); ok {
return strings.TrimSpace(code)
}
errorValue, ok := payload["error"]
if !ok {
return ""
}
switch typed := errorValue.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if code, ok := typed["code"].(string); ok {
return strings.TrimSpace(code)
}
}
return ""
}
func isUsageLimitMessage(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
if lower == "" {
return false
}
return strings.Contains(lower, "usage limit") ||
strings.Contains(lower, "purchase more credits") ||
strings.Contains(lower, "upgrade to plus") ||
strings.Contains(lower, "upgrade to pro") ||
strings.Contains(lower, "plan and billing")
}

View file

@ -0,0 +1,304 @@
package codex
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
)
type oauthTokens struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type refreshRequest struct {
ClientID string `json:"client_id"`
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
}
type refreshResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type oauthResult struct {
tokens oauthTokens
err error
}
type pkceCodes struct {
CodeVerifier string
CodeChallenge string
}
func (c *Client) runOAuthFlow(
ctx context.Context,
openBrowserFn func(string) error,
) (oauthTokens, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultCallbackPort))
if err != nil {
return oauthTokens{}, fmt.Errorf(i18n.T("codex_oauth_server_start_failed"), err)
}
defer listener.Close()
debuglog.Debug(debuglog.Detailed, "Codex OAuth callback listener started on 127.0.0.1:%d\n", defaultCallbackPort)
pkce, err := generatePKCECodes()
if err != nil {
return oauthTokens{}, err
}
state, err := randomBase64URL(oauthStateBytes)
if err != nil {
return oauthTokens{}, err
}
callbackURL := (&url.URL{
Scheme: "http",
Host: fmt.Sprintf("localhost:%d", defaultCallbackPort),
Path: oauthCallbackPath,
}).String()
authURL, err := buildAuthorizeURL(c.AuthBaseURL.Value, callbackURL, pkce, state)
if err != nil {
return oauthTokens{}, err
}
results := make(chan oauthResult, 1)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.handleOAuthCallback(w, r, callbackURL, pkce, state, results)
}),
}
serveDone := make(chan error, 1)
go func() {
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serveDone <- err
return
}
serveDone <- nil
}()
if err := openBrowserFn(authURL); err != nil {
fmt.Printf("%s\n%s\n", i18n.T("codex_browser_open_fallback"), authURL)
}
select {
case result := <-results:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
<-serveDone
return result.tokens, result.err
case err := <-serveDone:
if err != nil {
return oauthTokens{}, err
}
return oauthTokens{}, errors.New(i18n.T("codex_login_server_stopped"))
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
<-serveDone
return oauthTokens{}, errors.New(i18n.T("codex_login_timed_out"))
}
}
func (c *Client) handleOAuthCallback(
w http.ResponseWriter,
r *http.Request,
callbackURL string,
pkce pkceCodes,
expectedState string,
results chan<- oauthResult,
) {
if r.URL.Path != oauthCallbackPath {
http.NotFound(w, r)
return
}
if !oauthStatesMatch(expectedState, r.URL.Query().Get("state")) {
http.Error(w, i18n.T("codex_oauth_state_mismatch"), http.StatusBadRequest)
c.publishOAuthResult(results, oauthResult{
err: errors.New(i18n.T("codex_login_state_mismatch")),
})
return
}
if callbackError := strings.TrimSpace(r.URL.Query().Get("error")); callbackError != "" {
description := strings.TrimSpace(r.URL.Query().Get("error_description"))
if description != "" {
http.Error(w, description, http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{
err: fmt.Errorf(i18n.T("codex_login_failed"), description),
})
return
}
http.Error(w, callbackError, http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{
err: fmt.Errorf(i18n.T("codex_login_failed"), callbackError),
})
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
http.Error(w, i18n.T("codex_oauth_missing_auth_code"), http.StatusBadRequest)
c.publishOAuthResult(results, oauthResult{
err: errors.New(i18n.T("codex_login_missing_auth_code")),
})
return
}
tokens, err := c.exchangeCodeForTokens(r.Context(), callbackURL, pkce, code)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
c.publishOAuthResult(results, oauthResult{err: err})
return
}
if _, err := c.extractAccountID(tokens.IDToken, tokens.AccessToken); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
c.publishOAuthResult(results, oauthResult{err: err})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<html><body><h1>" + i18n.T("codex_login_completed") + "</h1><p>" + i18n.T("codex_login_return_to_fabric") + "</p></body></html>"))
c.publishOAuthResult(results, oauthResult{tokens: tokens})
}
func (c *Client) publishOAuthResult(results chan<- oauthResult, result oauthResult) {
select {
case results <- result:
default:
}
}
func (c *Client) exchangeCodeForTokens(
ctx context.Context,
callbackURL string,
pkce pkceCodes,
code string,
) (oauthTokens, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callbackURL)
form.Set("client_id", oauthClientID)
form.Set("code_verifier", pkce.CodeVerifier)
tokenURL := strings.TrimRight(c.AuthBaseURL.Value, "/") + "/oauth/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return oauthTokens{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.authHTTPClient.Do(req)
if err != nil {
return oauthTokens{}, fmt.Errorf(i18n.T("codex_token_exchange_failed"), err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return oauthTokens{}, err
}
if resp.StatusCode != http.StatusOK {
return oauthTokens{}, c.errorFromHTTPResponse(resp.StatusCode, body)
}
var tokens oauthTokens
if err := json.Unmarshal(body, &tokens); err != nil {
return oauthTokens{}, fmt.Errorf(i18n.T("codex_decode_token_response_failed"), err)
}
if strings.TrimSpace(tokens.AccessToken) == "" || strings.TrimSpace(tokens.RefreshToken) == "" {
return oauthTokens{}, errors.New(i18n.T("codex_login_missing_tokens"))
}
return tokens, nil
}
func buildAuthorizeURL(authBaseURL string, callbackURL string, pkce pkceCodes, state string) (string, error) {
issuer, err := url.Parse(strings.TrimRight(authBaseURL, "/"))
if err != nil {
return "", fmt.Errorf(i18n.T("codex_auth_base_url_invalid"), err)
}
issuer.Path = strings.TrimRight(issuer.Path, "/") + "/oauth/authorize"
query := issuer.Query()
query.Set("response_type", "code")
query.Set("client_id", oauthClientID)
query.Set("redirect_uri", callbackURL)
query.Set("scope", oauthScope)
query.Set("code_challenge", pkce.CodeChallenge)
query.Set("code_challenge_method", "S256")
query.Set("id_token_add_organizations", "true")
query.Set("codex_cli_simplified_flow", "true")
query.Set("state", state)
query.Set("originator", defaultOriginator)
issuer.RawQuery = query.Encode()
return issuer.String(), nil
}
func generatePKCECodes() (pkceCodes, error) {
verifier, err := randomBase64URL(oauthVerifierBytes)
if err != nil {
return pkceCodes{}, err
}
sum := sha256.Sum256([]byte(verifier))
return pkceCodes{
CodeVerifier: verifier,
CodeChallenge: base64.RawURLEncoding.EncodeToString(sum[:]),
}, nil
}
func randomBase64URL(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf(i18n.T("codex_oauth_random_state_failed"), err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func oauthStatesMatch(expected string, actual string) bool {
if len(expected) != len(actual) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func openBrowser(targetURL string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", targetURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL)
default:
cmd = exec.Command("xdg-open", targetURL)
}
return cmd.Start()
}

View file

@ -0,0 +1,115 @@
package codex
import (
"encoding/base64"
"encoding/json"
"errors"
"runtime/debug"
"slices"
"strings"
"time"
)
type tokenClaims struct {
Exp int64 `json:"exp"`
Auth tokenAuthClaims `json:"https://api.openai.com/auth"`
Profile tokenProfile `json:"https://api.openai.com/profile"`
Email string `json:"email"`
}
type tokenAuthClaims struct {
ChatGPTAccountID string `json:"chatgpt_account_id"`
ChatGPTPlanType string `json:"chatgpt_plan_type"`
UserID string `json:"user_id"`
ChatGPTUserID string `json:"chatgpt_user_id"`
}
type tokenProfile struct {
Email string `json:"email"`
}
func tokenNeedsRefresh(jwt string, now time.Time) bool {
expiry, err := extractExpiryFromJWT(jwt)
if err != nil {
return true
}
return now.Add(tokenRefreshLeeway).After(expiry)
}
func extractExpiryFromJWT(jwt string) (time.Time, error) {
claims, err := parseTokenClaims(jwt)
if err != nil {
return time.Time{}, err
}
if claims.Exp == 0 {
return time.Time{}, errors.New("jwt did not include an exp claim")
}
return time.Unix(claims.Exp, 0), nil
}
func extractAccountIDFromJWT(jwt string) (string, error) {
claims, err := parseTokenClaims(jwt)
if err != nil {
return "", err
}
return strings.TrimSpace(claims.Auth.ChatGPTAccountID), nil
}
func parseTokenClaims(jwt string) (tokenClaims, error) {
parts := strings.Split(jwt, ".")
if len(parts) < 2 {
return tokenClaims{}, errors.New("invalid jwt format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return tokenClaims{}, err
}
var claims tokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return tokenClaims{}, err
}
return claims, nil
}
func codexClientVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
if version := normalizeSemverLikeVersion(info.Main.Version); version != "" {
return version
}
}
return defaultClientVersion
}
func normalizeSemverLikeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
if version == "" || version == "(devel)" {
return ""
}
end := len(version)
for i, r := range version {
if (r < '0' || r > '9') && r != '.' {
end = i
break
}
}
version = strings.Trim(version[:end], ".")
if version == "" {
return ""
}
parts := strings.Split(version, ".")
if len(parts) < 3 {
return ""
}
if slices.Contains(parts[:3], "") {
return ""
}
return strings.Join(parts[:3], ".")
}

View file

@ -159,7 +159,7 @@ func (c *Client) IsConfigured() bool {
// ListModels returns the available models.
// Microsoft 365 Copilot exposes a single model - the Copilot service itself.
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(_ context.Context) ([]string, error) {
// Copilot doesn't expose multiple models - it's a unified service
// We expose it as a single "model" for consistency with Fabric's architecture
return []string{copilotModelName}, nil
@ -186,7 +186,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
}
// SendStream sends a message to Copilot and streams the response.
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
func (c *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
defer close(channel)
ctx := context.Background()

View file

@ -52,9 +52,9 @@ func NewClient() *Client {
return client
}
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
if c.ControlPlaneToken.Value == "" {
models, err := c.Client.ListModels()
models, err := c.Client.ListModels(ctx)
if err == nil && len(models) > 0 {
return models, nil
}

View file

@ -22,7 +22,7 @@ func NewClient() *Client {
return &Client{PluginBase: &plugins.PluginBase{Name: "DryRun"}}
}
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(_ context.Context) ([]string, error) {
return []string{"dry-run-model"}, nil
}
@ -108,7 +108,7 @@ func (c *Client) constructRequest(msgs []*chat.ChatCompletionMessage, opts *doma
return builder.String()
}
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
func (c *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
defer close(channel)
request := c.constructRequest(msgs, opts)
channel <- domain.StreamUpdate{

View file

@ -1,6 +1,7 @@
package dryrun
import (
"context"
"reflect"
"testing"
@ -11,7 +12,7 @@ import (
// Test generated using Keploy
func TestListModels_ReturnsExpectedModel(t *testing.T) {
client := NewClient()
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@ -41,7 +42,7 @@ func TestSendStream_SendsMessages(t *testing.T) {
}
channel := make(chan domain.StreamUpdate)
go func() {
err := client.SendStream(msgs, opts, channel)
err := client.SendStream(context.Background(), msgs, opts, channel)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

View file

@ -1,6 +1,7 @@
package exolab
import (
"context"
"strings"
"github.com/danielmiessler/fabric/internal/plugins"
@ -42,7 +43,7 @@ func (oi *Client) configure() (err error) {
return
}
func (oi *Client) ListModels() (ret []string, err error) {
func (oi *Client) ListModels(context.Context) (ret []string, err error) {
ret = oi.apiModels
return
}

View file

@ -60,7 +60,7 @@ type Client struct {
ApiKey *plugins.SetupQuestion
}
func (o *Client) ListModels() (ret []string, err error) {
func (o *Client) ListModels(_ context.Context) (ret []string, err error) {
ctx := context.Background()
var client *genai.Client
if client, err = genai.NewClient(ctx, &genai.ClientConfig{
@ -124,7 +124,7 @@ func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
return
}
func (o *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
func (o *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
ctx := context.Background()
defer close(channel)

View file

@ -52,7 +52,7 @@ func (c *Client) configure() error {
}
// ListModels returns a list of available models.
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(_ context.Context) ([]string, error) {
url := fmt.Sprintf("%s/models", c.ApiUrl.Value)
req, err := http.NewRequest("GET", url, nil)
@ -89,7 +89,7 @@ func (c *Client) ListModels() ([]string, error) {
return models, nil
}
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
func (c *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
url := fmt.Sprintf("%s/chat/completions", c.ApiUrl.Value)
payload := map[string]any{

View file

@ -27,7 +27,7 @@ func TestListModelsUsesBearerTokenWhenConfigured(t *testing.T) {
client.ApiKey.Value = "secret"
client.HttpClient = server.Client()
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"model-1"}, models)
}
@ -86,7 +86,7 @@ func TestListModelsDoesNotSendBearerForWhitespaceOnlyKey(t *testing.T) {
client.ApiKey.Value = " "
client.HttpClient = server.Client()
models, err := client.ListModels()
models, err := client.ListModels(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"model-1"}, models)
}

View file

@ -90,7 +90,7 @@ func (o *Client) configure() (err error) {
return
}
func (o *Client) ListModels() (ret []string, err error) {
func (o *Client) ListModels(_ context.Context) (ret []string, err error) {
ctx := context.Background()
var listResp *ollamaapi.ListResponse
@ -104,7 +104,7 @@ func (o *Client) ListModels() (ret []string, err error) {
return
}
func (o *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
func (o *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) (err error) {
ctx := context.Background()
var req ollamaapi.ChatRequest

View file

@ -30,7 +30,7 @@ func (o *Client) sendChatCompletions(ctx context.Context, msgs []*chat.ChatCompl
// sendStreamChatCompletions sends a streaming request using the Chat Completions API
func (o *Client) sendStreamChatCompletions(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
) (err error) {
defer close(channel)
@ -39,7 +39,7 @@ func (o *Client) sendStreamChatCompletions(
req.StreamOptions = openai.ChatCompletionStreamOptionsParam{
IncludeUsage: openai.Bool(true),
}
stream := o.ApiClient.Chat.Completions.NewStreaming(context.Background(), req)
stream := o.ApiClient.Chat.Completions.NewStreaming(ctx, req)
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {

View file

@ -96,9 +96,9 @@ func (o *Client) configure() (ret error) {
return
}
func (o *Client) ListModels() (ret []string, err error) {
func (o *Client) ListModels(ctx context.Context) (ret []string, err error) {
var page *pagination.Page[openai.Model]
if page, err = o.ApiClient.Models.List(context.Background()); err == nil {
if page, err = o.ApiClient.Models.List(ctx); err == nil {
for _, mod := range page.Data {
ret = append(ret, mod.ID)
}
@ -110,26 +110,26 @@ func (o *Client) ListModels() (ret []string, err error) {
// Some providers (e.g., GitHub Models) return non-standard response formats
// that the SDK fails to parse.
debuglog.Debug(debuglog.Basic, "SDK Models.List failed for %s: %v, falling back to direct API fetch\n", o.GetName(), err)
return FetchModelsDirectly(context.Background(), o.ApiBaseURL.Value, o.ApiKey.Value, o.GetName(), o.httpClient)
return FetchModelsDirectly(ctx, o.ApiBaseURL.Value, o.ApiKey.Value, o.GetName(), o.httpClient)
}
func (o *Client) SendStream(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
) (err error) {
// Use Responses API for OpenAI, Chat Completions API for other providers
if o.supportsResponsesAPI() {
return o.sendStreamResponses(msgs, opts, channel)
return o.sendStreamResponses(ctx, msgs, opts, channel)
}
return o.sendStreamChatCompletions(msgs, opts, channel)
return o.sendStreamChatCompletions(ctx, msgs, opts, channel)
}
func (o *Client) sendStreamResponses(
msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate,
) (err error) {
defer close(channel)
req := o.buildResponseParams(msgs, opts)
stream := o.ApiClient.Responses.NewStreaming(context.Background(), req)
stream := o.ApiClient.Responses.NewStreaming(ctx, req)
for stream.Next() {
event := stream.Current()
switch event.Type {

View file

@ -44,7 +44,7 @@ func NewClient(providerConfig ProviderConfig) *Client {
}
// ListModels overrides the default ListModels to handle different response formats
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
// If a custom models URL is provided, handle it
if c.modelsURL != "" {
if c.modelsURL == "static:abacus" {
@ -65,13 +65,13 @@ func (c *Client) ListModels() ([]string, error) {
}
// First try the standard OpenAI SDK approach
models, err := c.Client.ListModels()
models, err := c.Client.ListModels(ctx)
if err == nil && len(models) > 0 { // only return if OpenAI SDK returns models
return models, nil
}
// Fall back to direct API fetch
return c.DirectlyGetModels(context.Background())
return c.DirectlyGetModels(ctx)
}
func (c *Client) fetchAbacusModels() ([]string, error) {

View file

@ -53,7 +53,7 @@ func (c *Client) Configure() error {
return nil
}
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(_ context.Context) ([]string, error) {
// Perplexity API does not have a ListModels endpoint.
// We return a predefined list.
return models, nil
@ -119,7 +119,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
return content.String(), nil
}
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
func (c *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
if c.client == nil {
if err := c.Configure(); err != nil {
close(channel) // Ensure channel is closed on error

View file

@ -11,8 +11,8 @@ import (
type Vendor interface {
plugins.Plugin
ListModels() ([]string, error)
SendStream([]*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error
ListModels(context.Context) ([]string, error)
SendStream(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error
Send(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions) (string, error)
NeedsRawMode(modelName string) bool
}

View file

@ -117,7 +117,7 @@ func (o *VendorsManager) fetchVendorModels(
defer wg.Done()
models, err := vendor.ListModels()
models, err := vendor.ListModels(ctx)
select {
case <-ctx.Done():
// Context canceled, don't send the result

View file

@ -13,14 +13,14 @@ type stubVendor struct {
name string
}
func (v *stubVendor) GetName() string { return v.name }
func (v *stubVendor) GetSetupDescription() string { return "" }
func (v *stubVendor) IsConfigured() bool { return true }
func (v *stubVendor) Configure() error { return nil }
func (v *stubVendor) Setup() error { return nil }
func (v *stubVendor) SetupFillEnvFileContent(*bytes.Buffer) {}
func (v *stubVendor) ListModels() ([]string, error) { return nil, nil }
func (v *stubVendor) SendStream([]*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error {
func (v *stubVendor) GetName() string { return v.name }
func (v *stubVendor) GetSetupDescription() string { return "" }
func (v *stubVendor) IsConfigured() bool { return true }
func (v *stubVendor) Configure() error { return nil }
func (v *stubVendor) Setup() error { return nil }
func (v *stubVendor) SetupFillEnvFileContent(*bytes.Buffer) {}
func (v *stubVendor) ListModels(context.Context) ([]string, error) { return nil, nil }
func (v *stubVendor) SendStream(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions, chan domain.StreamUpdate) error {
return nil
}
func (v *stubVendor) Send(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions) (string, error) {

View file

@ -61,7 +61,7 @@ func (c *Client) configure() error {
return nil
}
func (c *Client) ListModels() ([]string, error) {
func (c *Client) ListModels(_ context.Context) ([]string, error) {
ctx := context.Background()
// Get ADC credentials for API authentication
@ -179,7 +179,7 @@ func (c *Client) sendClaude(ctx context.Context, msgs []*chat.ChatCompletionMess
return strings.Join(textParts, ""), nil
}
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
func (c *Client) SendStream(_ context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
if isGeminiModel(opts.Model) {
return c.sendStreamGemini(msgs, opts, channel)
}

View file

@ -132,7 +132,7 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
Quiet: true,
}
_, err = chatter.Send(chatReq, opts)
_, err = chatter.Send(c.Request.Context(), chatReq, opts)
if err != nil {
log.Printf("Error from chatter.Send: %v", err)
// Error already sent to streamChan via domain.StreamTypeError if occurred in Send loop

View file

@ -1 +1 @@
"1.4.441"
"1.4.442"