feat: add GitHub Models provider and refactor model fetching with direct API fallback

- Add GitHub Models to supported OpenAI-compatible providers list
- Implement direct HTTP fallback for non-standard model responses
- Centralize model fetching logic in openai package
- Upgrade openai-go SDK dependency from v1.8.2 to v1.12.0
- Remove redundant model fetching code from openai_compatible package
- Add comprehensive GitHub Models setup documentation (700+ lines)
- Support custom models URL endpoint per provider configuration
- Add unit tests for direct model fetching functionality
- Update internationalization strings for model fetching errors
- Add VSCode dictionary entries for "azureml" and "Jamba"
This commit is contained in:
Kayvan Sylvan
2025-11-22 16:46:25 +07:00
parent 67778a6159
commit 3c728cfacb
21 changed files with 989 additions and 110 deletions

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "leere Sekunden-Zeichenfolge",
"youtube_invalid_seconds_format": "ungültiges Sekundenformat %q: %w",
"error_fetching_playlist_videos": "Fehler beim Abrufen der Playlist-Videos: %w",
"openai_api_base_url_not_configured": "API-Basis-URL für Anbieter %s nicht konfiguriert",
"openai_failed_to_create_models_url": "Modell-URL konnte nicht erstellt werden: %w",
"openai_unexpected_status_code_with_body": "unerwarteter Statuscode: %d von Anbieter %s, Antwort: %s",
"openai_unexpected_status_code_read_error_partial": "unerwarteter Statuscode: %d von Anbieter %s (Fehler beim Lesen: %v), teilweise Antwort: %s",
"openai_unexpected_status_code_read_error": "unerwarteter Statuscode: %d von Anbieter %s (Fehler beim Lesen der Antwort: %v)",
"openai_unable_to_parse_models_response": "Modell-Antwort konnte nicht geparst werden; rohe Antwort: %s",
"scraping_not_configured": "Scraping-Funktionalität ist nicht konfiguriert. Bitte richte Jina ein, um Scraping zu aktivieren",
"could_not_determine_home_dir": "konnte Benutzer-Home-Verzeichnis nicht bestimmen: %w",
"could_not_stat_env_file": "konnte .env-Datei nicht überprüfen: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "empty seconds string",
"youtube_invalid_seconds_format": "invalid seconds format %q: %w",
"error_fetching_playlist_videos": "error fetching playlist videos: %w",
"openai_api_base_url_not_configured": "API base URL not configured for provider %s",
"openai_failed_to_create_models_url": "failed to create models URL: %w",
"openai_unexpected_status_code_with_body": "unexpected status code: %d from provider %s, response body: %s",
"openai_unexpected_status_code_read_error_partial": "unexpected status code: %d from provider %s (error reading body: %v), partial response: %s",
"openai_unexpected_status_code_read_error": "unexpected status code: %d from provider %s (failed to read response body: %v)",
"openai_unable_to_parse_models_response": "unable to parse models response; raw response: %s",
"scraping_not_configured": "scraping functionality is not configured. Please set up Jina to enable scraping",
"could_not_determine_home_dir": "could not determine user home directory: %w",
"could_not_stat_env_file": "could not stat .env file: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "cadena de segundos vacía",
"youtube_invalid_seconds_format": "formato de segundos inválido %q: %w",
"error_fetching_playlist_videos": "error al obtener videos de la lista de reproducción: %w",
"openai_api_base_url_not_configured": "URL base de API no configurada para el proveedor %s",
"openai_failed_to_create_models_url": "error al crear URL de modelos: %w",
"openai_unexpected_status_code_with_body": "código de estado inesperado: %d del proveedor %s, cuerpo de respuesta: %s",
"openai_unexpected_status_code_read_error_partial": "código de estado inesperado: %d del proveedor %s (error al leer cuerpo: %v), respuesta parcial: %s",
"openai_unexpected_status_code_read_error": "código de estado inesperado: %d del proveedor %s (error al leer cuerpo de respuesta: %v)",
"openai_unable_to_parse_models_response": "no se pudo analizar la respuesta de modelos; respuesta cruda: %s",
"scraping_not_configured": "la funcionalidad de extracción no está configurada. Por favor configura Jina para habilitar la extracción",
"could_not_determine_home_dir": "no se pudo determinar el directorio home del usuario: %w",
"could_not_stat_env_file": "no se pudo verificar el archivo .env: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "رشته ثانیه خالی",
"youtube_invalid_seconds_format": "فرمت ثانیه نامعتبر %q: %w",
"error_fetching_playlist_videos": "خطا در دریافت ویدیوهای فهرست پخش: %w",
"openai_api_base_url_not_configured": "URL پایه API برای ارائه‌دهنده %s پیکربندی نشده است",
"openai_failed_to_create_models_url": "ایجاد URL مدل‌ها ناموفق بود: %w",
"openai_unexpected_status_code_with_body": "کد وضعیت غیرمنتظره: %d از ارائه‌دهنده %s، پاسخ: %s",
"openai_unexpected_status_code_read_error_partial": "کد وضعیت غیرمنتظره: %d از ارائه‌دهنده %s (خطا در خواندن: %v)، پاسخ جزئی: %s",
"openai_unexpected_status_code_read_error": "کد وضعیت غیرمنتظره: %d از ارائه‌دهنده %s (خطا در خواندن پاسخ: %v)",
"openai_unable_to_parse_models_response": "تجزیه پاسخ مدل‌ها ناموفق بود; پاسخ خام: %s",
"scraping_not_configured": "قابلیت استخراج داده پیکربندی نشده است. لطفاً Jina را برای فعال‌سازی استخراج تنظیم کنید",
"could_not_determine_home_dir": "نتوانست دایرکتوری خانه کاربر را تعیین کند: %w",
"could_not_stat_env_file": "نتوانست وضعیت فایل .env را بررسی کند: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "chaîne de secondes vide",
"youtube_invalid_seconds_format": "format de secondes invalide %q : %w",
"error_fetching_playlist_videos": "erreur lors de la récupération des vidéos de la liste de lecture : %w",
"openai_api_base_url_not_configured": "URL de base de l'API non configurée pour le fournisseur %s",
"openai_failed_to_create_models_url": "échec de création de l'URL des modèles : %w",
"openai_unexpected_status_code_with_body": "code d'état inattendu : %d du fournisseur %s, corps de réponse : %s",
"openai_unexpected_status_code_read_error_partial": "code d'état inattendu : %d du fournisseur %s (erreur de lecture : %v), réponse partielle : %s",
"openai_unexpected_status_code_read_error": "code d'état inattendu : %d du fournisseur %s (échec de lecture du corps de réponse : %v)",
"openai_unable_to_parse_models_response": "impossible d'analyser la réponse des modèles ; réponse brute : %s",
"scraping_not_configured": "la fonctionnalité de scraping n'est pas configurée. Veuillez configurer Jina pour activer le scraping",
"could_not_determine_home_dir": "impossible de déterminer le répertoire home de l'utilisateur : %w",
"could_not_stat_env_file": "impossible de vérifier le fichier .env : %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "stringa di secondi vuota",
"youtube_invalid_seconds_format": "formato secondi non valido %q: %w",
"error_fetching_playlist_videos": "errore nel recupero dei video della playlist: %w",
"openai_api_base_url_not_configured": "URL base API non configurato per il provider %s",
"openai_failed_to_create_models_url": "impossibile creare URL modelli: %w",
"openai_unexpected_status_code_with_body": "codice di stato imprevisto: %d dal provider %s, corpo risposta: %s",
"openai_unexpected_status_code_read_error_partial": "codice di stato imprevisto: %d dal provider %s (errore lettura corpo: %v), risposta parziale: %s",
"openai_unexpected_status_code_read_error": "codice di stato imprevisto: %d dal provider %s (errore lettura corpo risposta: %v)",
"openai_unable_to_parse_models_response": "impossibile analizzare risposta modelli; risposta grezza: %s",
"scraping_not_configured": "la funzionalità di scraping non è configurata. Per favore configura Jina per abilitare lo scraping",
"could_not_determine_home_dir": "impossibile determinare la directory home dell'utente: %w",
"could_not_stat_env_file": "impossibile verificare il file .env: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "空の秒文字列",
"youtube_invalid_seconds_format": "無効な秒形式 %q: %w",
"error_fetching_playlist_videos": "プレイリスト動画の取得エラー: %w",
"openai_api_base_url_not_configured": "プロバイダー %s のAPIベースURLが設定されていません",
"openai_failed_to_create_models_url": "モデルURLの作成に失敗しました: %w",
"openai_unexpected_status_code_with_body": "予期しないステータスコード: プロバイダー %s から %d、レスポンス本文: %s",
"openai_unexpected_status_code_read_error_partial": "予期しないステータスコード: プロバイダー %s から %d (本文読み取りエラー: %v)、部分的なレスポンス: %s",
"openai_unexpected_status_code_read_error": "予期しないステータスコード: プロバイダー %s から %d (レスポンス本文の読み取りに失敗: %v)",
"openai_unable_to_parse_models_response": "モデルレスポンスの解析に失敗しました; 生のレスポンス: %s",
"scraping_not_configured": "スクレイピング機能が設定されていません。スクレイピングを有効にするためにJinaを設定してください",
"could_not_determine_home_dir": "ユーザーのホームディレクトリを特定できませんでした: %w",
"could_not_stat_env_file": ".envファイルの状態を確認できませんでした: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "string de segundos vazia",
"youtube_invalid_seconds_format": "formato de segundos inválido %q: %w",
"error_fetching_playlist_videos": "erro ao buscar vídeos da playlist: %w",
"openai_api_base_url_not_configured": "URL base da API não configurada para o provedor %s",
"openai_failed_to_create_models_url": "falha ao criar URL de modelos: %w",
"openai_unexpected_status_code_with_body": "código de status inesperado: %d do provedor %s, corpo da resposta: %s",
"openai_unexpected_status_code_read_error_partial": "código de status inesperado: %d do provedor %s (erro ao ler corpo: %v), resposta parcial: %s",
"openai_unexpected_status_code_read_error": "código de status inesperado: %d do provedor %s (falha ao ler corpo da resposta: %v)",
"openai_unable_to_parse_models_response": "não foi possível analisar a resposta de modelos; resposta bruta: %s",
"scraping_not_configured": "funcionalidade de scraping não está configurada. Por favor configure o Jina para ativar o scraping",
"could_not_determine_home_dir": "não foi possível determinar o diretório home do usuário: %w",
"could_not_stat_env_file": "não foi possível verificar o arquivo .env: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "cadeia de segundos vazia",
"youtube_invalid_seconds_format": "formato de segundos inválido %q: %w",
"error_fetching_playlist_videos": "erro ao obter vídeos da playlist: %w",
"openai_api_base_url_not_configured": "URL base da API não configurado para o fornecedor %s",
"openai_failed_to_create_models_url": "falha ao criar URL de modelos: %w",
"openai_unexpected_status_code_with_body": "código de estado inesperado: %d do fornecedor %s, corpo da resposta: %s",
"openai_unexpected_status_code_read_error_partial": "código de estado inesperado: %d do fornecedor %s (erro ao ler corpo: %v), resposta parcial: %s",
"openai_unexpected_status_code_read_error": "código de estado inesperado: %d do fornecedor %s (falha ao ler corpo da resposta: %v)",
"openai_unable_to_parse_models_response": "não foi possível analisar a resposta de modelos; resposta bruta: %s",
"scraping_not_configured": "funcionalidade de scraping não está configurada. Por favor configure o Jina para ativar o scraping",
"could_not_determine_home_dir": "não foi possível determinar o diretório home do utilizador: %w",
"could_not_stat_env_file": "não foi possível verificar o ficheiro .env: %w",

