Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ synthesis:
region: us-east5
```

Vertex providers use `golang.org/x/oauth2/google` application-default credentials. If no provider is specified, defaults to Ollama.
Vertex providers use `golang.org/x/oauth2/google` application-default credentials. If no provider is specified, defaults to Ollama. The `region` field accepts any GCP region (e.g., `us-east5`, `us-central1`) or `global` to route requests to the nearest available region.

**Embedding endpoint resolution** (highest to lowest precedence):
1. `DEWEY_EMBEDDING_ENDPOINT` env var (app-specific override)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ Vertex AI requires Google Cloud application-default credentials:
gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform
```

Set `project` and `region` in your config to match your GCP setup. Vertex AI embedding models (e.g., `text-embedding-005`) and Claude synthesis models (e.g., `claude-sonnet-4-6`) must be enabled in your GCP project.
Set `project` and `region` in your config to match your GCP setup. Use a regional endpoint (e.g., `us-east5`, `us-central1`) or `global` to route requests to the nearest available region. Vertex AI embedding models (e.g., `text-embedding-005`) and Claude synthesis models (e.g., `claude-sonnet-4-6`) must be enabled in your GCP project.

**Note**: Switching embedding providers changes vector dimensions. Run `dewey reindex` after changing the embedding model.

Expand Down
8 changes: 6 additions & 2 deletions embed/vertex.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,13 @@ func NewVertexEmbedder(project, region, model string) (*VertexEmbedder, error) {

// predictURL builds the Vertex AI prediction endpoint URL.
func (v *VertexEmbedder) predictURL() string {
host := v.region + "-aiplatform.googleapis.com"
if v.region == "global" {
host = "aiplatform.googleapis.com"
}
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict",
v.region, v.project, v.region, v.model,
"https://%s/v1/projects/%s/locations/%s/publishers/google/models/%s:predict",
host, v.project, v.region, v.model,
)
}

Expand Down
38 changes: 38 additions & 0 deletions embed/vertex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,44 @@ func TestNewVertexEmbedder_MissingModel(t *testing.T) {
}
}

func TestVertexEmbedder_PredictURL(t *testing.T) {
tests := []struct {
name string
region string
project string
model string
want string
}{
{
name: "global region uses aiplatform.googleapis.com",
region: "global",
project: "my-project",
model: "text-embedding-005",
want: "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/text-embedding-005:predict",
},
{
name: "regional endpoint uses region-prefixed hostname",
region: "us-central1",
project: "my-project",
model: "text-embedding-005",
want: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/text-embedding-005:predict",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &VertexEmbedder{
project: tt.project,
region: tt.region,
model: tt.model,
}
got := v.predictURL()
if got != tt.want {
t.Errorf("predictURL() = %q, want %q", got, tt.want)
}
})
}
}

// newTestVertexEmbedder creates a VertexEmbedder that routes requests to
// the given test server and uses a mock token function.
func newTestVertexEmbedder(srv *httptest.Server) *VertexEmbedder {
Expand Down
8 changes: 6 additions & 2 deletions llm/vertex.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,13 @@ func NewVertexSynthesizer(project, region, model string) (*VertexSynthesizer, er

// rawPredictURL builds the Vertex AI rawPredict endpoint URL for Anthropic models.
func (v *VertexSynthesizer) rawPredictURL() string {
host := v.region + "-aiplatform.googleapis.com"
if v.region == "global" {
host = "aiplatform.googleapis.com"
}
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict",
v.region, v.project, v.region, v.model,
"https://%s/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict",
host, v.project, v.region, v.model,
)
}

Expand Down
38 changes: 38 additions & 0 deletions llm/vertex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,44 @@ func TestVertexSynthesizer_Retry429ContextCancelled(t *testing.T) {
}
}

func TestVertexSynthesizer_RawPredictURL(t *testing.T) {
tests := []struct {
name string
region string
project string
model string
want string
}{
{
name: "global region uses aiplatform.googleapis.com",
region: "global",
project: "my-project",
model: "claude-opus-4-6",
want: "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict",
},
{
name: "regional endpoint uses region-prefixed hostname",
region: "us-east5",
project: "my-project",
model: "claude-sonnet-4-6",
want: "https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-6:rawPredict",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &VertexSynthesizer{
project: tt.project,
region: tt.region,
model: tt.model,
}
got := v.rawPredictURL()
if got != tt.want {
t.Errorf("rawPredictURL() = %q, want %q", got, tt.want)
}
})
}
}

