mirror of
https://github.com/luxfi/ai.git
synced 2026-10-30 17:02:43 +00:00
Close three `// TODO: Integrate with actual ...` stubs in pkg/miner/miner.go (runInference, runChat, runEmbedding) by introducing a small pluggable backend interface. The miner now dispatches through backend.InferenceBackend rather than inlining placeholder output. Two reference backends ship in-tree: - pkg/miner/backend/noop — deterministic mock, byte-for-byte identical to the pre-refactor inline stubs. Default. Zero config, zero deps. - pkg/miner/backend/openai — OpenAI-compatible HTTP adapter built on net/http. Covers llama.cpp, vllm, ollama, LocalAI, and api.openai.com via OPENAI_API_BASE. No new go.mod dependencies. Config gains Backend / OpenAIBase / OpenAIAPIKey / OpenAIModel / OpenAIEmbeddingModel fields. Unknown backend values fall back to noop so operator typos don't crash-loop the miner. Existing miner_test.go passes unchanged; DefaultConfig() behaviour is preserved. Also wire a GPUStatsProvider hook on Miner so GetStats can surface best-effort GPU telemetry (GPUUtilization, MemoryUsed) without pulling NVML/MLX bindings into the main binary. Default is nil = no-op, matching current behaviour; a follow-up PR will wire a real provider. Closes luxfi/ai#1. Contributed by kcolbchain (https://kcolbchain.com) · https://abhishekkrishna.com
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/ai/pkg/miner/backend"
|
|
)
|
|
|
|
func TestName(t *testing.T) {
|
|
if got := New(Config{}).Name(); got != "openai" {
|
|
t.Errorf("Name: got %q want %q", got, "openai")
|
|
}
|
|
}
|
|
|
|
func TestChatHappyPath(t *testing.T) {
|
|
var gotAuth, gotPath, gotBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotPath = r.URL.Path
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = string(body)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"id": "chatcmpl-xyz",
|
|
"model": "gpt-test",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "hi there"},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL, APIKey: "sk-test", Model: "gpt-default"})
|
|
resp, err := b.Chat(context.Background(), backend.ChatRequest{
|
|
Model: "gpt-test",
|
|
Messages: []backend.Message{{Role: "user", Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if gotPath != "/chat/completions" {
|
|
t.Errorf("request path: got %q want %q", gotPath, "/chat/completions")
|
|
}
|
|
if gotAuth != "Bearer sk-test" {
|
|
t.Errorf("auth header: got %q want %q", gotAuth, "Bearer sk-test")
|
|
}
|
|
if !strings.Contains(gotBody, `"model":"gpt-test"`) {
|
|
t.Errorf("request body missing model: %s", gotBody)
|
|
}
|
|
if !strings.Contains(gotBody, `"role":"user"`) {
|
|
t.Errorf("request body missing user message: %s", gotBody)
|
|
}
|
|
if resp.Content != "hi there" {
|
|
t.Errorf("response content: got %q want %q", resp.Content, "hi there")
|
|
}
|
|
if resp.Tokens != 5 {
|
|
t.Errorf("response tokens: got %d want 5", resp.Tokens)
|
|
}
|
|
if resp.Model != "gpt-test" {
|
|
t.Errorf("response model: got %q want %q", resp.Model, "gpt-test")
|
|
}
|
|
}
|
|
|
|
func TestChatDefaultsModelFromConfig(t *testing.T) {
|
|
var sawModel string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
sawModel, _ = req["model"].(string)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL, Model: "configured-default"})
|
|
_, err := b.Chat(context.Background(), backend.ChatRequest{})
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if sawModel != "configured-default" {
|
|
t.Errorf("model default fallback: got %q want %q", sawModel, "configured-default")
|
|
}
|
|
}
|
|
|
|
func TestChatErrorStatus(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key","type":"auth_error"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL, APIKey: "bad"})
|
|
_, err := b.Chat(context.Background(), backend.ChatRequest{Model: "m"})
|
|
if err == nil {
|
|
t.Fatal("expected error on 401")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid api key") {
|
|
t.Errorf("error should surface server message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChatEmptyChoices(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL})
|
|
_, err := b.Chat(context.Background(), backend.ChatRequest{Model: "m"})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty choices")
|
|
}
|
|
}
|
|
|
|
func TestInference(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/completions" {
|
|
t.Errorf("path: got %q want /completions", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"model": "inf-m",
|
|
"choices": [{"text": "the answer"}],
|
|
"usage": {"completion_tokens": 2, "total_tokens": 5}
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL})
|
|
resp, err := b.Inference(context.Background(), backend.InferenceRequest{
|
|
Model: "inf-m",
|
|
Prompt: "q?",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Inference: %v", err)
|
|
}
|
|
if resp.Text != "the answer" {
|
|
t.Errorf("text: got %q", resp.Text)
|
|
}
|
|
if resp.Tokens != 2 {
|
|
t.Errorf("tokens: got %d want 2", resp.Tokens)
|
|
}
|
|
}
|
|
|
|
func TestEmbed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/embeddings" {
|
|
t.Errorf("path: got %q want /embeddings", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"model": "emb-m",
|
|
"data": [{"index": 0, "embedding": [0.1, 0.2, 0.3]}]
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL, EmbeddingModel: "emb-m"})
|
|
resp, err := b.Embed(context.Background(), backend.EmbedRequest{Text: "hi"})
|
|
if err != nil {
|
|
t.Fatalf("Embed: %v", err)
|
|
}
|
|
if len(resp.Embedding) != 3 || resp.Embedding[2] != 0.3 {
|
|
t.Errorf("embedding: got %v", resp.Embedding)
|
|
}
|
|
if resp.Model != "emb-m" {
|
|
t.Errorf("model: got %q", resp.Model)
|
|
}
|
|
}
|
|
|
|
func TestBaseURLTrailingSlash(t *testing.T) {
|
|
b := New(Config{BaseURL: "http://example.com/v1/"})
|
|
if b.cfg.BaseURL != "http://example.com/v1" {
|
|
t.Errorf("trailing slash not stripped: %q", b.cfg.BaseURL)
|
|
}
|
|
}
|
|
|
|
func TestNoAuthHeaderWhenAPIKeyEmpty(t *testing.T) {
|
|
var sawAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sawAuth = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
b := New(Config{BaseURL: srv.URL})
|
|
_, _ = b.Chat(context.Background(), backend.ChatRequest{Model: "m"})
|
|
if sawAuth != "" {
|
|
t.Errorf("expected no Authorization header when APIKey empty, got %q", sawAuth)
|
|
}
|
|
}
|
|
|
|
func TestCapabilities(t *testing.T) {
|
|
caps := New(Config{}).Capabilities()
|
|
if !caps.Chat || !caps.Inference || !caps.Embedding {
|
|
t.Errorf("capabilities: %+v", caps)
|
|
}
|
|
}
|