From 523042509dee4cd68ee1437fa4edb4906c54e88b Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 6 Nov 2026 22:48:45 +0100 Subject: [PATCH 1/2] ai/provider: Gemini-Adapter hinter dem Provider-Interface Chat, Completion und Embedding ueber die Gemini-REST-API, Schluessel nur per NEXARCH_AI_GEMINI_API_KEY (Header, nie URL). Ohne Schluessel deaktiviert. Vertragstest gegen Fake-Server. Co-Authored-By: Claude Sonnet 5.5 --- ai/internal/provider/contract_test.go | 21 +++ ai/internal/provider/gemini/gemini.go | 203 +++++++++++++++++++++ ai/internal/provider/gemini/gemini_test.go | 128 +++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 ai/internal/provider/gemini/gemini.go create mode 100644 ai/internal/provider/gemini/gemini_test.go diff --git a/ai/internal/provider/contract_test.go b/ai/internal/provider/contract_test.go index 65b9df48..3d276ebd 100644 --- a/ai/internal/provider/contract_test.go +++ b/ai/internal/provider/contract_test.go @@ -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) { diff --git a/ai/internal/provider/gemini/gemini.go b/ai/internal/provider/gemini/gemini.go new file mode 100644 index 00000000..58566f70 --- /dev/null +++ b/ai/internal/provider/gemini/gemini.go @@ -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 +} diff --git a/ai/internal/provider/gemini/gemini_test.go b/ai/internal/provider/gemini/gemini_test.go new file mode 100644 index 00000000..204a0e40 --- /dev/null +++ b/ai/internal/provider/gemini/gemini_test.go @@ -0,0 +1,128 @@ +package gemini + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +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) + } +} -- 2.47.3 From 5f984e87a766a596a837a79d716568f575f4ad0a Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 6 Nov 2026 22:59:04 +0100 Subject: [PATCH 2/2] ai/provider: Opt-in-Test und Pruefprotokoll ENG-09 Co-Authored-By: Claude Sonnet 5.5 --- ai/docs/ENG-09-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++ ai/internal/provider/gemini/gemini_test.go | 20 ++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 ai/docs/ENG-09-PRUEFPROTOKOLL.md diff --git a/ai/docs/ENG-09-PRUEFPROTOKOLL.md b/ai/docs/ENG-09-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c3557a1d --- /dev/null +++ b/ai/docs/ENG-09-PRUEFPROTOKOLL.md @@ -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. diff --git a/ai/internal/provider/gemini/gemini_test.go b/ai/internal/provider/gemini/gemini_test.go index 204a0e40..9d81b883 100644 --- a/ai/internal/provider/gemini/gemini_test.go +++ b/ai/internal/provider/gemini/gemini_test.go @@ -9,6 +9,7 @@ import ( "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) { @@ -126,3 +127,22 @@ func TestKeyNotInErrors(t *testing.T) { 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") + } +} -- 2.47.3