View File

@@ -28,6 +28,12 @@
"youtube_empty_seconds_string": "秒数字符串为空",
"youtube_invalid_seconds_format": "无效的秒数格式 %q%w",
"error_fetching_playlist_videos": "获取播放列表视频时出错: %w",
"openai_api_base_url_not_configured": "未为提供商 %s 配置 API 基础 URL",
"openai_failed_to_create_models_url": "创建模型 URL 失败:%w",
"openai_unexpected_status_code_with_body": "意外的状态码:来自提供商 %s 的 %d响应主体%s",
"openai_unexpected_status_code_read_error_partial": "意外的状态码:来自提供商 %s 的 %d读取主体错误%v部分响应%s",
"openai_unexpected_status_code_read_error": "意外的状态码:来自提供商 %s 的 %d读取响应主体失败%v)",
"openai_unable_to_parse_models_response": "无法解析模型响应;原始响应:%s",
"scraping_not_configured": "抓取功能未配置。请设置 Jina 以启用抓取功能",
"could_not_determine_home_dir": "无法确定用户主目录: %w",
"could_not_stat_env_file": "无法获取 .env 文件状态: %w",

View File

@@ -0,0 +1,120 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
)
// modelResponse represents a minimal model returned by the API.
// This mirrors the shape used by OpenAI-compatible providers that return
// either an array of models or an object with a `data` field.
type modelResponse struct {
ID string `json:"id"`
}
// errorResponseLimit defines the maximum length of error response bodies for truncation.
const errorResponseLimit = 1024
// maxResponseSize defines the maximum size of response bodies to prevent memory exhaustion.
const maxResponseSize = 10 * 1024 * 1024 // 10MB
// FetchModelsDirectly is used to fetch models directly from the API when the
// standard OpenAI SDK method fails due to a nonstandard format. This is useful
// for providers that return a direct array of models (e.g., GitHub Models) or
// other OpenAI-compatible implementations.
func FetchModelsDirectly(ctx context.Context, baseURL, apiKey, providerName string) ([]string, error) {
if ctx == nil {
ctx = context.Background()
}
if baseURL == "" {
return nil, fmt.Errorf(i18n.T("openai_api_base_url_not_configured"), providerName)
}
// Build the /models endpoint URL
fullURL, err := url.JoinPath(baseURL, "models")
if err != nil {
return nil, fmt.Errorf(i18n.T("openai_failed_to_create_models_url"), err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
req.Header.Set("Accept", "application/json")
// TODO: Consider reusing a single http.Client instance (e.g., as a field on Client) instead of allocating a new one for
// each request.
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read the response body for debugging, but limit the number of bytes read
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, errorResponseLimit))
if readErr != nil {
return nil, fmt.Errorf(i18n.T("openai_unexpected_status_code_read_error"),
resp.StatusCode, providerName, readErr)
}
bodyString := string(bodyBytes)
return nil, fmt.Errorf(i18n.T("openai_unexpected_status_code_with_body"),
resp.StatusCode, providerName, bodyString)
}
// Read the response body once, with a size limit to prevent memory exhaustion
// Read up to maxResponseSize + 1 bytes to detect truncation
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return nil, err
}
if len(bodyBytes) > maxResponseSize {
return nil, fmt.Errorf(i18n.T("openai_models_response_too_large"), providerName, maxResponseSize)
}
// Try to parse as an object with data field (OpenAI format)
var openAIFormat struct {
Data []modelResponse `json:"data"`
}
// Try to parse as a direct array
var directArray []modelResponse
if err := json.Unmarshal(bodyBytes, &openAIFormat); err == nil {
debuglog.Debug(debuglog.Detailed, "Successfully parsed models response from %s using OpenAI format (found %d models)\n", providerName, len(openAIFormat.Data))
return extractModelIDs(openAIFormat.Data), nil
}
if err := json.Unmarshal(bodyBytes, &directArray); err == nil {
debuglog.Debug(debuglog.Detailed, "Successfully parsed models response from %s using direct array format (found %d models)\n", providerName, len(directArray))
return extractModelIDs(directArray), nil
}
var truncatedBody string
if len(bodyBytes) > errorResponseLimit {
truncatedBody = string(bodyBytes[:errorResponseLimit]) + "..."
} else {
truncatedBody = string(bodyBytes)
}
return nil, fmt.Errorf(i18n.T("openai_unable_to_parse_models_response"), truncatedBody)
}
func extractModelIDs(models []modelResponse) []string {
modelIDs := make([]string, 0, len(models))
for _, model := range models {
modelIDs = append(modelIDs, model.ID)
}
return modelIDs
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/danielmiessler/fabric/internal/chat"
"github.com/danielmiessler/fabric/internal/domain"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins"
openai "github.com/openai/openai-go"
"github.com/openai/openai-go/option"
@@ -83,13 +84,19 @@ func (o *Client) configure() (ret error) {
func (o *Client) ListModels() (ret []string, err error) {
var page *pagination.Page[openai.Model]
if page, err = o.ApiClient.Models.List(context.Background()); err != nil {
return
if page, err = o.ApiClient.Models.List(context.Background()); err == nil {
for _, mod := range page.Data {
ret = append(ret, mod.ID)
}
// SDK succeeded - return the result even if empty
return ret, nil
}
for _, mod := range page.Data {
ret = append(ret, mod.ID)
}
return
// SDK returned an error - fall back to direct API fetch.
// 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())
}
func (o *Client) SendStream(

View File

@@ -0,0 +1,58 @@
package openai
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
// Ensures we can fetch models directly when a provider returns a direct array of models
// instead of the standard OpenAI list response structure.
func TestFetchModelsDirectly_DirectArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`[{"id":"github-model"}]`))
assert.NoError(t, err)
}))
defer srv.Close()
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
assert.NoError(t, err)
assert.Equal(t, 1, len(models))
assert.Equal(t, "github-model", models[0])
}
// Ensures we can fetch models when a provider returns the standard OpenAI format
func TestFetchModelsDirectly_OpenAIFormat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`{"data":[{"id":"openai-model"}]}`))
assert.NoError(t, err)
}))
defer srv.Close()
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
assert.NoError(t, err)
assert.Equal(t, 1, len(models))
assert.Equal(t, "openai-model", models[0])
}
// Ensures we handle empty model lists correctly
func TestFetchModelsDirectly_EmptyArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`[]`))
assert.NoError(t, err)
}))
defer srv.Close()
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
assert.NoError(t, err)
assert.Equal(t, 0, len(models))
}

View File

@@ -2,104 +2,12 @@ package openai_compatible
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/danielmiessler/fabric/internal/plugins/ai/openai"
)
// Model represents a model returned by the API
type Model struct {
ID string `json:"id"`
}
// ErrorResponseLimit defines the maximum length of error response bodies for truncation.
const errorResponseLimit = 1024 // Limit for error response body size
// DirectlyGetModels is used to fetch models directly from the API
// when the standard OpenAI SDK method fails due to a nonstandard format.
// This is useful for providers like Together that return a direct array of models.
// DirectlyGetModels is used to fetch models directly from the API when the
// standard OpenAI SDK method fails due to a nonstandard format.
func (c *Client) DirectlyGetModels(ctx context.Context) ([]string, error) {
if ctx == nil {
ctx = context.Background()
}
baseURL := c.ApiBaseURL.Value
if baseURL == "" {
return nil, fmt.Errorf("API base URL not configured for provider %s", c.GetName())
}
// Build the /models endpoint URL
fullURL, err := url.JoinPath(baseURL, "models")
if err != nil {
return nil, fmt.Errorf("failed to create models URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ApiKey.Value))
req.Header.Set("Accept", "application/json")
// TODO: Consider reusing a single http.Client instance (e.g., as a field on Client) instead of allocating a new one for each request.
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read the response body for debugging
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)
if len(bodyString) > errorResponseLimit { // Truncate if too large
bodyString = bodyString[:errorResponseLimit] + "..."
}
return nil, fmt.Errorf("unexpected status code: %d from provider %s, response body: %s",
resp.StatusCode, c.GetName(), bodyString)
}
// Read the response body once
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Try to parse as an object with data field (OpenAI format)
var openAIFormat struct {
Data []Model `json:"data"`
}
// Try to parse as a direct array (Together format)
var directArray []Model
if err := json.Unmarshal(bodyBytes, &openAIFormat); err == nil && len(openAIFormat.Data) > 0 {
return extractModelIDs(openAIFormat.Data), nil
}
if err := json.Unmarshal(bodyBytes, &directArray); err == nil && len(directArray) > 0 {
return extractModelIDs(directArray), nil
}
var truncatedBody string
if len(bodyBytes) > errorResponseLimit {
truncatedBody = string(bodyBytes[:errorResponseLimit]) + "..."
} else {
truncatedBody = string(bodyBytes)
}
return nil, fmt.Errorf("unable to parse models response; raw response: %s", truncatedBody)
}
func extractModelIDs(models []Model) []string {
modelIDs := make([]string, 0, len(models))
for _, model := range models {
modelIDs = append(modelIDs, model.ID)
}
return modelIDs
return openai.FetchModelsDirectly(ctx, c.ApiBaseURL.Value, c.ApiKey.Value, c.GetName())
}

View File

@@ -12,17 +12,21 @@ import (
type ProviderConfig struct {
Name string
BaseURL string
ImplementsResponses bool // Whether the provider supports OpenAI's new Responses API
ModelsURL string // Optional: Custom endpoint for listing models (if different from BaseURL/models)
ImplementsResponses bool // Whether the provider supports OpenAI's new Responses API
}
// Client is the common structure for all OpenAI-compatible providers
type Client struct {
*openai.Client
modelsURL string // Custom URL for listing models (if different from BaseURL/models)
}
// NewClient creates a new OpenAI-compatible client for the specified provider
func NewClient(providerConfig ProviderConfig) *Client {
client := &Client{}
client := &Client{
modelsURL: providerConfig.ModelsURL,
}
client.Client = openai.NewClientCompatibleWithResponses(
providerConfig.Name,
providerConfig.BaseURL,
@@ -34,14 +38,20 @@ func NewClient(providerConfig ProviderConfig) *Client {
// ListModels overrides the default ListModels to handle different response formats
func (c *Client) ListModels() ([]string, error) {
// If a custom models URL is provided, use direct fetch with that URL
if c.modelsURL != "" {
// TODO: Handle context properly in Fabric by accepting and propagating a context.Context
// instead of creating a new one here.
return openai.FetchModelsDirectly(context.Background(), c.modelsURL, c.Client.ApiKey.Value, c.GetName())
}
// First try the standard OpenAI SDK approach
models, err := c.Client.ListModels()
if err == nil && len(models) > 0 { // only return if OpenAI SDK returns models
return models, nil
}
// TODO: Handle context properly in Fabric by accepting and propagating a context.Context
// instead of creating a new one here.
// Fall back to direct API fetch
return c.DirectlyGetModels(context.Background())
}
@@ -62,6 +72,12 @@ var ProviderMap = map[string]ProviderConfig{
BaseURL: "https://api.deepseek.com",
ImplementsResponses: false,
},
"GitHub": {
Name: "GitHub",
BaseURL: "https://models.github.ai/inference",
ModelsURL: "https://models.github.ai/catalog", // FetchModelsDirectly will append /models
ImplementsResponses: false,
},
"GrokAI": {
Name: "GrokAI",
BaseURL: "https://api.x.ai/v1",