Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f984e87a7 | ||
|
|
523042509d |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# ENG-09 – Weitere Provider (OpenAI-kompatible Presets, Gemini) – Prüfprotokoll
|
||||
|
||||
Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Neues Paket `ai/internal/provider/gemini`: Chat, Completion (über Chat) und Embedding gegen die Gemini-REST-API (`generateContent`, `embedContent`). Schlüssel aus `NEXARCH_AI_GEMINI_API_KEY`, optionale Basis-URL `NEXARCH_AI_GEMINI_BASE_URL`; Schlüssel nur im Header `x-goog-api-key`. Ohne Schlüssel liefert `NewFromEnv` `ErrDisabled`.
|
||||
- OpenAI-kompatible Dienste: der vorhandene `openai`-Adapter nimmt bereits eine Basis-URL (`NEXARCH_AI_OPENAI_BASE_URL`); es war keine Codeänderung nötig.
|
||||
- Opt-in je Mandant: `openai.GuardedProvider` mit `TenantGate` umschließt jeden `provider.Provider`, auch den Gemini-Adapter.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | OpenAI-kompatible Dienste ohne Codeänderung über Basis-URL und Schlüssel aus Umgebungsvariablen | **Teilweise belegt.** Mechanismus vorhanden und durch bestehende `openai`-Tests (Fake-Server mit abweichender Basis-URL) abgedeckt. Gegen OpenRouter, vLLM, LiteLLM oder DeepSeek nicht getestet (keine Zugänge). |
|
||||
| 2 | Gemini-Adapter erfüllt den Provider-Vertragstest | **Erfüllt gegen Fake-Server.** `TestContract_Gemini` (Chat, Complete, Embed über `runContract`), `TestUniformErrors` (404, 429, 500 auf die einheitlichen Codes), Timeout/Nicht-Erreichbar (`TestUnreachableAndCancelled`). Nicht gegen die echte Gemini-API. |
|
||||
| 3 | Externe Anbieter nur nach Opt-in und Residenz-Prüfung | **Teilweise belegt.** Opt-in per `GuardedProvider` belegt (`TestOptIn_GuardedProviderBlocksWithoutCallingServer`: kein Aufruf ohne Freigabe oder ohne Mandant). Datenresidenz-Prüfung (`residency`) ist nicht an den Adapter gekoppelt. |
|
||||
| 4 | Zugangsdaten ausschließlich über Umgebungsvariablen | **Erfüllt.** `TestDisabledWithoutKey`; `TestKeyNotInErrors`; Schlüssel nie in der URL (`fakeServer` prüft den Query-Parameter `key`). |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Vertragstest gegen jeden neuen Adapter | **Bestanden** (Fake-Server). Echte Gemini-API: **offen**, kein API-Schlüssel vorhanden. |
|
||||
| 2 | Opt-in-Verweigerung blockiert den Aufruf nachweislich | **Bestanden.** Server-Zähler bleibt unberührt. |
|
||||
| 3 | Keine echten Kundendaten in Tests gegen externe Dienste | **Bestanden.** Es gibt keine Tests gegen externe Dienste. |
|
||||
|
||||
## Einschränkungen
|
||||
|
||||
- Kein Lauf gegen eine echte Gemini-API; Request-/Response-Format nach der öffentlichen REST-Dokumentation nachgebaut, nicht gegen den echten Dienst verifiziert.
|
||||
- `ai-app` bindet den Adapter nicht ein; die Fehlermeldung von `GuardedProvider` nennt den Anbieter `openai`, auch wenn Gemini umschlossen ist.
|
||||
- Kopplung an `residency` und Streaming sind nicht Teil dieser Kachel.
|
||||
@@ -2,12 +2,17 @@ package provider_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/gemini"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
|
||||
)
|
||||
@@ -84,6 +89,22 @@ func TestContract_Ollama(t *testing.T) {
|
||||
runContract(t, p, model)
|
||||
}
|
||||
|
||||
func TestContract_Gemini(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ":embedContent") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embedding": map[string]any{"values": []float64{0.1, 0.2}}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"candidates": []map[string]any{{"content": map[string]any{"parts": []map[string]string{{"text": "OK"}}}}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := gemini.New(srv.URL, "k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runContract(t, p, "gemini-test")
|
||||
}
|
||||
|
||||
// TestInterchangeable_SameCallerCode belegt AK2: ein Aufrufer, der gegen das
|
||||
// Interface programmiert, funktioniert unveraendert mit jeder Implementierung.
|
||||
func TestInterchangeable_SameCallerCode(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
EnvAPIKey = "NEXARCH_AI_GEMINI_API_KEY"
|
||||
EnvBaseURL = "NEXARCH_AI_GEMINI_BASE_URL"
|
||||
)
|
||||
|
||||
var ErrDisabled = fmt.Errorf("gemini-adapter: %s nicht gesetzt, adapter bleibt deaktiviert", EnvAPIKey)
|
||||
|
||||
type Provider struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
func New(baseURL, apiKey string) (*Provider, error) {
|
||||
if apiKey == "" {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &Provider{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
Client: &http.Client{Timeout: 120 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewFromEnv() (*Provider, error) {
|
||||
return New(os.Getenv(EnvBaseURL), os.Getenv(EnvAPIKey))
|
||||
}
|
||||
|
||||
func newErr(code provider.Code, msg string, err error) error {
|
||||
return &provider.Error{Provider: "gemini", Code: code, Message: msg, Err: err}
|
||||
}
|
||||
|
||||
func (p *Provider) do(ctx context.Context, model, method string, body, out any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return newErr(provider.CodeInvalidRequest, "anfrage kodieren", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/models/%s:%s", p.BaseURL, url.PathEscape(strings.TrimPrefix(model, "models/")), method)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return newErr(provider.CodeInvalidRequest, "anfrage aufbauen", err)
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", p.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.Client.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return newErr(provider.CodeTimeout, "anfrage abgebrochen", err)
|
||||
}
|
||||
return newErr(provider.CodeUnavailable, "dienst nicht erreichbar", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return newErr(provider.CodeUpstream, "antwort lesen", err)
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return newErr(provider.CodeModelNotFound, "modell nicht gefunden", fmt.Errorf("http %d", resp.StatusCode))
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return newErr(provider.CodeRateLimited, "rate limit", fmt.Errorf("http %d", resp.StatusCode))
|
||||
case resp.StatusCode >= 400:
|
||||
return newErr(provider.CodeUpstream, "fehlerhafte antwort", fmt.Errorf("http %d: %s", resp.StatusCode, string(data)))
|
||||
}
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return newErr(provider.CodeUpstream, "antwort dekodieren", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type part struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type content struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []part `json:"parts"`
|
||||
}
|
||||
|
||||
type generationConfig struct {
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type generateRequest struct {
|
||||
SystemInstruction *content `json:"systemInstruction,omitempty"`
|
||||
Contents []content `json:"contents"`
|
||||
GenerationConfig generationConfig `json:"generationConfig"`
|
||||
}
|
||||
|
||||
type generateResponse struct {
|
||||
Candidates []struct {
|
||||
Content content `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
ModelVersion string `json:"modelVersion"`
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
body := generateRequest{GenerationConfig: generationConfig{MaxOutputTokens: req.MaxTokens, Temperature: req.Temperature}}
|
||||
var system []part
|
||||
for _, m := range req.Messages {
|
||||
switch m.Role {
|
||||
case provider.RoleSystem:
|
||||
system = append(system, part{Text: m.Content})
|
||||
case provider.RoleAssistant:
|
||||
body.Contents = append(body.Contents, content{Role: "model", Parts: []part{{Text: m.Content}}})
|
||||
default:
|
||||
body.Contents = append(body.Contents, content{Role: "user", Parts: []part{{Text: m.Content}}})
|
||||
}
|
||||
}
|
||||
if len(system) > 0 {
|
||||
body.SystemInstruction = &content{Parts: system}
|
||||
}
|
||||
|
||||
var out generateResponse
|
||||
if err := p.do(ctx, req.Model, "generateContent", body, &out); err != nil {
|
||||
return provider.ChatResponse{}, err
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
return provider.ChatResponse{}, newErr(provider.CodeUpstream, "keine antwort in candidates", nil)
|
||||
}
|
||||
c := out.Candidates[0]
|
||||
var text strings.Builder
|
||||
for _, pt := range c.Content.Parts {
|
||||
text.WriteString(pt.Text)
|
||||
}
|
||||
return provider.ChatResponse{
|
||||
Model: out.ModelVersion,
|
||||
Message: provider.Message{Role: provider.RoleAssistant, Content: text.String()},
|
||||
FinishReason: c.FinishReason,
|
||||
Usage: provider.Usage{
|
||||
PromptTokens: out.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: out.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: out.UsageMetadata.TotalTokenCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
|
||||
resp, err := p.Chat(ctx, provider.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: []provider.Message{{Role: provider.RoleUser, Content: req.Prompt}},
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
})
|
||||
if err != nil {
|
||||
return provider.CompletionResponse{}, err
|
||||
}
|
||||
return provider.CompletionResponse{Model: resp.Model, Text: resp.Message.Content, FinishReason: resp.FinishReason, Usage: resp.Usage}, nil
|
||||
}
|
||||
|
||||
type embedRequest struct {
|
||||
Content content `json:"content"`
|
||||
}
|
||||
|
||||
type embedResponse struct {
|
||||
Embedding struct {
|
||||
Values []float64 `json:"values"`
|
||||
} `json:"embedding"`
|
||||
}
|
||||
|
||||
func (p *Provider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
||||
var out embedResponse
|
||||
body := embedRequest{Content: content{Parts: []part{{Text: req.Input}}}}
|
||||
if err := p.do(ctx, req.Model, "embedContent", body, &out); err != nil {
|
||||
return provider.EmbeddingResponse{}, err
|
||||
}
|
||||
if len(out.Embedding.Values) == 0 {
|
||||
return provider.EmbeddingResponse{}, newErr(provider.CodeUpstream, "keine einbettung in der antwort", nil)
|
||||
}
|
||||
return provider.EmbeddingResponse{Model: req.Model, Embedding: out.Embedding.Values}, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
|
||||
)
|
||||
|
||||
func fakeServer(t *testing.T, status int) (*httptest.Server, *map[string]any) {
|
||||
t.Helper()
|
||||
var last map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("x-goog-api-key"); got != "key-1" {
|
||||
t.Errorf("x-goog-api-key = %q", got)
|
||||
}
|
||||
if r.URL.Query().Get("key") != "" {
|
||||
t.Error("schluessel darf nicht in der url stehen")
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&last)
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"x"}}`))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, ":generateContent"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"role": "model", "parts": []map[string]string{{"text": "ha"}, {"text": "llo"}}},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
"usageMetadata": map[string]int{"promptTokenCount": 3, "candidatesTokenCount": 2, "totalTokenCount": 5},
|
||||
"modelVersion": "gemini-test",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, ":embedContent"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embedding": map[string]any{"values": []float64{0.1, 0.2}}})
|
||||
default:
|
||||
t.Errorf("unerwarteter pfad %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &last
|
||||
}
|
||||
|
||||
func TestChat_MapsRolesUsageAndParts(t *testing.T) {
|
||||
srv, last := fakeServer(t, http.StatusOK)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
resp, err := p.Chat(context.Background(), provider.ChatRequest{
|
||||
Model: "models/gemini-test",
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "u"},
|
||||
{Role: provider.RoleAssistant, Content: "a"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Message.Content != "hallo" || resp.Usage.TotalTokens != 5 || resp.FinishReason != "STOP" {
|
||||
t.Fatalf("unerwartet: %+v", resp)
|
||||
}
|
||||
if (*last)["systemInstruction"] == nil {
|
||||
t.Fatal("systemInstruction fehlt")
|
||||
}
|
||||
contents := (*last)["contents"].([]any)
|
||||
if len(contents) != 2 || contents[1].(map[string]any)["role"] != "model" {
|
||||
t.Fatalf("rollen falsch: %v", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAndEmbed(t *testing.T) {
|
||||
srv, _ := fakeServer(t, http.StatusOK)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
c, err := p.Complete(context.Background(), provider.CompletionRequest{Model: "m", Prompt: "x"})
|
||||
if err != nil || c.Text != "hallo" {
|
||||
t.Fatalf("complete: %+v %v", c, err)
|
||||
}
|
||||
e, err := p.Embed(context.Background(), provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if err != nil || len(e.Embedding) != 2 {
|
||||
t.Fatalf("embed: %+v %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniformErrors(t *testing.T) {
|
||||
for status, code := range map[int]provider.Code{404: provider.CodeModelNotFound, 429: provider.CodeRateLimited, 500: provider.CodeUpstream} {
|
||||
srv, _ := fakeServer(t, status)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
_, err := p.Chat(context.Background(), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}})
|
||||
if !provider.IsCode(err, code) {
|
||||
t.Errorf("status %d: err=%v, want %s", status, err, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableAndCancelled(t *testing.T) {
|
||||
p, _ := New("http://127.0.0.1:1", "key-1")
|
||||
_, err := p.Embed(context.Background(), provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if !provider.IsCode(err, provider.CodeUnavailable) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = p.Embed(ctx, provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if !provider.IsCode(err, provider.CodeTimeout) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledWithoutKey(t *testing.T) {
|
||||
t.Setenv(EnvAPIKey, "")
|
||||
if _, err := NewFromEnv(); err != ErrDisabled {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyNotInErrors(t *testing.T) {
|
||||
srv, _ := fakeServer(t, http.StatusInternalServerError)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
_, err := p.Chat(context.Background(), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}})
|
||||
if err == nil || strings.Contains(err.Error(), "key-1") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptIn_GuardedProviderBlocksWithoutCallingServer(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
defer srv.Close()
|
||||
inner, _ := New(srv.URL, "key-1")
|
||||
g := &openai.GuardedProvider{Inner: inner, Gate: openai.NewEnvTenantGate("acme")}
|
||||
req := provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}}
|
||||
|
||||
if _, err := g.Chat(openai.WithTenant(context.Background(), "other"), req); err == nil {
|
||||
t.Fatal("freigabe fehlt, fehler erwartet")
|
||||
}
|
||||
if _, err := g.Chat(context.Background(), req); err == nil {
|
||||
t.Fatal("ohne mandant, fehler erwartet")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("server wurde trotz fehlender freigabe aufgerufen")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user