Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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 <key>` and POSTs to `<base-url>/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="<an-embedding-model-openrouter-serves>"
export OPENAI_API_KEY="<openrouter-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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ path with `--config <path>` 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

Expand All @@ -70,7 +72,9 @@ path with `--config <path>` 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 |
Expand Down
69 changes: 60 additions & 9 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1449,13 +1461,48 @@ 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
}
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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading