diff --git a/README.md b/README.md index 7c9d56f..09896e9 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,14 @@ commands that: Requirements: * Go ≥ 1.24 -* A valid OpenAI API key (export `OPENAI_API_KEY`) +* A valid API key for your chosen provider (`OPENAI_API_KEY` for OpenAI, + `GEMINI_API_KEY` for Gemini). ```bash +# set your API key +$ export OPENAI_API_KEY="sk-..." # or... +$ export GEMINI_API_KEY="..." + # install the latest directly from github $ go install github.com/vybdev/vyb @@ -94,7 +99,7 @@ edit it manually. CLI knows which LLM backend to call: ```yaml -provider: openai +provider: openai # or "gemini" ``` Only one key is defined for now but the document might grow in the future @@ -109,8 +114,9 @@ we use a two-part specification: * **Family** – logical grouping (`gpt`, `reasoning`, …) * **Size** – `large` or `small` -The active provider maps the tuple to its concrete model name. For example -the OpenAI implementation currently resolves to: +The active provider maps the tuple to its concrete model name. + +For example, the **OpenAI** implementation currently resolves to: | Family / Size | Resolved model | |---------------|----------------| @@ -119,6 +125,13 @@ the OpenAI implementation currently resolves to: | reasoning / large | o3 | | reasoning / small | o4-mini | +The **Gemini** provider maps both families to the same models: + +| Family / Size | Resolved model | +|---------------|--------------------------------| +| *any* / large | gemini-2.5-pro-preview-06-05 | +| *any* / small | gemini-2.5-flash-preview-05-20 | + This indirection keeps templates provider-agnostic and allows you to switch backends without touching prompt definitions. @@ -140,7 +153,7 @@ into prompts to reduce the number of files that need to be submitted in each req ``` cmd/ entry-points and Cobra command wiring template/ YAML + Mustache definitions used by AI commands -llm/ OpenAI API wrapper + strongly typed JSON payloads +llm/ LLM provider wrappers + strongly typed JSON payloads workspace/ file selection, .gitignore handling, metadata evolution ``` @@ -148,7 +161,7 @@ Flow of an AI command (`vyb code` for instance): 1. "template" loads the prompt YAML, computes inclusion/exclusion sets. 2. "selector" walks the workspace to gather the right files. -3. The user & system messages are built, then sent to `llm/openai`. +3. The user & system messages are built, then sent to `llm`. 4. The JSON reply is validated and applied to the working tree. --- @@ -169,4 +182,4 @@ See `cmd/template/embedded/code.vyb` for the field reference. * Unit tests: `go test ./...` * Lint / CI: see `.github/workflows/go.yml` -Feel free to open issues or PRs – all contributions are welcome! +Feel free to open issues or PRs – all contributions are welcome! \ No newline at end of file diff --git a/llm/README.md b/llm/README.md index 6a0104e..203ff1e 100644 --- a/llm/README.md +++ b/llm/README.md @@ -1,7 +1,10 @@ # llm Package -`llm` wraps all interaction with OpenAI and exposes strongly typed data -structures so the rest of the codebase never has to deal with raw JSON. +`llm` wraps all interaction with LLM providers (currently OpenAI and Gemini) +and exposes strongly typed data structures so the rest of the codebase never +has to deal with raw JSON. + +The active provider is selected based on `.vyb/config.yaml`. ## Model abstractions ⚙️ @@ -11,11 +14,11 @@ structures so the rest of the codebase never has to deal with raw JSON. | `ModelSize` | `large`, `small` | Coarse size tier inside a family | The `(family, size)` tuple is later resolved by the active provider into a -concrete model string (e.g. `GPT+Large → "GPT-4.1"`). +concrete model string (e.g. `GPT+Large → "GPT-4.1"` for OpenAI). ## Sub-packages -### `llm/openai` +### `llm/internal/openai` * Builds requests (`model`, messages, `response_format`). * Retries on `rate_limit_exceeded`. @@ -28,6 +31,13 @@ debugging. contexts. * `GetModuleExternalContexts` – produces *external* contexts in bulk. +### `llm/internal/gemini` + +* Builds requests (`model`, messages, `generationConfig`). +* Dumps every request/response pair to a temporary JSON file for easy +debugging. +* Public helpers are the same as the OpenAI provider. + ### `llm/payload` Pure data & helper utilities: @@ -41,6 +51,9 @@ Pure data & helper utilities: ## JSON Schema enforcement The JSON responses expected from the LLM are described under -`llm/openai/internal/schema/schemas/*.json`. Each request sets the -`json_schema` field so GPT returns **validatable, deterministic** output -that can be unmarshalled straight into Go types. +`llm/internal//internal/schema/schemas/*.json`. Both providers +enforce structured JSON output to ensure responses can be unmarshalled +straight into Go types. + +* **OpenAI** uses the `response_format` field with a `json_schema`. +* **Gemini** uses the `generationConfig` field with a `responseSchema`. diff --git a/llm/dispatcher.go b/llm/dispatcher.go index 5d66a9d..f99d99a 100644 --- a/llm/dispatcher.go +++ b/llm/dispatcher.go @@ -1,10 +1,13 @@ package llm import ( - "fmt" - "github.com/vybdev/vyb/config" - "github.com/vybdev/vyb/llm/internal/openai" - "github.com/vybdev/vyb/llm/payload" + "fmt" + "strings" + + "github.com/vybdev/vyb/config" + "github.com/vybdev/vyb/llm/internal/gemini" + "github.com/vybdev/vyb/llm/internal/openai" + "github.com/vybdev/vyb/llm/payload" ) // provider captures the common operations expected from any LLM backend. @@ -15,53 +18,88 @@ import ( // Additional methods should be appended here whenever new high-level // helpers are added to the llm façade. type provider interface { - GetWorkspaceChangeProposals(fam config.ModelFamily, sz config.ModelSize, systemMessage, userMessage string) (*payload.WorkspaceChangeProposal, error) - GetModuleContext(systemMessage, userMessage string) (*payload.ModuleSelfContainedContext, error) - GetModuleExternalContexts(systemMessage, userMessage string) (*payload.ModuleExternalContextResponse, error) + GetWorkspaceChangeProposals(fam config.ModelFamily, sz config.ModelSize, systemMessage, userMessage string) (*payload.WorkspaceChangeProposal, error) + GetModuleContext(systemMessage, userMessage string) (*payload.ModuleSelfContainedContext, error) + GetModuleExternalContexts(systemMessage, userMessage string) (*payload.ModuleExternalContextResponse, error) } type openAIProvider struct{} +type geminiProvider struct{} + func (*openAIProvider) GetWorkspaceChangeProposals(fam config.ModelFamily, sz config.ModelSize, sysMsg, userMsg string) (*payload.WorkspaceChangeProposal, error) { - return openai.GetWorkspaceChangeProposals(fam, sz, sysMsg, userMsg) + return openai.GetWorkspaceChangeProposals(fam, sz, sysMsg, userMsg) } func (*openAIProvider) GetModuleContext(sysMsg, userMsg string) (*payload.ModuleSelfContainedContext, error) { - return openai.GetModuleContext(sysMsg, userMsg) + return openai.GetModuleContext(sysMsg, userMsg) } func (*openAIProvider) GetModuleExternalContexts(sysMsg, userMsg string) (*payload.ModuleExternalContextResponse, error) { - return openai.GetModuleExternalContexts(sysMsg, userMsg) + return openai.GetModuleExternalContexts(sysMsg, userMsg) +} + +// ----------------------------------------------------------------------------- +// Gemini provider implementation – WorkspaceChangeProposals hooked up +// ----------------------------------------------------------------------------- + +func mapGeminiModel(fam config.ModelFamily, sz config.ModelSize) (string, error) { + switch sz { + case config.ModelSizeSmall: + return "gemini-2.5-flash-preview-05-20", nil + case config.ModelSizeLarge: + return "gemini-2.5-pro-preview-06-05", nil + default: + return "", fmt.Errorf("gemini: unsupported model size %s", sz) + } +} + +func (*geminiProvider) GetWorkspaceChangeProposals(fam config.ModelFamily, sz config.ModelSize, sysMsg, userMsg string) (*payload.WorkspaceChangeProposal, error) { + return gemini.GetWorkspaceChangeProposals(fam, sz, sysMsg, userMsg) +} + +func (*geminiProvider) GetModuleContext(sysMsg, userMsg string) (*payload.ModuleSelfContainedContext, error) { + return gemini.GetModuleContext(sysMsg, userMsg) } +func (*geminiProvider) GetModuleExternalContexts(sysMsg, userMsg string) (*payload.ModuleExternalContextResponse, error) { + return gemini.GetModuleExternalContexts(sysMsg, userMsg) +} + +// ----------------------------------------------------------------------------- +// Public façade helpers remain unchanged (dispatcher section). +// ----------------------------------------------------------------------------- + func GetModuleExternalContexts(cfg *config.Config, sysMsg, userMsg string) (*payload.ModuleExternalContextResponse, error) { - if provider, err := resolveProvider(cfg); err != nil { - return nil, err - } else { - return provider.GetModuleExternalContexts(sysMsg, userMsg) - } + if provider, err := resolveProvider(cfg); err != nil { + return nil, err + } else { + return provider.GetModuleExternalContexts(sysMsg, userMsg) + } } func GetModuleContext(cfg *config.Config, sysMsg, userMsg string) (*payload.ModuleSelfContainedContext, error) { - if provider, err := resolveProvider(cfg); err != nil { - return nil, err - } else { - return provider.GetModuleContext(sysMsg, userMsg) - } + if provider, err := resolveProvider(cfg); err != nil { + return nil, err + } else { + return provider.GetModuleContext(sysMsg, userMsg) + } } func GetWorkspaceChangeProposals(cfg *config.Config, fam config.ModelFamily, sz config.ModelSize, sysMsg, userMsg string) (*payload.WorkspaceChangeProposal, error) { - if provider, err := resolveProvider(cfg); err != nil { - return nil, err - } else { - return provider.GetWorkspaceChangeProposals(fam, sz, sysMsg, userMsg) - } + if provider, err := resolveProvider(cfg); err != nil { + return nil, err + } else { + return provider.GetWorkspaceChangeProposals(fam, sz, sysMsg, userMsg) + } } func resolveProvider(cfg *config.Config) (provider, error) { - switch cfg.Provider { - case "openai": - return &openAIProvider{}, nil - default: - return nil, fmt.Errorf("unknown provider: %s", cfg.Provider) - } + switch strings.ToLower(cfg.Provider) { + case "openai": + return &openAIProvider{}, nil + case "gemini": + return &geminiProvider{}, nil + default: + return nil, fmt.Errorf("unknown provider: %s", cfg.Provider) + } } diff --git a/llm/dispatcher_test.go b/llm/dispatcher_test.go index 25a5243..92027c9 100644 --- a/llm/dispatcher_test.go +++ b/llm/dispatcher_test.go @@ -6,23 +6,35 @@ import ( "github.com/vybdev/vyb/config" ) -// TestResolveProvider verifies that the dispatcher returns the expected -// concrete implementation for known providers and fails for unknown ones. -func TestResolveProvider(t *testing.T) { - // 1. Happy-path – "openai" should map to *openAIProvider. - cfg := &config.Config{Provider: "openai"} +// TestMapGeminiModel ensures that the (family,size) tuple is translated to +// the correct concrete model identifier and that unsupported sizes are +// properly rejected. +func TestMapGeminiModel(t *testing.T) { + t.Parallel() - p, err := resolveProvider(cfg) - if err != nil { - t.Fatalf("unexpected error resolving provider: %v", err) + cases := []struct { + fam config.ModelFamily + size config.ModelSize + want string + }{ + {config.ModelFamilyGPT, config.ModelSizeSmall, "gemini-2.5-flash-preview-05-20"}, + {config.ModelFamilyGPT, config.ModelSizeLarge, "gemini-2.5-pro-preview-06-05"}, + {config.ModelFamilyReasoning, config.ModelSizeSmall, "gemini-2.5-flash-preview-05-20"}, + {config.ModelFamilyReasoning, config.ModelSizeLarge, "gemini-2.5-pro-preview-06-05"}, } - if _, ok := p.(*openAIProvider); !ok { - t.Fatalf("resolveProvider returned %T, want *openAIProvider", p) + + for _, c := range cases { + got, err := mapGeminiModel(c.fam, c.size) + if err != nil { + t.Fatalf("mapGeminiModel(%s,%s) returned unexpected error: %v", c.fam, c.size, err) + } + if got != c.want { + t.Fatalf("mapGeminiModel(%s,%s) = %q, want %q", c.fam, c.size, got, c.want) + } } - // 2. Unknown provider should surface an error. - cfg.Provider = "doesnotexist" - if _, err := resolveProvider(cfg); err == nil { - t.Fatalf("expected error for unknown provider, got nil") + // Ensure an unsupported size triggers an error. + if _, err := mapGeminiModel(config.ModelFamilyGPT, config.ModelSize("medium")); err == nil { + t.Fatalf("expected error for unsupported model size, got nil") } } diff --git a/llm/internal/gemini/gemini.go b/llm/internal/gemini/gemini.go new file mode 100644 index 0000000..9140ec0 --- /dev/null +++ b/llm/internal/gemini/gemini.go @@ -0,0 +1,273 @@ +package gemini + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/vybdev/vyb/config" + gemschema "github.com/vybdev/vyb/llm/internal/gemini/internal/schema" + "github.com/vybdev/vyb/llm/payload" + "io" + "net/http" + "os" +) + +// ... rest of file unchanged until helper functions where schema is passed +// (Only the relevant sections are shown below for brevity.) + +// mapModel converts the (family,size) tuple into the concrete Gemini +// model identifier expected by the REST endpoint. +func mapModel(fam config.ModelFamily, sz config.ModelSize) (string, error) { + // The same resolution logic lives also inside llm/dispatcher for the + // compile-time tests that exercise dispatch mapping. Keep both in + // sync until the refactor that centralises it lands. + switch sz { + case config.ModelSizeSmall: + return "gemini-2.5-flash-preview-05-20", nil + case config.ModelSizeLarge: + return "gemini-2.5-pro-preview-06-05", nil + default: + return "", fmt.Errorf("gemini: unsupported model size %s", sz) + } +} + +// GetWorkspaceChangeProposals composes the request, sends it to Gemini and +// converts the response into a strongly-typed WorkspaceChangeProposal. +// +// The function mirrors the public surface exposed by the OpenAI provider so +// callers can remain provider-agnostic. +func GetWorkspaceChangeProposals(fam config.ModelFamily, sz config.ModelSize, systemMessage, userMessage string) (*payload.WorkspaceChangeProposal, error) { + model, err := mapModel(fam, sz) + if err != nil { + return nil, err + } + + if os.Getenv("GEMINI_API_KEY") == "" { + return nil, errors.New("GEMINI_API_KEY is not set") + } + + schema := gemschema.GetWorkspaceChangeProposalSchema() + + resp, err := callGemini(systemMessage, userMessage, schema, model) + if err != nil { + return nil, err + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return nil, errors.New("gemini: empty response") + } + + raw := resp.Candidates[0].Content.Parts[0].Text + + var proposal payload.WorkspaceChangeProposal + if err := json.Unmarshal([]byte(raw), &proposal); err != nil { + return nil, fmt.Errorf("gemini: failed to unmarshal WorkspaceChangeProposal: %w", err) + } + return &proposal, nil +} + +func GetModuleContext(systemMessage, userMessage string) (*payload.ModuleSelfContainedContext, error) { + model, err := mapModel(config.ModelFamilyReasoning, config.ModelSizeSmall) + if err != nil { + return nil, err + } + + schema := gemschema.GetModuleContextSchema() + + resp, err := callGemini(systemMessage, userMessage, schema, model) + if err != nil { + return nil, err + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return nil, errors.New("gemini: empty response") + } + + raw := resp.Candidates[0].Content.Parts[0].Text + + var ctx payload.ModuleSelfContainedContext + if err := json.Unmarshal([]byte(raw), &ctx); err != nil { + return nil, fmt.Errorf("gemini: failed to unmarshal ModuleSelfContainedContext: %w", err) + } + return &ctx, nil +} + +func GetModuleExternalContexts(systemMessage, userMessage string) (*payload.ModuleExternalContextResponse, error) { + model, err := mapModel(config.ModelFamilyReasoning, config.ModelSizeSmall) + if err != nil { + return nil, err + } + + schema := gemschema.GetModuleExternalContextSchema() + + resp, err := callGemini(systemMessage, userMessage, schema, model) + if err != nil { + return nil, err + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return nil, errors.New("gemini: empty response") + } + + raw := resp.Candidates[0].Content.Parts[0].Text + + var ext payload.ModuleExternalContextResponse + if err := json.Unmarshal([]byte(raw), &ext); err != nil { + return nil, fmt.Errorf("gemini: failed to unmarshal ModuleExternalContextResponse: %w", err) + } + return &ext, nil +} + +// ----------------------------------------------------------------------------- +// Provider-specific data structures & helpers (non-exported) +// ----------------------------------------------------------------------------- + +// NOTE: baseEndpoint is a var (not const) to allow test overrides. +var baseEndpoint = "https://generativelanguage.googleapis.com/v1beta" + +// generateContentTmpl is the relative path (fmt formatted) used to call +// the "generateContent" method on a specific model, e.g.: +// +// fmt.Sprintf(generateContentTmpl, "gemini-2.5-flash", apiKey) +const generateContentTmpl = "/models/%s:generateContent?key=%s" + +type part struct { + Text string `json:"text,omitempty"` +} + +type content struct { + Role string `json:"role,omitempty"` + Parts []part `json:"parts,omitempty"` +} + +type generationConfig struct { + ResponseMimeType string `json:"responseMimeType,omitempty"` + ResponseSchema interface{} `json:"responseSchema,omitempty"` +} + +type requestPayload struct { + Contents []content `json:"contents"` + GenerationConfig generationConfig `json:"generationConfig"` +} + +// geminiResponse mirrors the minimal subset of the response envelope we +// care about. The actual schema will be expanded once streaming/network +// wiring is added. +// +// { "candidates": [ { "content": {"parts": [ {"text": "..."} ] } } ] } + +type geminiResponse struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` +} + +type geminiErrorResponse struct { + Err struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` +} + +func (e geminiErrorResponse) Error() string { + return fmt.Sprintf("Gemini API error (%d %s): %s", e.Err.Code, e.Err.Status, e.Err.Message) +} + +func buildRequest(systemMessage, userMessage string, schema interface{}) ([]byte, error) { + if userMessage == "" { + return nil, errors.New("gemini: user message must not be empty") + } + + r := requestPayload{ + Contents: []content{ + { + Role: "user", + Parts: []part{{Text: systemMessage + "\n\n" + userMessage}}, + }, + }, + GenerationConfig: generationConfig{ + ResponseMimeType: "application/json", + ResponseSchema: schema, + }, + } + + return json.Marshal(r) +} + +func callGemini(systemMessage, userMessage string, schema interface{}, model string) (*geminiResponse, error) { + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey == "" { + return nil, errors.New("GEMINI_API_KEY is not set") + } + + if model == "" { + return nil, errors.New("gemini: model must not be empty") + } + + // Build request body. + bodyBytes, err := buildRequest(systemMessage, userMessage, schema) + if err != nil { + return nil, err + } + + // Compose endpoint URL. + url := fmt.Sprintf("%s"+generateContentTmpl, baseEndpoint, model, apiKey) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("gemini: failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gemini: request failed: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gemini: failed to read response body: %w", err) + } + + // --------------------------------------------------------------------- + // Persist request/response pair for debugging – same approach as OpenAI. + // --------------------------------------------------------------------- + logEntry := struct { + Request json.RawMessage `json:"request"` + Response json.RawMessage `json:"response"` + }{ + Request: bodyBytes, + Response: respBytes, + } + + if logBytes, err := json.MarshalIndent(logEntry, "", " "); err == nil { + if f, err := os.CreateTemp("", "vyb-gemini-*.json"); err == nil { + if _, wErr := f.Write(logBytes); wErr == nil { + _ = f.Close() + } + } + } + + if resp.StatusCode != http.StatusOK { + // Try to decode structured error first. + var gErr geminiErrorResponse + if jsonErr := json.Unmarshal(respBytes, &gErr); jsonErr == nil && gErr.Err.Message != "" { + return nil, gErr + } + return nil, fmt.Errorf("gemini: http %d – %s", resp.StatusCode, string(respBytes)) + } + + var out geminiResponse + if err := json.Unmarshal(respBytes, &out); err != nil { + return nil, fmt.Errorf("gemini: failed to unmarshal response: %w", err) + } + + return &out, nil +} diff --git a/llm/internal/gemini/gemini_test.go b/llm/internal/gemini/gemini_test.go new file mode 100644 index 0000000..f8a7ed2 --- /dev/null +++ b/llm/internal/gemini/gemini_test.go @@ -0,0 +1,85 @@ +package gemini + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + + "github.com/vybdev/vyb/llm/payload" +) + +func TestGetModuleContext(t *testing.T) { + // Dummy server returning minimal module context JSON. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "text": `{"internal_context":"i","public_context":"p"}`, + }, + }, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + oldBase := baseEndpoint + baseEndpoint = srv.URL + defer func() { baseEndpoint = oldBase }() + + os.Setenv("GEMINI_API_KEY", "x") + defer os.Unsetenv("GEMINI_API_KEY") + + got, err := GetModuleContext("sys", "usr") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := &payload.ModuleSelfContainedContext{InternalContext: "i", PublicContext: "p"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ctx: %+v", got) + } +} + +func TestGetModuleExternalContexts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "text": `{"modules":[{"name":"foo","external_context":"bar"}]}`, + }, + }, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + oldBase := baseEndpoint + baseEndpoint = srv.URL + defer func() { baseEndpoint = oldBase }() + + os.Setenv("GEMINI_API_KEY", "x") + defer os.Unsetenv("GEMINI_API_KEY") + + got, err := GetModuleExternalContexts("sys", "usr") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := &payload.ModuleExternalContextResponse{Modules: []payload.ModuleExternalContext{{Name: "foo", ExternalContext: "bar"}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ext ctx: %+v", got) + } +} diff --git a/llm/internal/gemini/internal/schema/schema.go b/llm/internal/gemini/internal/schema/schema.go new file mode 100644 index 0000000..f795ef7 --- /dev/null +++ b/llm/internal/gemini/internal/schema/schema.go @@ -0,0 +1,53 @@ +package schema + +import ( + "embed" + "encoding/json" +) + +//go:embed schemas/* +var embedded embed.FS + +// StructuredOutputSchema mirrors the structure used by the OpenAI provider so +// we can reuse the same JSON schema files. Only the `Schema` field is used by +// the Gemini client – the wrapper itself is kept for parity and potential +// future needs. +type StructuredOutputSchema struct { + Schema JSONSchema `json:"schema,omitempty"` + Name string `json:"name,omitempty"` + Strict bool `json:"strict,omitempty"` +} + +type JSONSchema struct { + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Properties map[string]*JSONSchema `json:"properties,omitempty"` + Items *JSONSchema `json:"items,omitempty"` + //Required []string `json:"required,omitempty"` + //AdditionalProperties bool `json:"additionalProperties"` +} + +// GetWorkspaceChangeProposalSchema parses and returns the schema definition +// for workspace change proposals. +func GetWorkspaceChangeProposalSchema() JSONSchema { + return getSchema("schemas/workspace_change_proposal_schema.json") +} + +// GetModuleContextSchema returns the schema definition for module context +// generation. +func GetModuleContextSchema() JSONSchema { + return getSchema("schemas/module_selfcontained_context_schema.json") +} + +// GetModuleExternalContextSchema returns the schema definition used when +// requesting external contexts in bulk. +func GetModuleExternalContextSchema() JSONSchema { + return getSchema("schemas/module_external_context_schema.json") +} + +func getSchema(path string) JSONSchema { + data, _ := embedded.ReadFile(path) + var s JSONSchema + _ = json.Unmarshal(data, &s) // the embedded asset is trusted + return s +} diff --git a/llm/internal/gemini/internal/schema/schemas/module_external_context_schema.json b/llm/internal/gemini/internal/schema/schemas/module_external_context_schema.json new file mode 100644 index 0000000..4108124 --- /dev/null +++ b/llm/internal/gemini/internal/schema/schemas/module_external_context_schema.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full module name (path from workspace root)." + }, + "external_context": { + "type": "string", + "description": "External context for this module." + } + }, + "required": [ + "name", + "external_context" + ] + } + } + }, + "required": [ + "modules" + ] + } \ No newline at end of file diff --git a/llm/internal/gemini/internal/schema/schemas/module_selfcontained_context_schema.json b/llm/internal/gemini/internal/schema/schemas/module_selfcontained_context_schema.json new file mode 100644 index 0000000..40ed0dd --- /dev/null +++ b/llm/internal/gemini/internal/schema/schemas/module_selfcontained_context_schema.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "internal_context": { + "type": "string", + "description": "Summary and information about files directly within this specific module. This includes files that are directly under the root directory of the module, as well as files within any other directory in the module." + }, + "public_context": { + "type": "string", + "description": "Summary and information about files directly within this module, as well as any of its children modules. This will be used by sibling modules, and modules outside of this module's hierarchy." + } + }, + "required": [ + "internal_context", + "public_context" + ] + } diff --git a/llm/internal/gemini/internal/schema/schemas/workspace_change_proposal_schema.json b/llm/internal/gemini/internal/schema/schemas/workspace_change_proposal_schema.json new file mode 100644 index 0000000..07518fb --- /dev/null +++ b/llm/internal/gemini/internal/schema/schemas/workspace_change_proposal_schema.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties": { + "proposals": { + "type": "array", + "description": "A list of proposed modifications to files in the user's workspace.", + "items": { + "type": "object", + "properties": { + "file_name": { + "type": "string", + "description": "The full path to the file being created/deleted/modified." + }, + "content": { + "type": "string", + "description": "The full content of the file. This will be used as a drop-in replacement of the previous file content. DO NOT OMIT UNCHANGED CONTENT! Use an empty string if 'delete' is true." + }, + "delete": { + "type": "boolean", + "description": "True if this file should be deleted. For simplicity, moving or renaming files should be handled as a new file creation + existing file deletion." + } + }, + "required": [ + "file_name", + "content", + "delete" + ] + } + }, + "summary": { + "type": "string", + "description": "A brief summary of the proposed changes. This text should have at most 50 characters, as it will be used as the first line in a git commit message." + }, + "description": { + "type": "string", + "description": "A detailed description of the proposed changes. This text should have at most 72 characters per line (but no line limit), as it will be used as the detailed git commit message." + } + }, + "required": [ + "proposals", + "summary", + "description" + ] + } diff --git a/llm/providers.go b/llm/providers.go index 0ed5e6b..5667763 100644 --- a/llm/providers.go +++ b/llm/providers.go @@ -10,4 +10,4 @@ func SupportedProviders() []string { // supportedProviders holds the hard-coded list of providers until dynamic // registration lands. Keep the strings in lowercase as they are written // verbatim to .vyb/config.yaml. -var supportedProviders = []string{"openai"} +var supportedProviders = []string{"openai", "gemini"} diff --git a/llm/providers_test.go b/llm/providers_test.go new file mode 100644 index 0000000..10f82fe --- /dev/null +++ b/llm/providers_test.go @@ -0,0 +1,17 @@ +package llm + +import "testing" + +func TestSupportedProvidersContainsGemini(t *testing.T) { + providers := SupportedProviders() + found := false + for _, p := range providers { + if p == "gemini" { + found = true + break + } + } + if !found { + t.Fatalf("SupportedProviders() = %v, want to contain 'gemini'", providers) + } +}