// newTestVertexSynth creates a VertexSynthesizer that routes requests to
// the given test server with a mock token function.
func newTestVertexSynth(srv *httptest.Server) *VertexSynthesizer {
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/vertex-global-region-url/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: unbound-force
created: 2026-08-14
48 changes: 48 additions & 0 deletions openspec/changes/vertex-global-region-url/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Context

Both Vertex AI providers (`VertexSynthesizer` in `llm/vertex.go` and `VertexEmbedder` in `embed/vertex.go`) construct endpoint URLs using the pattern `https://{region}-aiplatform.googleapis.com/...`. This format is correct for regional endpoints (e.g., `us-east5`) but invalid for the `global` endpoint, which uses `https://aiplatform.googleapis.com/...` without a region prefix.

The `locations/{region}` path segment in the URL correctly uses `global` in both cases — the bug is only in the hostname construction.

## Goals / Non-Goals

### Goals
- Support `region: global` in Vertex AI config for both synthesis and embedding
- Maintain identical behavior for existing regional endpoints
- Add test coverage for `global` region URL construction

### Non-Goals
- Validating whether a given region string is a real GCP region
- Changing the Vertex AI config schema or adding new config fields
- Updating the `unbound-force` gateway (that repo has its own fix via PR #125)

## Decisions

### D1: Conditional hostname construction (not a separate URL template)

**Decision**: Add a conditional check in each URL builder method: if `region == "global"`, use `aiplatform.googleapis.com` as the hostname; otherwise, use `{region}-aiplatform.googleapis.com`.

**Rationale**: This is the minimal change that fixes the bug. An alternative would be extracting a shared helper function, but since the two URL builders are in different packages (`llm/` and `embed/`) with slightly different URL paths (`rawPredict` vs `predict`, `anthropic` vs `google` publisher), a shared helper would add coupling without meaningful deduplication. Each method stays self-contained and readable.

**Constitution alignment**: Composability First — each package remains independently testable with no cross-package dependency introduced.

### D2: Extract helper variable, not a function

**Decision**: Use a local `host` variable in each URL builder rather than extracting a `vertexHost(region string)` function.

**Rationale**: The logic is two lines. A function would be over-engineering for a simple conditional. If more region-specific logic emerges later, extraction can happen then.

### D3: Config update is out of scope for the code fix

**Decision**: The `.uf/dewey/config.yaml` update (setting correct project/region) is a local configuration change, not a code change. It will be done as a separate task but is not part of the tested/reviewed code change.

## Coverage Strategy

- **Type**: Unit tests only (no integration or e2e needed — URL construction is a pure function with no I/O)
- **Target**: 100% branch coverage of the `rawPredictURL()` and `predictURL()` methods
- **Regression**: Both `global` and regional cases covered per method to prevent regression (TC-006)

## Risks / Trade-offs

- **Risk**: The `global` endpoint may not support all Vertex AI model types or APIs. **Mitigation**: This is a Google API concern, not a Dewey concern. If Google doesn't support a model in the global endpoint, the user will get a clear HTTP error from Google, not a DNS failure from a malformed URL.
- **Trade-off**: No shared helper between `llm/` and `embed/` means the fix is duplicated in two places. Accepted because the duplication is minimal (3 lines) and avoids cross-package coupling.
58 changes: 58 additions & 0 deletions openspec/changes/vertex-global-region-url/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
## Why

The Vertex AI URL builders in both `llm/vertex.go` and `embed/vertex.go` construct endpoint URLs by prepending `{region}-` to `aiplatform.googleapis.com`. This works for regional endpoints (e.g., `us-east5-aiplatform.googleapis.com`) but produces an invalid URL when `region` is set to `global`: `global-aiplatform.googleapis.com` instead of `aiplatform.googleapis.com`.

The `global` endpoint is a valid and useful Vertex AI location — it routes requests to the nearest available region. Users configuring `region: global` in their `config.yaml` get silent failures (DNS resolution errors or unexpected HTTP errors) with no indication that the URL is malformed.

Related: PR #125 in `unbound-force/unbound-force` addressed the same `global` region issue in the gateway's Vertex provider by rejecting it with an error. Dewey should instead support it properly, since Dewey's Vertex providers use `rawPredict`/`predict` endpoints that do work with the global endpoint when the URL is constructed correctly.

## What Changes

Fix the URL construction in both Vertex providers to detect `region == "global"` and use `aiplatform.googleapis.com` (no region prefix) instead of `global-aiplatform.googleapis.com`.

## Capabilities

### New Capabilities
- None

### Modified Capabilities
- `VertexSynthesizer.rawPredictURL()`: Correctly handles `global` region by omitting the region subdomain prefix
- `VertexEmbedder.predictURL()`: Same fix — correctly handles `global` region

### Removed Capabilities
- None

## Impact

- **Files**: `llm/vertex.go`, `embed/vertex.go`, `llm/vertex_test.go`, `embed/vertex_test.go`
- **Behavior**: Users can now set `region: global` in their Vertex AI config and get working synthesis/embedding. Regional endpoints (e.g., `us-east5`) continue to work identically.
- **Config**: Update `.uf/dewey/config.yaml` with correct project/region values for local development.
- **Backward compatible**: No API changes. Existing regional configurations are unaffected.

## Constitution Alignment

Assessed against the Unbound Force org constitution.

### I. Autonomous Collaboration

**Assessment**: N/A

This is a bug fix to URL construction logic. No changes to artifact-based communication or MCP tool interfaces.

### II. Composability First

**Assessment**: PASS

Dewey remains independently installable. The fix improves standalone functionality by supporting a broader range of valid Vertex AI configurations without external workarounds.

### III. Observable Quality

**Assessment**: PASS

No changes to output format or provenance metadata. The fix ensures Vertex AI requests reach the correct endpoint, improving reliability of synthesis and embedding operations.

### IV. Testability

**Assessment**: PASS

New unit tests will verify URL construction for both `global` and regional endpoints. Tests are isolated (no external service calls) and verify the specific URL format produced by each region value.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## ADDED Requirements

### Requirement: Global region endpoint URL

When `region` is set to `"global"`, the Vertex AI endpoint URL MUST use `aiplatform.googleapis.com` as the hostname without a region prefix.

#### Scenario: Synthesis with global region
- **GIVEN** a `VertexSynthesizer` configured with `region: "global"`, `project: "my-project"`, and `model: "claude-opus-4-6"`
- **WHEN** `rawPredictURL()` is called
- **THEN** the returned URL MUST be `https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict`

#### Scenario: Embedding with global region
- **GIVEN** a `VertexEmbedder` configured with `region: "global"`, `project: "my-project"`, and `model: "text-embedding-005"`
- **WHEN** `predictURL()` is called
- **THEN** the returned URL MUST be `https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/text-embedding-005:predict`

#### Scenario: Regional synthesis endpoint unchanged
- **GIVEN** a `VertexSynthesizer` configured with `region: "us-east5"`, `project: "my-project"`, and `model: "claude-sonnet-4-6"`
- **WHEN** `rawPredictURL()` is called
- **THEN** the returned URL MUST be `https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-6:rawPredict`

#### Scenario: Regional embedding endpoint unchanged
- **GIVEN** a `VertexEmbedder` configured with `region: "us-central1"`, `project: "my-project"`, and `model: "text-embedding-005"`
- **WHEN** `predictURL()` is called
- **THEN** the returned URL MUST be `https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/text-embedding-005:predict`

> **Note**: The `region == "global"` comparison is case-sensitive. GCP region names are conventionally lowercase. Mixed-case variants (e.g., `"Global"`, `"GLOBAL"`) are not specially handled and will produce a region-prefixed hostname, consistent with how all other non-global region strings are treated. Validating whether a region string is a real GCP region is a non-goal (see design.md).

> **Regression**: The "Synthesis with global region" and "Embedding with global region" scenarios above reproduce the original bug. Without the fix, these methods return URLs with `global-aiplatform.googleapis.com` (invalid hostname). With the fix, they return URLs with `aiplatform.googleapis.com` (correct). These scenarios serve as regression tests per TC-006.

## MODIFIED Requirements

None.

## REMOVED Requirements

None.
30 changes: 30 additions & 0 deletions openspec/changes/vertex-global-region-url/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!-- spec-review: passed -->
<!--
[P] marks tasks eligible for parallel execution.
Add [P] when a task: (a) touches different files from
other [P] tasks in the group, (b) has no dependency
on prior tasks in the group, (c) can safely execute
without ordering constraints.
Do NOT add [P] when tasks modify the same file —
parallel workers will cause merge conflicts.
Tasks without [P] run sequentially first, then [P]
tasks run in parallel.
-->

## 1. Fix URL construction

- [x] 1.1 [P] Fix `rawPredictURL()` in `llm/vertex.go` to detect `region == "global"` and use `aiplatform.googleapis.com` (no region prefix) as the hostname. Regional endpoints remain unchanged.
- [x] 1.2 [P] Fix `predictURL()` in `embed/vertex.go` with the same conditional: `region == "global"` uses `aiplatform.googleapis.com`, otherwise `{region}-aiplatform.googleapis.com`.

## 2. Add test coverage

- [x] 2.1 [P] Add `TestVertexSynthesizer_RawPredictURL` in `llm/vertex_test.go` with table-driven subtests covering: (a) `region: "global"` produces URL with `aiplatform.googleapis.com` hostname (regression test — MUST fail without the fix per TC-006), (b) `region: "us-east5"` produces URL with `us-east5-aiplatform.googleapis.com` hostname.
- [x] 2.2 [P] Add `TestVertexEmbedder_PredictURL` in `embed/vertex_test.go` with table-driven subtests covering: (a) `region: "global"` produces URL with `aiplatform.googleapis.com` hostname (regression test — MUST fail without the fix per TC-006), (b) `region: "us-central1"` produces URL with `us-central1-aiplatform.googleapis.com` hostname.

## 3. Verify

- [x] 3.1 Run `go build ./...` and `go test -race -count=1 ./llm/ ./embed/` to confirm the fix compiles and all tests pass.
- [x] 3.2 Run `go vet ./...` to confirm no static analysis issues.
- [x] 3.3 Verify constitution alignment: Composability (no new imports between `llm/` and `embed/` — run `go list -f '{{.Imports}}' ./llm/ ./embed/`), Testability (new tests are isolated, no external service calls).
- [x] 3.4 Update `README.md` and `AGENTS.md` to mention `global` as a valid `region` value for Vertex AI providers (routes to nearest available region).
<!-- code-review: passed -->
Loading