diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index d4ac9ac0..bc29243b 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -55,6 +55,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize" "gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantprovider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize" "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" "gitea.perlbach24.de/scripte/nexarch/common/auditclient" @@ -67,6 +68,8 @@ const workerPollInterval = 2 * time.Second const defaultListenAddr = "127.0.0.1:9600" const defaultModel = "default" +const tenantProviderName = "ollama" + type config struct { dsn string serviceToken string @@ -161,6 +164,14 @@ type wiring struct { // build verdrahtet alle AI-API-Pakete (AK1) sowie Queue-Worker und // Quota-Enforcement (AK2) auf einem gemeinsamen Pool/Provider. func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID string) wiring { + listers := map[string]modelconfig.ModelLister{} + if l, ok := prov.(modelconfig.ModelLister); ok { + listers[tenantProviderName] = l + } + modelStore := modelconfig.NewStore(pool, listers) + templateStore := prompttemplate.New(pool) + tenantProv := &tenantprovider.Provider{Inner: prov, ProviderName: tenantProviderName, Models: modelStore, Templates: templateStore} + mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -221,7 +232,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri langStore := &doclang.Store{Pool: pool} markerStore := &originmark.Store{Pool: pool} suggest := &suggestapi.Server{ - Provider: prov, + Provider: tenantProv, Model: cfg.model, Resolver: &doclang.Resolver{Registry: registry, Tenants: langStore}, Languages: langStore, @@ -246,22 +257,18 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri worker.SetUsageRecorder(quotaStore) taxonomy := &doctype.Taxonomy{Pool: pool} - listers := map[string]modelconfig.ModelLister{} - if l, ok := prov.(modelconfig.ModelLister); ok { - listers["ollama"] = l - } - classifier := &classify.Classifier{Provider: prov, Model: cfg.model} + classifier := &classify.Classifier{Provider: tenantProv, Model: cfg.model} features := &featureapi.Server{ Classifier: classifier, Doctype: &doctype.Detector{Classifier: classifier, Taxonomy: taxonomy}, - Highlight: &highlight.Detector{Provider: prov, Model: cfg.model}, + Highlight: &highlight.Detector{Provider: tenantProv, Model: cfg.model}, Taxonomy: taxonomy, Features: &tenantfeature.Store{Pool: pool}, Thresholds: &confidence.Store{Pool: pool}, Corrections: &correction.Recorder{Pool: pool}, Embeddings: embedpipeline.New(pool, q, cfg.model), - Models: modelconfig.NewStore(pool, listers), - Templates: prompttemplate.New(pool), + Models: modelStore, + Templates: templateStore, ServiceToken: cfg.serviceToken, } if cfg.coreBaseURL != "" { diff --git a/ai/cmd/ai-app/main_test.go b/ai/cmd/ai-app/main_test.go index 18c15fa2..bb8830d0 100644 --- a/ai/cmd/ai-app/main_test.go +++ b/ai/cmd/ai-app/main_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -178,6 +179,77 @@ func TestBuild_FeatureAPIAgainstRealTables(t *testing.T) { } } +type recordingProvider struct { + mu sync.Mutex + last provider.ChatRequest +} + +func (r *recordingProvider) Chat(_ context.Context, req provider.ChatRequest) (provider.ChatResponse, error) { + r.mu.Lock() + r.last = req + r.mu.Unlock() + return provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: `[{"title":"Rechnung Mai","confidence":0.9}]`}}, nil +} +func (r *recordingProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) { + return provider.CompletionResponse{}, nil +} +func (r *recordingProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return provider.EmbeddingResponse{}, nil +} +func (r *recordingProvider) ListModels(context.Context) ([]string, error) { + return []string{"big"}, nil +} + +func TestBuild_TenantModelAndTemplateReachInference(t *testing.T) { + pool := newTestPool(t) + rec := &recordingProvider{} + w := build(pool, config{serviceToken: "tok", model: "standard"}, rec, "tenant-model-worker") + srv := httptest.NewServer(w.mux) + defer srv.Close() + + call := func(method, path, body string) int { + t.Helper() + req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + req.Header.Set("X-Service-Token", "tok") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + resp.Body.Close() + return resp.StatusCode + } + title := func(tenant string) provider.ChatRequest { + t.Helper() + if code := call("POST", "/suggest/title", `{"tenant_slug":"`+tenant+`","text":"Rechnung fuer Mai"}`); code != http.StatusOK { + t.Fatalf("title %s: status %d", tenant, code) + } + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.last + } + + if got := title("tm-a"); got.Model != "standard" { + t.Fatalf("ohne Wahl: model = %q, want standard", got.Model) + } + if code := call("PUT", "/tenants/tm-a/model-config/title", `{"provider":"ollama","model":"big"}`); code != http.StatusOK { + t.Fatalf("model-config put: %d", code) + } + if code := call("POST", "/prompt-templates/title", `{"body":"Vorlage-Marker","created_by":"u"}`); code != http.StatusCreated { + t.Fatalf("template save: %d", code) + } + + got := title("tm-a") + if got.Model != "big" { + t.Errorf("mit Wahl: model = %q, want big", got.Model) + } + if !strings.HasPrefix(got.Messages[0].Content, "Vorlage-Marker") { + t.Errorf("system = %q, want Vorlage", got.Messages[0].Content) + } + if other := title("tm-b"); other.Model != "standard" { + t.Errorf("anderer Mandant: model = %q, want standard", other.Model) + } +} + func newTestPool(t *testing.T) *pgxpool.Pool { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") diff --git a/ai/docs/ROUTEN-FEATUREAPI.md b/ai/docs/ROUTEN-FEATUREAPI.md index af372d84..b5450319 100644 --- a/ai/docs/ROUTEN-FEATUREAPI.md +++ b/ai/docs/ROUTEN-FEATUREAPI.md @@ -21,7 +21,18 @@ Alle Routen verlangen `X-Service-Token`. Verdrahtet in `ai/cmd/ai-app/main.go` ( - `classify` und `doctype` liefern `meets_threshold` gegen die Schwelle aus `confidence` (Standard 0,7). Die Schwelle entscheidet nur die Anzeige, nichts wird automatisch übernommen. - Embeddings: `ingest` reiht nur ein. Verarbeitet werden die Aufträge vom vorhandenen Queue-Worker. +## Modellwahl und Vorlage im Inferenzpfad + +`internal/tenantprovider` umschliesst den Provider. Fuer die Funktionen `title`, `correspondent`, `tags` (suggestapi) sowie `classify`, `doctype`, `highlight` (featureapi) gilt pro Aufruf: + +- Modell: Eintrag aus `modelconfig` fuer Mandant und Funktion, sonst `NEXARCH_AI_APP_MODEL`. Nur Eintraege mit Provider `ollama` greifen. +- Systemprompt: die aktive `prompttemplate`-Version zum Funktionsnamen ersetzt die erste Systemnachricht. Die Schutzanweisung gegen Prompt-Injection (`promptguard.SystemGuidance`) wird immer angehaengt. Die Vorlage gilt fuer alle Mandanten, sie ist nicht mandantenbezogen. +- Lesefehler oder fehlender Eintrag: Standardmodell und Standardprompt, ein Lesefehler wird geloggt. + +Der Nutzerprompt mit Dokumenttext und Ausgabeformat bleibt im Code. Eine Vorlage ersetzt nur die Rollenanweisung, nicht das geforderte JSON-Format. + ## Nicht angebunden -- `modelconfig` und `prompttemplate` speichern nur. Kein Inferenzpfad liest die Mandanten-Modellwahl oder die aktive Vorlage; Suggest-, Summary- und Extract-Routen nutzen weiter `NEXARCH_AI_APP_MODEL` und die festen Prompts. `modelconfig` kennt nur den Provider `ollama`. +- Summary-, Extract-, Docchat- und Suche-Routen: kein `tenant_slug` bzw. eigene Prompts, daher keine Modellwahl und keine Vorlage. +- Embeddings: Modellwechsel wuerde die Vektoren unvergleichbar machen, deshalb nicht angebunden. - `notraining` ist ein reiner Architekturtest, `refcorpus` ein Testkorpus. Beide haben keine Laufzeitfunktion und bekommen keine Route. diff --git a/ai/internal/featureapi/featureapi.go b/ai/internal/featureapi/featureapi.go index 389e4695..95cfe20a 100644 --- a/ai/internal/featureapi/featureapi.go +++ b/ai/internal/featureapi/featureapi.go @@ -17,6 +17,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" "gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryquality" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantprovider" "gitea.perlbach24.de/scripte/nexarch/common/svcauth" ) @@ -184,9 +185,9 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { ) if audited { dc := classify.DecisionContext{Recorder: s.Audit, TenantSlug: req.TenantSlug, Actor: req.Actor, PromptVersion: req.PromptVersion} - res, err = s.Classifier.ClassifyWithSchemaAudited(r.Context(), schema, req.Text, dc, req.InputRef) + res, err = s.Classifier.ClassifyWithSchemaAudited(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionClassify), schema, req.Text, dc, req.InputRef) } else { - res, err = s.Classifier.ClassifyWithSchema(r.Context(), schema, req.Text) + res, err = s.Classifier.ClassifyWithSchema(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionClassify), schema, req.Text) } if err != nil { writeError(w, http.StatusBadGateway, "klassifizierung fehlgeschlagen") @@ -232,7 +233,7 @@ func (s *Server) handleDoctypeDetect(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, "keine dokumenttyp-taxonomie fuer diesen mandanten") return } - res, err := s.Doctype.Detect(r.Context(), req.TenantSlug, req.Text) + res, err := s.Doctype.Detect(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionDoctype), req.TenantSlug, req.Text) if err != nil { writeError(w, http.StatusBadGateway, "dokumenttyp-erkennung fehlgeschlagen") return @@ -299,7 +300,7 @@ func (s *Server) handleHighlight(w http.ResponseWriter, r *http.Request) { if !s.enabled(w, r, req.TenantSlug, FunctionHighlight) { return } - passages, err := s.Highlight.DetectImportant(r.Context(), req.Text) + passages, err := s.Highlight.DetectImportant(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionHighlight), req.Text) resp := map[string]any{"passages": nonNil(passages)} if err != nil { // Teilergebnisse bleiben erhalten (highlight liefert sie neben dem Fehler). diff --git a/ai/internal/suggestapi/suggestapi.go b/ai/internal/suggestapi/suggestapi.go index 02de1c21..63932398 100644 --- a/ai/internal/suggestapi/suggestapi.go +++ b/ai/internal/suggestapi/suggestapi.go @@ -12,6 +12,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/tagsuggest" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantprovider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/titlesuggest" "gitea.perlbach24.de/scripte/nexarch/common/svcauth" ) @@ -112,7 +113,7 @@ func (s *Server) handleTitle(w http.ResponseWriter, r *http.Request) { return } sug := &titlesuggest.Suggester{Provider: s.Provider, Model: s.Model} - titles, err := sug.Suggest(r.Context(), req.Module, res.Output.Name, req.Text) + titles, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "title"), req.Module, res.Output.Name, req.Text) if err != nil { writeError(w, http.StatusBadGateway, "titelvorschlag fehlgeschlagen") return @@ -134,7 +135,7 @@ func (s *Server) handleCorrespondent(w http.ResponseWriter, r *http.Request) { return } sug := &correspondentsuggest.Suggester{Provider: s.Provider, Model: s.Model} - got, found, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text) + got, found, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "correspondent"), req.Existing, req.Module, req.Text) if err != nil { writeError(w, http.StatusBadGateway, "korrespondentenvorschlag fehlgeschlagen") return @@ -157,7 +158,7 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) { return } sug := &tagsuggest.Suggester{Provider: s.Provider, Model: s.Model, Language: res.Output.Name} - tags, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text) + tags, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "tags"), req.Existing, req.Module, req.Text) if err != nil { writeError(w, http.StatusBadGateway, "tagvorschlag fehlgeschlagen") return diff --git a/ai/internal/tenantprovider/tenantprovider.go b/ai/internal/tenantprovider/tenantprovider.go new file mode 100644 index 00000000..41ed87f3 --- /dev/null +++ b/ai/internal/tenantprovider/tenantprovider.go @@ -0,0 +1,100 @@ +package tenantprovider + +import ( + "context" + "errors" + "log" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/promptguard" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +type Models interface { + Get(ctx context.Context, tenantSlug, function string) (modelconfig.Selection, error) +} + +type Templates interface { + Active(ctx context.Context, functionKey string) (prompttemplate.Version, error) +} + +type callKey struct{} + +type call struct{ tenant, function string } + +// WithCall markiert den Kontext mit Mandant und Funktion; nur dann greifen Modellwahl und Vorlage. +func WithCall(ctx context.Context, tenantSlug, function string) context.Context { + return context.WithValue(ctx, callKey{}, call{tenantSlug, function}) +} + +// Provider wendet pro Aufruf die Modellwahl (modelconfig) und die aktive Systemvorlage +// (prompttemplate) an. Ohne Eintrag oder bei Lesefehlern bleibt die Anfrage unveraendert. +type Provider struct { + Inner provider.Provider + // ProviderName ist der Name, unter dem Inner in modelconfig gefuehrt wird; andere Eintraege werden ignoriert. + ProviderName string + Models Models + Templates Templates +} + +var _ provider.Provider = (*Provider)(nil) + +func (p *Provider) model(ctx context.Context, c call, current string) string { + sel, err := p.Models.Get(ctx, c.tenant, c.function) + if err != nil { + if !errors.Is(err, modelconfig.ErrNotFound) { + log.Printf("tenantprovider: modellwahl %s/%s nicht lesbar, Standardmodell: %v", c.tenant, c.function, err) + } + return current + } + if sel.ProviderName != p.ProviderName || sel.Model == "" { + return current + } + return sel.Model +} + +func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) { + if c, ok := ctx.Value(callKey{}).(call); ok { + req.Model = p.model(ctx, c, req.Model) + req.Messages = p.withTemplate(ctx, c, req.Messages) + } + return p.Inner.Chat(ctx, req) +} + +func (p *Provider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) { + if c, ok := ctx.Value(callKey{}).(call); ok { + req.Model = p.model(ctx, c, req.Model) + } + return p.Inner.Complete(ctx, req) +} + +func (p *Provider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return p.Inner.Embed(ctx, req) +} + +// withTemplate ersetzt die erste Systemnachricht; die Schutzanweisung gegen Prompt-Injection bleibt immer erhalten. +func (p *Provider) withTemplate(ctx context.Context, c call, msgs []provider.Message) []provider.Message { + v, err := p.Templates.Active(ctx, c.function) + if err != nil { + if !errors.Is(err, prompttemplate.ErrNotFound) { + log.Printf("tenantprovider: vorlage %s nicht lesbar, Standardprompt: %v", c.function, err) + } + return msgs + } + sys := provider.Message{Role: provider.RoleSystem, Content: v.Body + " " + promptguard.SystemGuidance} + out := make([]provider.Message, 0, len(msgs)+1) + replaced := false + for _, m := range msgs { + if m.Role == provider.RoleSystem && !replaced { + out = append(out, sys) + replaced = true + continue + } + out = append(out, m) + } + if !replaced { + out = append([]provider.Message{sys}, out...) + } + return out +} diff --git a/ai/internal/tenantprovider/tenantprovider_test.go b/ai/internal/tenantprovider/tenantprovider_test.go new file mode 100644 index 00000000..ab9364dc --- /dev/null +++ b/ai/internal/tenantprovider/tenantprovider_test.go @@ -0,0 +1,137 @@ +package tenantprovider + +import ( + "context" + "errors" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/promptguard" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +type spy struct { + chat provider.ChatRequest + comp provider.CompletionRequest +} + +func (s *spy) Chat(_ context.Context, r provider.ChatRequest) (provider.ChatResponse, error) { + s.chat = r + return provider.ChatResponse{}, nil +} +func (s *spy) Complete(_ context.Context, r provider.CompletionRequest) (provider.CompletionResponse, error) { + s.comp = r + return provider.CompletionResponse{}, nil +} +func (s *spy) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return provider.EmbeddingResponse{}, nil +} + +type models map[string]modelconfig.Selection + +func (m models) Get(_ context.Context, t, f string) (modelconfig.Selection, error) { + if s, ok := m[t+"/"+f]; ok { + return s, nil + } + return modelconfig.Selection{}, modelconfig.ErrNotFound +} + +type templates map[string]string + +func (m templates) Active(_ context.Context, k string) (prompttemplate.Version, error) { + if b, ok := m[k]; ok { + return prompttemplate.Version{FunctionKey: k, Version: 1, Body: b}, nil + } + return prompttemplate.Version{}, prompttemplate.ErrNotFound +} + +type failing struct{} + +func (failing) Get(context.Context, string, string) (modelconfig.Selection, error) { + return modelconfig.Selection{}, errors.New("db down") +} +func (failing) Active(context.Context, string) (prompttemplate.Version, error) { + return prompttemplate.Version{}, errors.New("db down") +} + +var base = provider.ChatRequest{Model: "default", Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "alt"}, {Role: provider.RoleUser, Content: "text"}, +}} + +func newP(inner provider.Provider, m Models, t Templates) *Provider { + return &Provider{Inner: inner, ProviderName: "ollama", Models: m, Templates: t} +} + +func TestChat_AppliesTenantModelAndTemplate(t *testing.T) { + s := &spy{} + p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{"title": "neu"}) + _, _ = p.Chat(WithCall(context.Background(), "acme", "title"), base) + if s.chat.Model != "big" { + t.Errorf("model = %q, want big", s.chat.Model) + } + if got := s.chat.Messages[0].Content; !strings.HasPrefix(got, "neu ") || !strings.HasSuffix(got, promptguard.SystemGuidance) { + t.Errorf("system = %q, want vorlage + schutzanweisung", got) + } + if len(s.chat.Messages) != 2 || s.chat.Messages[1].Content != "text" { + t.Errorf("nutzernachricht veraendert: %+v", s.chat.Messages) + } +} + +func TestChat_OtherTenantKeepsDefaultModel(t *testing.T) { + s := &spy{} + p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{}) + _, _ = p.Chat(WithCall(context.Background(), "globex", "title"), base) + if s.chat.Model != "default" { + t.Errorf("model = %q, want default", s.chat.Model) + } + if s.chat.Messages[0].Content != "alt" { + t.Errorf("system = %q, want unveraendert", s.chat.Messages[0].Content) + } +} + +func TestChat_ForeignProviderSelectionIgnored(t *testing.T) { + s := &spy{} + p := newP(s, models{"acme/title": {ProviderName: "openai", Model: "gpt"}}, templates{}) + _, _ = p.Chat(WithCall(context.Background(), "acme", "title"), base) + if s.chat.Model != "default" { + t.Errorf("model = %q, want default", s.chat.Model) + } +} + +func TestChat_WithoutCallContextUntouched(t *testing.T) { + s := &spy{} + p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{"title": "neu"}) + _, _ = p.Chat(context.Background(), base) + if s.chat.Model != "default" || s.chat.Messages[0].Content != "alt" { + t.Errorf("ohne call-kontext veraendert: %+v", s.chat) + } +} + +func TestChat_ReadErrorsFallBackToDefaults(t *testing.T) { + s := &spy{} + p := newP(s, failing{}, failing{}) + _, err := p.Chat(WithCall(context.Background(), "acme", "title"), base) + if err != nil || s.chat.Model != "default" || s.chat.Messages[0].Content != "alt" { + t.Errorf("err=%v req=%+v", err, s.chat) + } +} + +func TestChat_TemplateWithoutSystemMessageIsPrepended(t *testing.T) { + s := &spy{} + p := newP(s, models{}, templates{"title": "neu"}) + _, _ = p.Chat(WithCall(context.Background(), "a", "title"), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}}) + if len(s.chat.Messages) != 2 || s.chat.Messages[0].Role != provider.RoleSystem { + t.Errorf("messages = %+v", s.chat.Messages) + } +} + +func TestComplete_AppliesModelOnly(t *testing.T) { + s := &spy{} + p := newP(s, models{"acme/f": {ProviderName: "ollama", Model: "big"}}, templates{"f": "neu"}) + _, _ = p.Complete(WithCall(context.Background(), "acme", "f"), provider.CompletionRequest{Model: "default", Prompt: "p"}) + if s.comp.Model != "big" || s.comp.Prompt != "p" { + t.Errorf("comp = %+v", s.comp) + } +}