diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 9064eb93..49df1995 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -44,7 +44,10 @@ class ProviderField: # endpoint, so the user only has to paste a key. Distinct from `placeholder` (grey hint). default: str = "" # Non-empty → the field renders as a segmented choice control instead of a text input; - # each option is {"value", "label"}. The chosen value is stored like any field value. + # each option is {"value", "label"} plus optional UI extras: "tag" (a tiny badge like + # "Easiest"), "desc" (one-liner atop the method's panel), and "command" (a copyable + # terminal command shown in the panel, e.g. the gcloud ADC login). The chosen value is + # stored like any other field value. choices: tuple = () # {"other_field_key": "value"} → the field only renders while that other field holds # that value. Drives auth-method switching (Bedrock) without a per-provider form. @@ -158,7 +161,6 @@ def _build_bedrock(profile: dict[str, Any], secrets: Any) -> ProviderClient: def _build_vertex(profile: dict[str, Any], secrets: Any) -> ProviderClient: - # Blank service_account_json → Application Default Credentials, resolved at call time. p = profile or {} def get(key: str) -> Optional[str]: @@ -167,7 +169,9 @@ def _build_vertex(profile: dict[str, Any], secrets: Any) -> ProviderClient: return VertexProvider( project=get("project"), location=get("location"), + auth_method=get("auth_method"), service_account_json=get("service_account_json"), + api_key=get("vertex_api_key"), ) @@ -318,9 +322,22 @@ DESCRIPTORS: list[ProviderDescriptor] = [ required=False, # the default stands in; builder tolerates absence default="api_key", choices=( - {"value": "api_key", "label": "Bedrock API key"}, - {"value": "profile", "label": "AWS profile"}, - {"value": "iam", "label": "IAM keys"}, + { + "value": "api_key", + "label": "Bedrock API key", + "tag": "Easiest", + "desc": "A single key generated on the Bedrock console — no AWS CLI or IAM setup needed.", + }, + { + "value": "profile", + "label": "AWS profile", + "desc": "Uses a named profile from ~/.aws — works with `aws configure` and `aws sso login`.", + }, + { + "value": "iam", + "label": "IAM keys", + "desc": "An IAM access key pair. For temporary STS credentials, include the session token.", + }, ), ), ProviderField( @@ -330,7 +347,6 @@ DESCRIPTORS: list[ProviderDescriptor] = [ required=False, placeholder="ABSK…", show_when={"auth_method": "api_key"}, - help="Generate one on the Bedrock console — no AWS CLI or IAM setup needed.", ), ProviderField( "aws_profile", @@ -339,9 +355,7 @@ DESCRIPTORS: list[ProviderDescriptor] = [ required=False, placeholder="default", show_when={"auth_method": "profile"}, - help="A named profile from ~/.aws — works with `aws configure` and " - "`aws sso login` (IAM Identity Center). Leave blank to use your " - "default AWS credentials (env vars or ~/.aws).", + help="Leave blank to use your default AWS credentials (env vars or ~/.aws).", ), ProviderField( "aws_access_key_id", @@ -390,14 +404,50 @@ DESCRIPTORS: list[ProviderDescriptor] = [ help="The region your Vertex AI models are enabled in " "(Claude models: us-east5 or europe-west1).", ), + ProviderField( + "auth_method", + "Connect with", + secret=False, + required=False, # the default stands in; builder tolerates absence + default="adc", + choices=( + { + "value": "adc", + "label": "Google Cloud login", + "tag": "Recommended", + "desc": "Uses your machine's Google Cloud identity (Application " + "Default Credentials). Nothing to paste — sign in once in a terminal:", + "command": "gcloud auth application-default login", + }, + { + "value": "service_account", + "label": "Service account", + "desc": "A service-account key — the usual path on shared or headless machines.", + }, + { + "value": "api_key", + "label": "API key", + "desc": "A long-lived key from the Google Cloud console's API Keys page. " + "Reaches Gemini models only — Claude and open-weight need Google " + "Cloud login or a service account.", + }, + ), + ), ProviderField( "service_account_json", - "Service-account JSON (optional)", + "Service-account JSON", secret=True, required=False, - help="Paste the JSON key or a path to it. Leave blank to use " - "Application Default Credentials " - "(`gcloud auth application-default login`).", + show_when={"auth_method": "service_account"}, + help="Paste the JSON key, or a path to the file.", + ), + ProviderField( + "vertex_api_key", + "Vertex API key", + secret=True, + required=False, + placeholder="AQ.…", + show_when={"auth_method": "api_key"}, ), ], build=_build_vertex, @@ -646,16 +696,44 @@ def _verify_bedrock(fields: dict[str, Any], timeout: float) -> dict[str, Any]: def _verify_vertex(fields: dict[str, Any], timeout: float) -> dict[str, Any]: - """Resolve credentials (service account or ADC), mint a bearer, and list Google's - publisher models in the given project/location — one cheap read-only call.""" + """One cheap read-only call (list Google's publisher models) through the SELECTED + auth method: ADC / service-account bearer, or the express API key header.""" import httpx from .vertex_provider import load_credentials project = (fields.get("project") or "").strip() location = (fields.get("location") or "").strip() + method = (fields.get("auth_method") or "").strip() or ( + "service_account" if (fields.get("service_account_json") or "").strip() else "adc" + ) + if method == "api_key": + key = (fields.get("vertex_api_key") or "").strip() + if not key: + return {"ok": False, "error": "Enter a Vertex API key to test."} + try: + # Express mode is global — no region host, no project in the path. + resp = httpx.get( + "https://aiplatform.googleapis.com/v1/publishers/google/models", + headers={"x-goog-api-key": key}, + timeout=timeout, + ) + except Exception as exc: + return { + "ok": False, + "error": f"Couldn't reach Vertex AI ({exc.__class__.__name__}).", + } + if resp.status_code < 300: + return {"ok": True} + if resp.status_code in (401, 403): + return {"ok": False, "error": "Google rejected the API key."} + return {"ok": False, "error": f"Vertex AI returned HTTP {resp.status_code}."} + if method == "service_account" and not (fields.get("service_account_json") or "").strip(): + return {"ok": False, "error": "Paste a service-account JSON to test."} try: - creds = load_credentials(fields.get("service_account_json")) + creds = None + if method == "service_account": + creds = load_credentials(fields.get("service_account_json")) if creds is None: import google.auth diff --git a/coworker/providers/vertex_provider.py b/coworker/providers/vertex_provider.py index 3110dd64..0891339a 100644 --- a/coworker/providers/vertex_provider.py +++ b/coworker/providers/vertex_provider.py @@ -12,11 +12,22 @@ An id with no recognized family segment is best-effort routed by name (gemini* claude* → Claude, anything else → MaaS) so a raw id pasted without the add-model dropdown still works. -Credentials: an explicit service-account JSON (pasted content or a file path) when the -profile has one, else Application Default Credentials (`gcloud auth application-default -login`). The MaaS path authenticates with a google-auth bearer token that expires ~hourly — -this wrapper refreshes it and rebuilds the OpenAI sub-client as needed; the two native SDK -clients take the credentials object and refresh internally. +Auth is ONE method at a time, selected by the profile's `auth_method` (a segmented choice +in Settings, mirroring Bedrock — owner call 2026-07-26): + +- `adc` — Application Default Credentials (`gcloud auth application-default + login`), Google's own recommended path. Nothing stored. +- `service_account` — an explicit service-account JSON (pasted content or a file path). +- `api_key` — a Vertex API key (express mode). GEMINI FAMILY ONLY: the genai SDK + takes it (and it excludes project/location — mutually exclusive there), but Claude + (AnthropicVertex) and the MaaS endpoint require OAuth credentials, so those families + raise a clear error directing the user to the other methods. + +The MaaS path authenticates with a google-auth bearer token that expires ~hourly — this +wrapper refreshes it and rebuilds the OpenAI sub-client as needed; the two native SDK +clients take the credentials object and refresh internally. Fields from non-selected +methods are dropped at construction; a missing/unknown method falls back to whichever +fields are present (service account, else ADC). """ from __future__ import annotations @@ -59,14 +70,25 @@ class VertexProvider(ProviderClient): *, project: Optional[str] = None, location: Optional[str] = None, + auth_method: Optional[str] = None, service_account_json: Optional[str] = None, + api_key: Optional[str] = None, credentials: Any = None, gemini_client: Optional[ProviderClient] = None, claude_client: Optional[ProviderClient] = None, openweight_client: Optional[ProviderClient] = None, ): + # Narrow to the selected auth method here, once — stale values stored under a + # previously-selected method must never reach a different credential path. + if auth_method == "adc": + service_account_json = api_key = None + elif auth_method == "service_account": + api_key = None + elif auth_method == "api_key": + service_account_json = None self._project = project self._location = location + self._api_key = api_key self._service_account_json = service_account_json self._credentials = credentials # test seam; normally resolved lazily # Test seams: pre-built sub-providers skip the SDK construction below. @@ -117,6 +139,12 @@ class VertexProvider(ProviderClient): # -- family sub-clients -------------------------------------------------------- def _family_client(self, family: str) -> ProviderClient: + if self._api_key and family != "gemini": + raise RuntimeError( + "Vertex API keys cover Gemini models only — switch the Vertex provider " + "to Google Cloud login or a service account for Claude and open-weight " + "models (Settings ▸ Models)." + ) if family == "openweight": return self._openweight_client() client = self._clients.get(family) @@ -124,14 +152,18 @@ class VertexProvider(ProviderClient): if family == "gemini": from google import genai - client = GeminiProvider( - client=genai.Client( + if self._api_key: + # Express mode: the key excludes project/location (SDK enforces + # mutual exclusivity — the key already identifies the project). + sdk = genai.Client(vertexai=True, api_key=self._api_key) + else: + sdk = genai.Client( vertexai=True, project=self._project, location=self._location, credentials=self._explicit_credentials(), ) - ) + client = GeminiProvider(client=sdk) else: from anthropic import AnthropicVertex diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 516b6662..dcb54ac7 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1265,7 +1265,9 @@ export interface ProviderField { help: string; placeholder: string; default?: string; // pre-filled editable value (e.g. an OpenAI-compatible vendor's endpoint) - choices?: { value: string; label: string }[]; // non-empty → segmented choice, not a text input + // Non-empty → segmented choice, not a text input. tag = tiny badge ("Easiest"); + // desc = one-liner atop the method panel; command = copyable terminal command. + choices?: { value: string; label: string; tag?: string; desc?: string; command?: string }[]; show_when?: Record | null; // render only while these fields hold these values } diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 27e522e2..b2f962d4 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -4,6 +4,7 @@ import { removeProvider, setProvider, verifyProvider, + type ProviderField as ProviderFieldT, type ProviderInfo, } from "../api"; import { openExternal } from "../tauri"; @@ -321,7 +322,73 @@ export function ProviderForm({ const label = "block text-[12px] text-muted mt-3 mb-1"; const input = "w-full px-3 py-2 rounded-lg border bg-panel text-[13.5px] outline-none focus:border-accent"; + const fieldsAll = info?.fields || []; + const keyed = fieldsAll.some((x) => x.secret); + // Cloud providers declare a segmented auth-method choice; the selected method's + // credential fields render inside a panel with its own Test & save footer. + const choice = fieldsAll.find((f) => f.choices && f.choices.length); + const method = choice ? ps.fields[choice.key] || choice.default || "" : ""; + const selected = choice?.choices?.find((c) => c.value === method); + const methodFields = choice + ? fieldsAll.filter( + (f) => + f.show_when && + Object.entries(f.show_when).every(([k, v]) => (ps.fields[k] || "") === v), + ) + : []; + // Without a choice control, Test lives next to the required secret (the API key), or + // the first field for keyless providers (Ollama's Detect). + const requiredSecret = fieldsAll.find((x) => x.secret && x.required); + const testKey = requiredSecret ? requiredSecret.key : fieldsAll[0]?.key; if (!sel) return null; + + const fieldRow = (f: ProviderFieldT, testable: boolean) => ( +
+ +
+
+ ps.setFieldValue(f.key, e.target.value)} + onBlur={f.secret ? undefined : () => void ps.saveField(f.key)} + /> + {ps.fieldSaved === f.key && ( + + ✓ Saved + + )} + {/* §39: state lives IN the field — no status lines below. */} + {ps.savedState && testable && ( + + {info?.needs_key ? <>✓ Tested & saved : <>✓ Detected} + + )} +
+ {testable && ( + + )} +
+ {f.help &&

{f.help}

} +
+ ); + return (
{info?.blurb &&

{info.blurb}

} - {(info?.fields || []).map((f) => { - const keyed = (info?.fields || []).some((x) => x.secret); - // A keyed provider's base_url is an expert option — it renders BELOW the key-help - // line as its own advanced section (owner nit 2026-07-19), not inside the loop. - if (f.key === "base_url" && keyed) return null; - // Conditional fields (Bedrock's per-auth-method inputs) render only while their - // controlling field holds the matching value. - if (f.show_when && !Object.entries(f.show_when).every(([k, v]) => (ps.fields[k] || "") === v)) - return null; - // Segmented choice (e.g. Bedrock's "Connect with"): a button per option, stored - // like any field value. Switching methods requires a fresh Test to save. - if (f.choices?.length) - return ( -
- -
- {f.choices.map((c) => { - const active = (ps.fields[f.key] || "") === c.value; - return ( - - ); - })} -
- {f.help &&

{f.help}

} -
- ); - // Test lives next to the required secret (the API key) when there is one; cloud - // providers whose secrets are all optional (Bedrock, Vertex) test from the first - // field instead — their credentials may be ambient (~/.aws, ADC). - const requiredSecret = (info?.fields || []).find((x) => x.secret && x.required); - const testable = requiredSecret ? f.key === requiredSecret.key : f.key === (info?.fields || [])[0]?.key; - return ( -
- -
-
- ps.setFieldValue(f.key, e.target.value)} - onBlur={f.secret ? undefined : () => void ps.saveField(f.key)} - /> - {ps.fieldSaved === f.key && ( - - ✓ Saved - - )} - {/* §39: state lives IN the field — no status lines below. */} - {ps.savedState && testable && ( - - {info?.needs_key ? <>✓ Tested & saved : <>✓ Detected} - - )} -
- {testable && ( + {fieldsAll + .filter( + (f) => + !f.show_when && + !(f.choices && f.choices.length) && + !(f.key === "base_url" && keyed), + ) + .map((f) => fieldRow(f, !choice && f.key === testKey))} + + {/* Auth-method segmented control + the selected method's panel (owner call + 2026-07-26): one joined track, then a soft inset card holding only that + method's description, fields, and its own Test & save footer. */} + {choice && ( +
+ +
+ {(choice.choices || []).map((c) => { + const active = method === c.value; + return ( - )} -
- {f.help &&

{f.help}

} + ); + })}
- ); - })} + +
+ {selected?.desc &&

{selected.desc}

} + {selected?.command && ( + + )} + {methodFields.map((f) => fieldRow(f, false))} +
+ {ps.savedState ? ( + + ✓ Tested & saved + + ) : ( + Runs one read-only check, then saves. + )} + +
+
+
+ )} {info?.needs_key && KEY_HELP[sel] && (

diff --git a/tests/test_vertex_provider.py b/tests/test_vertex_provider.py index b1f52b95..48055b03 100644 --- a/tests/test_vertex_provider.py +++ b/tests/test_vertex_provider.py @@ -142,10 +142,25 @@ def test_vertex_descriptor_and_builder(): d = get_descriptor("vertex") assert d is not None and d.needs_key - assert [f.key for f in d.fields] == ["project", "location", "service_account_json"] + assert [f.key for f in d.fields] == [ + "project", + "location", + "auth_method", + "service_account_json", + "vertex_api_key", + ] assert [f.key for f in d.fields if f.required] == ["project", "location"] - sa = next(f for f in d.fields if f.key == "service_account_json") - assert sa.secret and "Application Default Credentials" in sa.help + # ADC is the default method (Google's own recommendation) and carries the copyable + # sign-in command; the two credential fields hide behind their methods. + method = next(f for f in d.fields if f.key == "auth_method") + assert method.default == "adc" + assert [c["value"] for c in method.choices] == ["adc", "service_account", "api_key"] + adc = next(c for c in method.choices if c["value"] == "adc") + assert adc["command"].startswith("gcloud auth application-default") + by_key = {f.key: f for f in d.fields} + assert by_key["service_account_json"].show_when == {"auth_method": "service_account"} + assert by_key["vertex_api_key"].show_when == {"auth_method": "api_key"} + assert by_key["service_account_json"].secret and by_key["vertex_api_key"].secret from coworker.providers.matrix import models_for_provider @@ -158,6 +173,108 @@ def test_vertex_descriptor_and_builder(): assert p._project == "proj" and p._location == "europe-west1" +def test_auth_method_narrows_out_other_methods_fields(): + """Stale values stored under a previously-selected method must never leak into a + different auth path.""" + p = VertexProvider( + project="proj", + location="us-east5", + auth_method="adc", + service_account_json="{stale}", + api_key="AQ.stale", + ) + assert p._service_account_json is None and p._api_key is None + p2 = VertexProvider( + project="proj", + location="us-east5", + auth_method="api_key", + api_key="AQ.live", + service_account_json="{stale}", + ) + assert p2._api_key == "AQ.live" and p2._service_account_json is None + + +def test_api_key_method_is_gemini_only(): + p = VertexProvider( + project="proj", location="us-east5", auth_method="api_key", api_key="AQ.k" + ) + msgs = [{"role": "user", "content": "x"}] + with pytest.raises(RuntimeError, match="Gemini models only"): + p.complete(model="claude/claude-sonnet-4-6", messages=msgs) + with pytest.raises(RuntimeError, match="Gemini models only"): + p.complete(model="openweight/meta/llama-4-maverick-maas", messages=msgs) + + +def test_api_key_method_builds_express_gemini_client(monkeypatch): + """Express mode: the key goes to genai.Client WITHOUT project/location (the SDK + treats them as mutually exclusive with an API key).""" + from google import genai + + captured: dict = {} + + class _FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(genai, "Client", _FakeClient) + p = VertexProvider( + project="proj", location="us-east5", auth_method="api_key", api_key="AQ.k" + ) + sub = p._family_client("gemini") + from coworker.providers import GeminiProvider + + assert isinstance(sub, GeminiProvider) + assert captured == {"vertexai": True, "api_key": "AQ.k"} + + +def test_verify_vertex_api_key_method(monkeypatch): + import httpx + + from coworker.providers.registry import verify_provider_key + + out = verify_provider_key( + "vertex", + fields={"project": "p", "location": "l", "auth_method": "api_key"}, + ) + assert not out["ok"] and "API key" in out["error"] + + captured: dict = {} + + def fake_get(url, headers=None, timeout=None, **kw): + captured["url"] = url + captured["headers"] = headers + + class _Resp: + status_code = 200 + + return _Resp() + + monkeypatch.setattr(httpx, "get", fake_get) + out = verify_provider_key( + "vertex", + fields={ + "project": "p", + "location": "l", + "auth_method": "api_key", + "vertex_api_key": "AQ.k", + }, + ) + assert out == {"ok": True} + assert captured["headers"]["x-goog-api-key"] == "AQ.k" + # Express mode is global — no region host, no project in the path. + assert captured["url"] == "https://aiplatform.googleapis.com/v1/publishers/google/models" + + +def test_verify_vertex_service_account_requires_json(): + from coworker.providers.registry import verify_provider_key + + out = verify_provider_key( + "vertex", + fields={"project": "p", "location": "l", "auth_method": "service_account"}, + ) + assert not out["ok"] and "service-account JSON" in out["error"] + + def test_vertex_configured_needs_project_and_location(): from coworker.providers.registry import descriptor_configured, get_descriptor