diff --git a/AGENTS.md b/AGENTS.md index cc6ecd5..08c7bde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index 29ed266..bedfa68 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/embed/vertex.go b/embed/vertex.go index 6f31a10..340c3ef 100644 --- a/embed/vertex.go +++ b/embed/vertex.go @@ -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, ) } diff --git a/embed/vertex_test.go b/embed/vertex_test.go index 2bb7cd2..e22f731 100644 --- a/embed/vertex_test.go +++ b/embed/vertex_test.go @@ -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 { diff --git a/llm/vertex.go b/llm/vertex.go index 2912f71..ca02fd7 100644 --- a/llm/vertex.go +++ b/llm/vertex.go @@ -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, ) } diff --git a/llm/vertex_test.go b/llm/vertex_test.go index 09aef20..10e7f6b 100644 --- a/llm/vertex_test.go +++ b/llm/vertex_test.go @@ -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 { diff --git a/openspec/changes/vertex-global-region-url/.openspec.yaml b/openspec/changes/vertex-global-region-url/.openspec.yaml new file mode 100644 index 0000000..d206b49 --- /dev/null +++ b/openspec/changes/vertex-global-region-url/.openspec.yaml @@ -0,0 +1,2 @@ +schema: unbound-force +created: 2026-08-14 diff --git a/openspec/changes/vertex-global-region-url/design.md b/openspec/changes/vertex-global-region-url/design.md new file mode 100644 index 0000000..e7022ad --- /dev/null +++ b/openspec/changes/vertex-global-region-url/design.md @@ -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. diff --git a/openspec/changes/vertex-global-region-url/proposal.md b/openspec/changes/vertex-global-region-url/proposal.md new file mode 100644 index 0000000..9e5b400 --- /dev/null +++ b/openspec/changes/vertex-global-region-url/proposal.md @@ -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. diff --git a/openspec/changes/vertex-global-region-url/specs/vertex-url-construction.md b/openspec/changes/vertex-global-region-url/specs/vertex-url-construction.md new file mode 100644 index 0000000..e7d3f0a --- /dev/null +++ b/openspec/changes/vertex-global-region-url/specs/vertex-url-construction.md @@ -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. diff --git a/openspec/changes/vertex-global-region-url/tasks.md b/openspec/changes/vertex-global-region-url/tasks.md new file mode 100644 index 0000000..fad48a1 --- /dev/null +++ b/openspec/changes/vertex-global-region-url/tasks.md @@ -0,0 +1,30 @@ + + + +## 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). +