mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Provider auth redesign: joined segments, method panels, Vertex methods
Segmented track + inset per-method panel with its own Test & save footer. Vertex gains the same treatment: Google Cloud login (default), service account, and API key (express mode, Gemini-only with a clear error elsewhere).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, string> | null; // render only while these fields hold these values
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
className={input + (ps.savedState && testable ? " border-ok pr-32" : " border-line")}
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.secret && ps.credentialed && !ps.dirty ? "••••••••" : f.placeholder}
|
||||
value={ps.fields[f.key] || ""}
|
||||
data-testid={`${tp}-field-${f.key}`}
|
||||
onChange={(e) => ps.setFieldValue(f.key, e.target.value)}
|
||||
onBlur={f.secret ? undefined : () => void ps.saveField(f.key)}
|
||||
/>
|
||||
{ps.fieldSaved === f.key && (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||
data-testid={`${tp}-field-saved-${f.key}`}
|
||||
>
|
||||
✓ Saved
|
||||
</span>
|
||||
)}
|
||||
{/* §39: state lives IN the field — no status lines below. */}
|
||||
{ps.savedState && testable && (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||
data-testid={`${tp}-saved-pill`}
|
||||
>
|
||||
{info?.needs_key ? <>✓ Tested & saved</> : <>✓ Detected</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{testable && (
|
||||
<button
|
||||
className="px-4 rounded-lg border border-line text-[13px] font-medium text-ink hover:border-lineStrong shrink-0 disabled:opacity-40"
|
||||
onClick={() => ps.runTestAndSave()}
|
||||
disabled={ps.verify.state === "testing" || (!ps.secretFilled && !ps.credentialed)}
|
||||
data-testid={`${tp}-test`}
|
||||
>
|
||||
{ps.verify.state === "testing" ? "…" : info?.needs_key ? "Test" : "Detect"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{f.help && <p className="text-[11.5px] text-faint mt-1">{f.help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button className="text-[12.5px] text-muted hover:text-ink" onClick={ps.backToGallery} data-testid={`${tp}-back`}>
|
||||
@@ -336,98 +403,87 @@ export function ProviderForm({
|
||||
</div>
|
||||
{info?.blurb && <p className="text-[11.5px] text-faint mt-1">{info.blurb}</p>}
|
||||
|
||||
{(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 (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<div className="flex gap-1.5" role="radiogroup" aria-label={f.label}>
|
||||
{f.choices.map((c) => {
|
||||
const active = (ps.fields[f.key] || "") === c.value;
|
||||
return (
|
||||
<button
|
||||
key={c.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={
|
||||
"px-3 py-1.5 rounded-lg border text-[12.5px] transition-colors " +
|
||||
(active
|
||||
? "border-accent text-ink font-medium"
|
||||
: "border-line text-muted hover:border-lineStrong")
|
||||
}
|
||||
data-testid={`${tp}-choice-${f.key}-${c.value}`}
|
||||
onClick={() => ps.setFieldValue(f.key, c.value)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{f.help && <p className="text-[11.5px] text-faint mt-1">{f.help}</p>}
|
||||
</div>
|
||||
);
|
||||
// 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 (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
className={input + (ps.savedState && testable ? " border-ok pr-32" : " border-line")}
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.secret && ps.credentialed && !ps.dirty ? "••••••••" : f.placeholder}
|
||||
value={ps.fields[f.key] || ""}
|
||||
data-testid={`${tp}-field-${f.key}`}
|
||||
onChange={(e) => ps.setFieldValue(f.key, e.target.value)}
|
||||
onBlur={f.secret ? undefined : () => void ps.saveField(f.key)}
|
||||
/>
|
||||
{ps.fieldSaved === f.key && (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||
data-testid={`${tp}-field-saved-${f.key}`}
|
||||
>
|
||||
✓ Saved
|
||||
</span>
|
||||
)}
|
||||
{/* §39: state lives IN the field — no status lines below. */}
|
||||
{ps.savedState && testable && (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||
data-testid={`${tp}-saved-pill`}
|
||||
>
|
||||
{info?.needs_key ? <>✓ Tested & saved</> : <>✓ Detected</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{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 && (
|
||||
<div>
|
||||
<label className={label}>{choice.label}</label>
|
||||
<div
|
||||
className="inline-flex gap-0.5 rounded-[10px] border border-line bg-line/40 p-[3px]"
|
||||
role="radiogroup"
|
||||
aria-label={choice.label}
|
||||
>
|
||||
{(choice.choices || []).map((c) => {
|
||||
const active = method === c.value;
|
||||
return (
|
||||
<button
|
||||
className="px-4 rounded-lg border border-line text-[13px] font-medium text-ink hover:border-lineStrong shrink-0 disabled:opacity-40"
|
||||
onClick={() => ps.runTestAndSave()}
|
||||
disabled={ps.verify.state === "testing" || (!ps.secretFilled && !ps.credentialed)}
|
||||
data-testid={`${tp}-test`}
|
||||
key={c.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12.5px] whitespace-nowrap transition-colors " +
|
||||
(active
|
||||
? "bg-panel text-ink font-medium shadow-sm ring-1 ring-line"
|
||||
: "text-muted hover:text-ink")
|
||||
}
|
||||
data-testid={`${tp}-choice-${choice.key}-${c.value}`}
|
||||
onClick={() => ps.setFieldValue(choice.key, c.value)}
|
||||
>
|
||||
{ps.verify.state === "testing" ? "…" : info?.needs_key ? "Test" : "Detect"}
|
||||
{c.label}
|
||||
{c.tag && (
|
||||
<span className="text-[9.5px] font-semibold uppercase tracking-wide text-accent bg-accentSoft rounded-full px-1.5 py-px">
|
||||
{c.tag}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{f.help && <p className="text-[11.5px] text-faint mt-1">{f.help}</p>}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2.5 rounded-xl border border-line bg-paper/60 px-4 pb-3.5 pt-3">
|
||||
{selected?.desc && <p className="text-[12px] text-muted">{selected.desc}</p>}
|
||||
{selected?.command && (
|
||||
<button
|
||||
className="mt-2.5 inline-flex items-center gap-2 rounded-lg border border-line bg-panel px-2.5 py-1.5 text-[12px] font-mono text-ink hover:border-lineStrong"
|
||||
onClick={() => void navigator.clipboard?.writeText(selected.command || "")}
|
||||
title="Copy command"
|
||||
data-testid={`${tp}-cmd-copy`}
|
||||
>
|
||||
{selected.command}
|
||||
<span className="font-sans text-[11px] text-faint">⧉</span>
|
||||
</button>
|
||||
)}
|
||||
{methodFields.map((f) => fieldRow(f, false))}
|
||||
<div className="mt-3.5 flex items-center justify-between gap-3 border-t border-line pt-3">
|
||||
{ps.savedState ? (
|
||||
<span className="text-[11.5px] font-medium text-ok" data-testid={`${tp}-saved-pill`}>
|
||||
✓ Tested & saved
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11.5px] text-faint">Runs one read-only check, then saves.</span>
|
||||
)}
|
||||
<button
|
||||
className="shrink-0 rounded-lg border border-accent bg-accent px-4 py-1.5 text-[13px] font-medium text-white hover:brightness-105 disabled:opacity-40"
|
||||
onClick={() => ps.runTestAndSave()}
|
||||
disabled={ps.verify.state === "testing"}
|
||||
data-testid={`${tp}-test`}
|
||||
>
|
||||
{ps.verify.state === "testing" ? "…" : <>Test & save</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.needs_key && KEY_HELP[sel] && (
|
||||
<p className="text-[11.5px] text-faint mt-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user