diff --git a/docs/configuration.md b/docs/configuration.md index 2560c7f1..f7aa49d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,9 @@ to any command. ```toml summary_model = "gpt-5.4" +summary_base_url = "https://custom.example.com/v1" embed_model = "text-embedding-3-small" +embed_base_url = "https://custom.example.com/v1" embed_dimensions = 1024 embedding_basis = "title_original" vector_backend = "exact" @@ -107,7 +109,9 @@ token_env = "CRAWL_REMOTE_TOKEN" | Field | Default | Notes | | --- | --- | --- | | `summary_model` | `gpt-5.4` | Reserved for future summary commands | +| `summary_base_url` | _(empty)_ | Custom endpoint for the summary model; falls back to `GITCRAWL_OPENAI_BASE_URL`/`OPENAI_BASE_URL`. Override at runtime with `GITCRAWL_SUMMARY_BASE_URL` | | `embed_model` | `text-embedding-3-small` | OpenAI embedding model | +| `embed_base_url` | _(empty)_ | Custom endpoint for the embedding model; falls back to `GITCRAWL_OPENAI_BASE_URL`/`OPENAI_BASE_URL`. Override at runtime with `GITCRAWL_EMBED_BASE_URL` | | `embed_dimensions` | `1024` | Must match the model | | `embedding_basis` | `title_original` | Only `title_original` is implemented | | `vector_backend` | `exact` | Semantic search backend: `exact` or optional `turbovec` via Python `turbovec`; turbovec requires dimensions divisible by 8 | @@ -143,6 +147,39 @@ before updating gitcrawl. | `GITCRAWL_VECTOR_BACKEND` | Override semantic vector backend (`exact` or `turbovec`) | | `GITCRAWL_OPENAI_RETRY_DISABLED` | Set to `1` to disable OpenAI retry/backoff | | `GITCRAWL_OPENAI_BASE_URL` / `OPENAI_BASE_URL` | Custom OpenAI endpoint (e.g., for a proxy) | +| `GITCRAWL_SUMMARY_BASE_URL` | Custom endpoint for the summary model (falls back to the shared OpenAI endpoint) | +| `GITCRAWL_EMBED_BASE_URL` | Custom endpoint for the embedding model (falls back to the shared OpenAI endpoint) | + +#### How the base-URL variables relate + +There are three base-URL variables, and they form a fallback chain rather than +competing with each other: + +- **`GITCRAWL_OPENAI_BASE_URL`** (or `OPENAI_BASE_URL`) is the **shared** endpoint + used for *every* OpenAI call — both summaries and embeddings. Set only this and + both workloads hit the same endpoint. This is the common case (e.g. one proxy + for everything). +- **`GITCRAWL_SUMMARY_BASE_URL`** is a **per-purpose override** that applies only + to the summary model. +- **`GITCRAWL_EMBED_BASE_URL`** is a **per-purpose override** that applies only to + the embedding model. + +The per-purpose variables do not replace the shared one — they layer on top of +it. When a per-purpose value is empty, that workload falls back to the shared +endpoint. Resolution order for each purpose: + +- **Embeddings:** `GITCRAWL_EMBED_BASE_URL` → `embed_base_url` (config) → `GITCRAWL_OPENAI_BASE_URL` → `OPENAI_BASE_URL` +- **Summaries:** `GITCRAWL_SUMMARY_BASE_URL` → `summary_base_url` (config) → `GITCRAWL_OPENAI_BASE_URL` → `OPENAI_BASE_URL` + +Use the per-purpose overrides only when the two workloads must diverge — for +example, running chat/summaries against one provider while embeddings go to a +different host or a local embedding server: + +```bash +# Summaries use the shared endpoint; embeddings go to a dedicated one. +export GITCRAWL_OPENAI_BASE_URL="https://openrouter.ai/api/v1" +export GITCRAWL_EMBED_BASE_URL="http://localhost:8080/v1" +``` ### GitHub overrides @@ -151,6 +188,57 @@ before updating gitcrawl. | `GITCRAWL_GITHUB_BASE_URL` / `GITHUB_BASE_URL` | Custom GitHub API endpoint used by `sync` | | `GH_REPO` | Default repository for compatible local search shapes | +## Non-OpenAI / OpenAI-compatible embedding endpoints + +`OPENAI_API_KEY` is only used to generate embeddings (`embed`, `refresh`). The +embedding client is a generic OpenAI-compatible client: it sends +`Authorization: Bearer ` and POSTs to `/embeddings`. Any +compatible endpoint — OpenRouter, a proxy, a local server, or a self-hosted +gateway — works if you override three things: + +1. **Base URL** — `GITCRAWL_OPENAI_BASE_URL` (or `OPENAI_BASE_URL`), including + the version suffix your provider expects (e.g. `/v1`). To point *only* + embeddings at this endpoint while leaving summaries on the shared one, use + `GITCRAWL_EMBED_BASE_URL` (or `embed_base_url`) instead — see + [How the base-URL variables relate](#how-the-base-url-variables-relate). +2. **Model** — `GITCRAWL_EMBED_MODEL` (or `embed_model`). The default + `text-embedding-3-small` is an OpenAI model name; set a model the provider + actually serves for embeddings. +3. **Dimensions** — set `embed_dimensions` to match the model's output if it + differs from `1024`, or if the endpoint does not honor the `dimensions` + request parameter the way OpenAI does. + +By default the key is read from `OPENAI_API_KEY`. To read it from a different +variable instead, set `openai.api_key_env` in `config.toml`: + +```toml +embed_model = "text-embedding-3-small" +embed_dimensions = 1024 + +[openai] +api_key_env = "OPENROUTER_API_KEY" +``` + +OpenRouter example: + +```bash +export GITCRAWL_OPENAI_BASE_URL="https://openrouter.ai/api/v1" +export GITCRAWL_EMBED_MODEL="" +export OPENAI_API_KEY="" # or set openai.api_key_env +``` + +Notes: + +- OpenRouter's embeddings coverage is narrower than its chat coverage; confirm + it serves the embedding model you pick before running `embed`. +- Changing the model changes the vector space. `embed` auto-forces a rebuild + when `embed_model` changes, so existing rows are re-embedded consistently. If + you only change `embed_dimensions` (or switch to an endpoint that produces + different vectors under the same model name), re-run `embed --force` yourself + so `neighbors` and clustering do not mix incompatible vectors. +- `gitcrawl doctor` reports where the key came from (env vs. config) and the + active embed model, which is useful for confirming the override took effect. + ### gh shim `gitcrawl gh` moved to Octopool. Run `octopool login`, then use `octopool gh ...` or symlink Octopool as `gh`. diff --git a/docs/reference.md b/docs/reference.md index ffea4bba..a67a7938 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -53,6 +53,8 @@ path with `--config ` or `GITCRAWL_CONFIG`. | `GITCRAWL_EMBED_MODEL` | `text-embedding-3-small` | OpenAI embedding model | | `GITCRAWL_OPENAI_RETRY_DISABLED` | _(off)_ | Set `1` to disable OpenAI retry/backoff | | `GITCRAWL_OPENAI_BASE_URL` / `OPENAI_BASE_URL` | OpenAI default | Custom OpenAI endpoint | +| `GITCRAWL_SUMMARY_BASE_URL` | Shared OpenAI endpoint | Custom endpoint for the summary model | +| `GITCRAWL_EMBED_BASE_URL` | Shared OpenAI endpoint | Custom endpoint for the embedding model | ### GitHub overrides @@ -70,7 +72,9 @@ path with `--config ` or `GITCRAWL_CONFIG`. | Field | Default | | --- | --- | | `summary_model` | `gpt-5.4` | +| `summary_base_url` | _(empty; falls back to `OPENAI_BASE_URL`)_ | | `embed_model` | `text-embedding-3-small` | +| `embed_base_url` | _(empty; falls back to `OPENAI_BASE_URL`)_ | | `embed_dimensions` | `1024` | | `embedding_basis` | `title_original` | | `vector_backend` | `exact`; `turbovec` requires Python `turbovec` and dimensions divisible by 8 | diff --git a/internal/cli/app.go b/internal/cli/app.go index 617df048..8ea4bc1e 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -277,10 +277,12 @@ func (a *App) runConfigure(args []string) error { fs := flag.NewFlagSet("configure", flag.ContinueOnError) fs.SetOutput(io.Discard) summaryModel := fs.String("summary-model", "", "summary model") + summaryBaseURLFlag := fs.String("summary-base-url", "", "custom endpoint for the summary model") embedModel := fs.String("embed-model", "", "embedding model") + embedBaseURLFlag := fs.String("embed-base-url", "", "custom endpoint for the embedding model") embeddingBasis := fs.String("embedding-basis", "", "embedding basis") jsonOut := fs.Bool("json", false, "write JSON output") - if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"summary-model": true, "embed-model": true, "embedding-basis": true})); err != nil { + if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"summary-model": true, "summary-base-url": true, "embed-model": true, "embed-base-url": true, "embedding-basis": true})); err != nil { return usageErr(err) } a.applyCommandJSON(*jsonOut) @@ -299,10 +301,18 @@ func (a *App) runConfigure(args []string) error { cfg.OpenAI.SummaryModel = strings.TrimSpace(*summaryModel) updated = true } + if fsFlagSet(fs, "summary-base-url") { + cfg.OpenAI.SummaryBaseURL = strings.TrimSpace(*summaryBaseURLFlag) + updated = true + } if strings.TrimSpace(*embedModel) != "" { cfg.OpenAI.EmbedModel = strings.TrimSpace(*embedModel) updated = true } + if fsFlagSet(fs, "embed-base-url") { + cfg.OpenAI.EmbedBaseURL = strings.TrimSpace(*embedBaseURLFlag) + updated = true + } if strings.TrimSpace(*embeddingBasis) != "" { cfg.EmbeddingBasis = strings.TrimSpace(*embeddingBasis) updated = true @@ -313,11 +323,13 @@ func (a *App) runConfigure(args []string) error { } } return a.writeOutput("configure", map[string]any{ - "config_path": config.ResolvePath(a.configPath), - "updated": updated || !configExists, - "summary_model": cfg.OpenAI.SummaryModel, - "embed_model": cfg.OpenAI.EmbedModel, - "embedding_basis": cfg.EmbeddingBasis, + "config_path": config.ResolvePath(a.configPath), + "updated": updated || !configExists, + "summary_model": cfg.OpenAI.SummaryModel, + "summary_base_url": summaryBaseURL(cfg), + "embed_model": cfg.OpenAI.EmbedModel, + "embed_base_url": embedBaseURL(cfg), + "embedding_basis": cfg.EmbeddingBasis, }, true) } @@ -649,7 +661,7 @@ func (a *App) semanticSearchDocuments(ctx context.Context, rt localRuntime, repo if token.Value == "" { return nil, fmt.Errorf("semantic search requires OpenAI API key: set %s", rt.Config.OpenAI.APIKeyEnv) } - client := openai.New(openai.Options{APIKey: token.Value, BaseURL: openAIBaseURL(), Dimensions: rt.Config.OpenAI.EmbedDimensions, Retry: embedRetryOverride()}) + client := openai.New(openai.Options{APIKey: token.Value, BaseURL: embedBaseURL(rt.Config), Dimensions: rt.Config.OpenAI.EmbedDimensions, Retry: embedRetryOverride()}) queryVectors, err := client.Embed(ctx, rt.Config.OpenAI.EmbedModel, []string{query}) if err != nil { return nil, err @@ -1259,7 +1271,7 @@ func (a *App) embedRepository(ctx context.Context, owner, repoName string, optio if batchSize <= 0 { batchSize = 64 } - client := openai.New(openai.Options{APIKey: token.Value, BaseURL: openAIBaseURL(), Dimensions: rt.Config.OpenAI.EmbedDimensions, Retry: embedRetryOverride()}) + client := openai.New(openai.Options{APIKey: token.Value, BaseURL: embedBaseURL(rt.Config), Dimensions: rt.Config.OpenAI.EmbedDimensions, Retry: embedRetryOverride()}) type pendingBatch struct { start, end int @@ -1449,6 +1461,19 @@ func truncatedEmbeddingTaskCount(tasks []store.EmbeddingTask) int { return count } +// fsFlagSet reports whether the named flag was explicitly provided on the +// command line, allowing callers to distinguish an empty value from an omitted +// flag (e.g. clearing summary_base_url back to the shared endpoint). +func fsFlagSet(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + func openAIBaseURL() string { if value := strings.TrimSpace(os.Getenv("GITCRAWL_OPENAI_BASE_URL")); value != "" { return value @@ -1456,6 +1481,28 @@ func openAIBaseURL() string { return strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")) } +// summaryBaseURL resolves the endpoint for the summary model. A configured +// summary_base_url (or its GITCRAWL_SUMMARY_BASE_URL override, applied at +// runtime) takes precedence, so summaries can target a different endpoint than +// embeddings; otherwise it falls back to the shared OpenAI base URL. +func summaryBaseURL(cfg config.Config) string { + if value := strings.TrimSpace(cfg.OpenAI.SummaryBaseURL); value != "" { + return value + } + return openAIBaseURL() +} + +// embedBaseURL resolves the endpoint for the embedding model. A configured +// embed_base_url (or its GITCRAWL_EMBED_BASE_URL override, applied at runtime) +// takes precedence, so embeddings can target a different endpoint than +// summaries; otherwise it falls back to the shared OpenAI base URL. +func embedBaseURL(cfg config.Config) string { + if value := strings.TrimSpace(cfg.OpenAI.EmbedBaseURL); value != "" { + return value + } + return openAIBaseURL() +} + func githubBaseURL() string { if value := strings.TrimSpace(os.Getenv("GITCRAWL_GITHUB_BASE_URL")); value != "" { return value @@ -3512,7 +3559,9 @@ func (a *App) runDoctor(ctx context.Context, args []string) error { "cluster_count": storeStatus.ClusterCount, "last_sync_at": formatOptionalTime(storeStatus.LastSyncAt), "summary_model": cfg.OpenAI.SummaryModel, + "summary_base_url": summaryBaseURL(cfg), "embed_model": cfg.OpenAI.EmbedModel, + "embed_base_url": embedBaseURL(cfg), "embedding_basis": cfg.EmbeddingBasis, "api_supported": false, } @@ -3614,7 +3663,9 @@ func (a *App) runRemoteDoctor(ctx context.Context, cfg config.Config, configExis "openai_key_present": openAIKey.Value != "", "openai_key_source": openAIKey.Source, "summary_model": cfg.OpenAI.SummaryModel, + "summary_base_url": summaryBaseURL(cfg), "embed_model": cfg.OpenAI.EmbedModel, + "embed_base_url": embedBaseURL(cfg), "embedding_basis": cfg.EmbeddingBasis, "api_supported": false, } @@ -4626,7 +4677,7 @@ Usage: "configure": `gitcrawl configure updates model fields in the config. Usage: - gitcrawl configure [--summary-model name] [--embed-model name] [--embedding-basis title_original] [--json] + gitcrawl configure [--summary-model name] [--summary-base-url url] [--embed-model name] [--embed-base-url url] [--embedding-basis title_original] [--json] `, "doctor": `gitcrawl doctor checks config, token, and database readiness. diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 7b980304..8d9a4fa6 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -2840,6 +2840,20 @@ func TestAppOutputModesAndUsageBranches(t *testing.T) { if got := openAIBaseURL(); got != "https://gitcrawl-openai.example/v1" { t.Fatalf("openAIBaseURL override = %q", got) } + if got := summaryBaseURL(config.Config{}); got != "https://gitcrawl-openai.example/v1" { + t.Fatalf("summaryBaseURL fallback = %q", got) + } + if got := embedBaseURL(config.Config{}); got != "https://gitcrawl-openai.example/v1" { + t.Fatalf("embedBaseURL fallback = %q", got) + } + summaryCfg := config.Config{OpenAI: config.OpenAIConfig{SummaryBaseURL: "https://summary.example/v1"}} + if got := summaryBaseURL(summaryCfg); got != "https://summary.example/v1" { + t.Fatalf("summaryBaseURL override = %q", got) + } + embedCfg := config.Config{OpenAI: config.OpenAIConfig{EmbedBaseURL: "https://embed.example/v1"}} + if got := embedBaseURL(embedCfg); got != "https://embed.example/v1" { + t.Fatalf("embedBaseURL override = %q", got) + } t.Setenv("GITHUB_BASE_URL", "https://github.example") if got := githubBaseURL(); got != "https://github.example" { t.Fatalf("githubBaseURL fallback = %q", got) diff --git a/internal/config/config.go b/internal/config/config.go index 6d5087f9..643b9f16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,7 +38,9 @@ type GitHubConfig struct { type OpenAIConfig struct { APIKeyEnv string `toml:"api_key_env"` SummaryModel string `toml:"summary_model"` + SummaryBaseURL string `toml:"summary_base_url"` EmbedModel string `toml:"embed_model"` + EmbedBaseURL string `toml:"embed_base_url"` EmbedDimensions int `toml:"embed_dimensions"` BatchSize int `toml:"batch_size"` Concurrency int `toml:"concurrency"` @@ -175,9 +177,11 @@ func (c *Config) Normalize() error { if c.OpenAI.SummaryModel == "" { c.OpenAI.SummaryModel = def.OpenAI.SummaryModel } + c.OpenAI.SummaryBaseURL = strings.TrimSpace(c.OpenAI.SummaryBaseURL) if c.OpenAI.EmbedModel == "" { c.OpenAI.EmbedModel = def.OpenAI.EmbedModel } + c.OpenAI.EmbedBaseURL = strings.TrimSpace(c.OpenAI.EmbedBaseURL) if c.OpenAI.EmbedDimensions <= 0 { c.OpenAI.EmbedDimensions = def.OpenAI.EmbedDimensions } @@ -217,7 +221,9 @@ func (c *Config) Normalize() error { func (c *Config) ApplyRuntimeEnv() { c.OpenAI.SummaryModel = c.envOrDefault("GITCRAWL_SUMMARY_MODEL", c.OpenAI.SummaryModel) + c.OpenAI.SummaryBaseURL = c.envOrDefault("GITCRAWL_SUMMARY_BASE_URL", c.OpenAI.SummaryBaseURL) c.OpenAI.EmbedModel = c.envOrDefault("GITCRAWL_EMBED_MODEL", c.OpenAI.EmbedModel) + c.OpenAI.EmbedBaseURL = c.envOrDefault("GITCRAWL_EMBED_BASE_URL", c.OpenAI.EmbedBaseURL) c.VectorBackend = c.envOrDefault("GITCRAWL_VECTOR_BACKEND", c.VectorBackend) c.TUI.DefaultLayout = c.envOrDefault("GITCRAWL_TUI_LAYOUT", c.TUI.DefaultLayout) c.Remote.Mode = c.envOrDefault("GITCRAWL_REMOTE_MODE", c.Remote.Mode) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2ebc827f..a74271b7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -451,6 +451,62 @@ GITCRAWL_EMBED_MODEL = "embed-from-config-env" } } +func TestLoadRuntimeAppliesSummaryBaseURLOverride(t *testing.T) { + t.Setenv("GITCRAWL_SUMMARY_BASE_URL", "https://process.example.com/v1") + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[openai] +summary_base_url = "https://config.example.com/v1" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if loaded.OpenAI.SummaryBaseURL != "https://config.example.com/v1" { + t.Fatalf("config summary_base_url not loaded: %q", loaded.OpenAI.SummaryBaseURL) + } + + runtime, err := LoadRuntime(path) + if err != nil { + t.Fatalf("load runtime config: %v", err) + } + if runtime.OpenAI.SummaryBaseURL != "https://process.example.com/v1" { + t.Fatalf("process env did not override summary_base_url: %q", runtime.OpenAI.SummaryBaseURL) + } +} + +func TestLoadRuntimeAppliesEmbedBaseURLOverride(t *testing.T) { + t.Setenv("GITCRAWL_EMBED_BASE_URL", "https://process.example.com/v1") + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[openai] +embed_base_url = "https://config.example.com/v1" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if loaded.OpenAI.EmbedBaseURL != "https://config.example.com/v1" { + t.Fatalf("config embed_base_url not loaded: %q", loaded.OpenAI.EmbedBaseURL) + } + + runtime, err := LoadRuntime(path) + if err != nil { + t.Fatalf("load runtime config: %v", err) + } + if runtime.OpenAI.EmbedBaseURL != "https://process.example.com/v1" { + t.Fatalf("process env did not override embed_base_url: %q", runtime.OpenAI.EmbedBaseURL) + } +} + func TestLoadDoesNotApplyRuntimeEnvFallback(t *testing.T) { t.Setenv("GITCRAWL_SUMMARY_MODEL", "summary-from-process") t.Setenv("GITCRAWL_EMBED_MODEL", "")