This commit is contained in:
Jeremy Gu
2026-08-29 17:04:20 -07:00
committed by GitHub
13 changed files with 547 additions and 31 deletions
+7 -1
View File
@@ -76,10 +76,16 @@ Unattended runs never self-approve: their asks park in an inbox until a human an
Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box:
**OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
**OpenAI · Azure OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.
### Azure OpenAI with Microsoft Entra ID
In **Settings → Models → OpenAI**, choose **Microsoft Entra ID** and enter the Azure tenant ID, application (client) ID, client secret, and the resource's v1 endpoint (for example, `https://RESOURCE.openai.azure.com/openai/v1`). The service principal needs the **Cognitive Services OpenAI User** role on the resource. OpenWorker uses `azure-identity` for token acquisition and automatic refresh; the existing API-key path remains the default.
See Microsoft's [Azure OpenAI v1 authentication guide](https://learn.microsoft.com/azure/ai-foundry/openai/api-version-lifecycle) for endpoint and RBAC setup.
## Privacy
OpenWorker is local-first. Everything lives on your machine: the agent loop, your conversations, connector tokens, and model keys - all in the app's local secret store. The only cloud piece is a small service that brokers OAuth handshakes for connectors. You can always use the App without signing-in - use the connectors via manually-created credentials/API-keys.
+4
View File
@@ -19,6 +19,8 @@ from .registry import (
descriptor_configured,
detect_provider,
get_descriptor,
provider_active_fields,
provider_missing_fields,
provider_descriptors,
provider_names,
verify_provider_key,
@@ -47,6 +49,8 @@ __all__ = [
"provider_descriptors",
"provider_names",
"get_descriptor",
"provider_active_fields",
"provider_missing_fields",
"build_provider_client",
"descriptor_configured",
"detect_provider",
+32 -2
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import json
import re
from typing import Any, Optional
from typing import Any, Callable, Optional
from .base import (
AssistantTurn,
@@ -25,6 +25,36 @@ from .base import (
from .capabilities import capabilities_for
# Azure OpenAI's v1 data-plane OAuth scope. Keep the resource identity here beside the
# credential factory so the runtime and the Settings verification probe cannot drift.
AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
def build_azure_ad_token_provider(
tenant_id: str, client_id: str, client_secret: str
) -> Callable[[], str]:
"""Build Azure Identity's caching, auto-refreshing service-principal token callback.
The callback is passed straight to the OpenAI SDK as ``api_key``. It is internal
authentication state, never a model-controlled tool argument.
"""
try:
from azure.identity import ClientSecretCredential, get_bearer_token_provider
# Defensive for source installs made before azure-identity became a core dependency.
except ImportError as exc:
raise RuntimeError(
"Microsoft Entra ID authentication requires azure-identity. "
"Reinstall OpenWorker to add it."
) from exc
credential = ClientSecretCredential(
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
)
return get_bearer_token_provider(credential, AZURE_OPENAI_SCOPE)
def resolve_api_key(secrets: Any = None) -> Optional[str]:
"""Resolve the OpenAI API key: env `OPENAI_API_KEY` first, else the SecretStore
`provider:openai` profile (`{api_key}`). Lets a Tauri-launched sidecar — which does NOT
@@ -142,7 +172,7 @@ class OpenAIProvider(ProviderClient):
client: Any = None,
*,
default_model: str = "gpt-5.6-sol",
api_key: Optional[str] = None,
api_key: Optional[str | Callable[[], str]] = None,
base_url: Optional[str] = None,
secrets: Any = None,
):
+150 -6
View File
@@ -25,11 +25,17 @@ from .anthropic_provider import AnthropicProvider
from .base import ProviderClient
from .bedrock_provider import BedrockProvider
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider
from .openai_provider import OpenAIProvider, build_azure_ad_token_provider
from .openai_responses import OpenAIResponsesProvider
from .vertex_provider import VertexProvider
DEFAULT_OLLAMA_URL = "http://localhost:11434"
OPENAI_AUTH_API_KEY = "api_key"
OPENAI_AUTH_AZURE_AD = "azure_ad"
def _openai_auth_method(profile: dict[str, Any]) -> str:
return str((profile or {}).get("auth_method") or OPENAI_AUTH_API_KEY).strip()
@dataclass(frozen=True)
@@ -123,7 +129,20 @@ def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# API — the only wire with reasoning + tools on GPT-5.6+. A custom endpoint (Azure
# OpenAI /openai/v1, vLLM, any OpenAI-compliant gateway) keeps Chat Completions,
# which is what compat servers implement.
base_url = ((profile or {}).get("base_url") or "").strip() or None
profile = profile or {}
base_url = (profile.get("base_url") or "").strip() or None
if _openai_auth_method(profile) == OPENAI_AUTH_AZURE_AD:
missing = provider_missing_fields(_BY_NAME["openai"], profile)
if missing:
raise RuntimeError(
"Microsoft Entra ID authentication requires: " + ", ".join(missing)
)
token_provider = build_azure_ad_token_provider(
str(profile["tenant_id"]).strip(),
str(profile["client_id"]).strip(),
str(profile["client_secret"]).strip(),
)
return OpenAIProvider(api_key=token_provider, base_url=base_url)
if base_url:
return OpenAIProvider(secrets=secrets, base_url=base_url)
return OpenAIResponsesProvider(secrets=secrets)
@@ -341,24 +360,63 @@ DESCRIPTORS: list[ProviderDescriptor] = [
title="OpenAI",
needs_key=True,
fields=[
ProviderField(
"auth_method",
"Connect with",
required=False,
default=OPENAI_AUTH_API_KEY,
choices=(
{
"value": OPENAI_AUTH_API_KEY,
"label": "API key",
"tag": "Default",
"desc": "Use an OpenAI key, or a key accepted by your custom OpenAI-compatible endpoint.",
},
{
"value": OPENAI_AUTH_AZURE_AD,
"label": "Microsoft Entra ID",
"desc": "Use an Azure service principal. Set its Azure /openai/v1 endpoint under Custom endpoint below; tokens refresh automatically.",
},
),
),
ProviderField(
"api_key",
"OpenAI API key",
secret=True,
placeholder="sk-…",
show_when={"auth_method": OPENAI_AUTH_API_KEY},
),
ProviderField(
"tenant_id",
"Tenant ID",
placeholder="00000000-0000-0000-0000-000000000000",
show_when={"auth_method": OPENAI_AUTH_AZURE_AD},
),
ProviderField(
"client_id",
"Client ID",
placeholder="00000000-0000-0000-0000-000000000000",
show_when={"auth_method": OPENAI_AUTH_AZURE_AD},
),
ProviderField(
"client_secret",
"Client secret",
secret=True,
show_when={"auth_method": OPENAI_AUTH_AZURE_AD},
),
ProviderField(
"base_url",
"Custom endpoint (optional)",
"Custom endpoint",
secret=False,
required=False,
placeholder="https://…/openai/v1",
help="For Azure OpenAI, vLLM, or any OpenAI-compliant server. Leave blank for api.openai.com.",
help="Required for Microsoft Entra ID (for example, https://RESOURCE.openai.azure.com/openai/v1). Optional for API-key OpenAI-compatible servers.",
),
],
build=_build_openai,
recommended_model="gpt-5.6-sol",
env_key="OPENAI_API_KEY",
blurb="OpenAI by default; custom endpoints can use an API key or Microsoft Entra ID.",
),
ProviderDescriptor(
name="openai-codex",
@@ -723,11 +781,47 @@ def descriptor_configured(d: ProviderDescriptor, profile: dict[str, Any]) -> boo
if not d.needs_key:
return True # keyless (Ollama) — usable out of the box
profile = profile or {}
if any(f.key == "api_key" for f in d.fields):
if any(f.key == "api_key" for f in provider_active_fields(d, profile)):
return bool(profile.get("api_key")) or bool(
d.env_key and os.environ.get(d.env_key)
)
return all(profile.get(f.key) for f in d.fields if f.required)
return not provider_missing_fields(d, profile)
def provider_active_fields(
d: ProviderDescriptor, profile: dict[str, Any]
) -> list[ProviderField]:
"""Fields selected by the provider schema's current ``show_when`` values."""
profile = profile or {}
values = {f.key: profile.get(f.key) or f.default for f in d.fields}
return [
f
for f in d.fields
if not f.show_when
or all(values.get(key) == value for key, value in f.show_when.items())
]
def provider_missing_fields(
d: ProviderDescriptor, profile: dict[str, Any]
) -> list[str]:
"""Labels of required fields active for the selected provider auth method.
``show_when`` is the provider schema's single source of truth for method-specific
fields. Azure's shared custom endpoint stays visible for both methods, so its
conditional requirement is added here beside the Azure auth policy.
"""
profile = profile or {}
missing = [
f.label
for f in provider_active_fields(d, profile)
if f.required and not str(profile.get(f.key) or "").strip()
]
if d.name == "openai" and _openai_auth_method(profile) == OPENAI_AUTH_AZURE_AD:
endpoint = next(f for f in d.fields if f.key == "base_url")
if not str(profile.get("base_url") or "").strip():
missing.append(endpoint.label)
return missing
def detect_provider(api_key: str) -> Optional[str]:
@@ -927,6 +1021,54 @@ def _verify_vertex(fields: dict[str, Any], timeout: float) -> dict[str, Any]:
return {"ok": False, "error": f"Vertex AI returned HTTP {resp.status_code}."}
def _verify_azure_openai_entra(
fields: dict[str, Any], timeout: float
) -> dict[str, Any]:
"""Acquire one Entra token and use it for Azure OpenAI's read-only model-list probe."""
import httpx
missing = provider_missing_fields(_BY_NAME["openai"], fields)
if missing:
return {
"ok": False,
"error": "Microsoft Entra ID requires: " + ", ".join(missing) + ".",
}
base = str(fields["base_url"]).strip().rstrip("/")
try:
token_provider = build_azure_ad_token_provider(
str(fields["tenant_id"]).strip(),
str(fields["client_id"]).strip(),
str(fields["client_secret"]).strip(),
)
token = token_provider()
resp = httpx.get(
base + "/models",
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
)
except Exception as exc:
return {
"ok": False,
"error": "Couldn't authenticate with Microsoft Entra ID "
f"({exc.__class__.__name__}).",
}
if resp.status_code < 300:
return {"ok": True}
if resp.status_code == 401:
return {
"ok": False,
"error": "Azure OpenAI rejected the Microsoft Entra token.",
}
if resp.status_code == 403:
return {
"ok": False,
"error": "The service principal lacks access to this Azure OpenAI resource.",
}
if resp.status_code == 404:
return {"ok": False, "error": "Azure OpenAI endpoint not found."}
return {"ok": False, "error": f"Azure OpenAI returned HTTP {resp.status_code}."}
def verify_provider_key(
name: str,
*,
@@ -946,6 +1088,8 @@ def verify_provider_key(
d = _BY_NAME.get(name) or _BY_NAME["openai"]
key = (api_key or "").strip()
if name == "openai" and _openai_auth_method(fields or {}) == OPENAI_AUTH_AZURE_AD:
return _verify_azure_openai_entra(fields or {}, timeout)
if d.auth == "oauth":
# OAuth providers verify from their stored tokens (needs the SecretStore),
# which only the manager holds — see SessionManager.verify_provider.
+7 -3
View File
@@ -79,6 +79,8 @@ from ..providers import (
ProviderRouter,
descriptor_configured,
get_descriptor,
provider_active_fields,
provider_missing_fields,
provider_descriptors,
verify_provider_key,
)
@@ -3008,7 +3010,7 @@ class SessionManager:
profile[f.key] = val
elif not f.required:
profile.pop(f.key, None)
missing = [f.label for f in d.fields if f.required and not profile.get(f.key)]
missing = provider_missing_fields(d, profile)
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
# A (re)pasted key stamps its save date — Settings shows "key added <date>" so stale
@@ -3125,13 +3127,15 @@ class SessionManager:
api_key = merged.get("api_key", "")
if not api_key and d.env_key:
api_key = os.environ.get(d.env_key, "").strip()
has_key_field = any(f.key == "api_key" for f in d.fields)
has_key_field = any(
f.key == "api_key" for f in provider_active_fields(d, merged)
)
if d.needs_key and has_key_field and not api_key:
return {"ok": False, "error": "Enter an API key to test."}
if d.needs_key and not has_key_field:
# Multi-field cloud providers (Bedrock): required fields must be present;
# actual credentials may be ambient (~/.aws, env) and are checked by the call.
missing = [f.label for f in d.fields if f.required and not merged.get(f.key)]
missing = provider_missing_fields(d, merged)
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
return verify_provider_key(
+9 -1
View File
@@ -62,7 +62,15 @@ if not INCLUDE_EXPERIMENTAL:
# collect it explicitly or the packaged relay adapter fails to open its socket.
# `pypdf`/`pypdfium2` are lazy-imported the same way (pdf_support.py) — and pypdfium2
# carries the libpdfium binary, which collect_all is what actually stages.
for pkg in ("uvicorn", "certifi", "anyio", "websockets", "pypdf", "pypdfium2"):
for pkg in (
"uvicorn",
"certifi",
"anyio",
"websockets",
"pypdf",
"pypdfium2",
"azure.identity",
):
d, b, h = collect_all(pkg)
datas += d
binaries += b
+4 -1
View File
@@ -8,7 +8,10 @@ version = "0.0.0"
description = "Agent coworker platform — provider-agnostic agentic coworker runtime"
requires-python = ">=3.10"
dependencies = [
"openai>=1.0",
# 1.106 added callable API keys, which Azure OpenAI's v1 endpoint uses for
# automatically refreshed Microsoft Entra bearer tokens.
"openai>=1.106.0",
"azure-identity>=1.17", # Azure OpenAI service-principal auth + token refresh
"anthropic>=0.40", # native Claude Messages API provider
"google-genai>=1.0", # native Gemini provider
"google-auth>=2.23", # Vertex credentials (service account / ADC / MaaS bearer)
+14 -3
View File
@@ -358,7 +358,17 @@ const baseName = (p: string) => p.split("/").filter(Boolean).pop() || p;
const PROVIDERS = [
// openai: configured + used (drives the "Last used" sub-line and the status dot).
{ name: "openai", title: "OpenAI", needs_key: true, fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["gpt-5.5"], key_set_at: "2026-06-12", last_used_at: Math.floor(Date.now() / 1000) - 7200 },
{ name: "openai", title: "OpenAI", needs_key: true, blurb: "OpenAI by default; custom endpoints can use an API key or Microsoft Entra ID.", fields: [
{ key: "auth_method", label: "Connect with", secret: false, required: false, help: "", placeholder: "", default: "api_key", choices: [
{ value: "api_key", label: "API key", tag: "Default", desc: "Use an OpenAI key, or a key accepted by your custom OpenAI-compatible endpoint." },
{ value: "azure_ad", label: "Microsoft Entra ID", desc: "Use an Azure service principal. Set its Azure /openai/v1 endpoint under Custom endpoint below; tokens refresh automatically." },
] },
{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…", show_when: { auth_method: "api_key" } },
{ key: "tenant_id", label: "Tenant ID", secret: false, required: true, help: "", placeholder: "00000000-0000-0000-0000-000000000000", show_when: { auth_method: "azure_ad" } },
{ key: "client_id", label: "Client ID", secret: false, required: true, help: "", placeholder: "00000000-0000-0000-0000-000000000000", show_when: { auth_method: "azure_ad" } },
{ key: "client_secret", label: "Client secret", secret: true, required: true, help: "", placeholder: "", show_when: { auth_method: "azure_ad" } },
{ key: "base_url", label: "Custom endpoint", secret: false, required: false, help: "Required for Microsoft Entra ID.", placeholder: "https://…/openai/v1" },
], configured: true, values: { auth_method: "api_key" }, suggested_models: ["gpt-5.5"], key_set_at: "2026-06-12", last_used_at: Math.floor(Date.now() / 1000) - 7200 },
// anthropic: configured but never used ("Not used yet").
{ name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null },
// zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb.
@@ -1942,13 +1952,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
const b = req.postDataJSON();
const prov = providers.find((x) => x.name === b.name);
if (!prov) return json({ ok: false, error: `unknown provider: ${b.name}` });
if (b.fields?.api_key) {
const secretKeys = new Set(prov.fields.filter((f) => f.secret).map((f) => f.key));
if (Object.entries(b.fields || {}).some(([k, v]) => secretKeys.has(k) && v)) {
prov.configured = true;
prov.key_set_at = "2026-07-05";
}
// Backend parity: non-secret fields merge into `values` (empty clears them).
for (const [k, v] of Object.entries(b.fields || {})) {
if (k === "api_key") continue;
if (secretKeys.has(k)) continue;
if (v) prov.values = { ...prov.values, [k]: v };
else if (prov.values) delete prov.values[k];
}
+26
View File
@@ -53,6 +53,32 @@ test("a configured provider's form opens with the saved state, no plaintext key"
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
});
test("OpenAI switches from API key to Microsoft Entra ID and saves that method", async ({
page,
}) => {
await openModels(page);
await page.getByTestId("set-provider-openai").click();
await page.getByTestId("set-choice-auth_method-azure_ad").click();
await expect(page.getByTestId("set-field-api_key")).toHaveCount(0);
await expect(page.getByTestId("set-field-tenant_id")).toBeVisible();
await expect(page.getByTestId("set-field-client_id")).toBeVisible();
await expect(page.getByTestId("set-field-client_secret")).toBeVisible();
await expect(page.getByTestId("set-test")).toBeDisabled();
await page.getByTestId("set-field-tenant_id").fill("00000000-0000-0000-0000-000000000001");
await page.getByTestId("set-field-client_id").fill("00000000-0000-0000-0000-000000000002");
await page.getByTestId("set-field-client_secret").fill("entra-client-secret");
await page.getByTestId("set-endpoint-link").click();
await page.getByTestId("set-field-base_url").fill("https://contoso.openai.azure.com/openai/v1");
await page.getByTestId("set-test").click();
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await expect(page.getByTestId("set-provider-openai")).toContainText("✓ Connected", {
timeout: 5_000,
});
});
test("non-secret fields blur-save on a configured provider (ollama endpoint)", async ({
page,
}) => {
+1 -1
View File
@@ -57,7 +57,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
const canNext = anyReady || nextFromForm;
const advance = async () => {
if (nextFromForm && !ps.credentialed) {
if (nextFromForm && !ps.activeCredentialed) {
ps.cancelBackTimer();
if (!(await ps.runTestAndSave())) return;
}
@@ -39,13 +39,47 @@ const BEDROCK: ProviderInfo = {
],
};
function makePs(fields: Record<string, string>, setFieldValue = vi.fn()): ProviderSetupState {
const OPENAI: ProviderInfo = {
name: "openai",
title: "OpenAI",
needs_key: true,
configured: false,
values: {},
suggested_models: [],
recommended_model: "gpt-5.6-sol",
fields: [
{
key: "auth_method",
label: "Connect with",
secret: false,
required: false,
help: "",
placeholder: "",
default: "api_key",
choices: [
{ value: "api_key", label: "API key" },
{ value: "azure_ad", label: "Microsoft Entra ID" },
],
},
{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…", show_when: { auth_method: "api_key" } },
{ key: "tenant_id", label: "Tenant ID", secret: false, required: true, help: "", placeholder: "", show_when: { auth_method: "azure_ad" } },
{ key: "client_id", label: "Client ID", secret: false, required: true, help: "", placeholder: "", show_when: { auth_method: "azure_ad" } },
{ key: "client_secret", label: "Client secret", secret: true, required: true, help: "", placeholder: "", show_when: { auth_method: "azure_ad" } },
{ key: "base_url", label: "Custom endpoint (optional)", secret: false, required: false, help: "", placeholder: "https://…/openai/v1" },
],
};
function makePs(
fields: Record<string, string>,
setFieldValue = vi.fn(),
provider: ProviderInfo = BEDROCK,
): ProviderSetupState {
return {
providers: [BEDROCK],
ordered: [BEDROCK],
providers: [provider],
ordered: [provider],
refreshProviders: async () => {},
sel: "bedrock",
info: BEDROCK,
sel: provider.name,
info: provider,
fields,
setFieldValue,
dirty: false,
@@ -54,6 +88,7 @@ function makePs(fields: Record<string, string>, setFieldValue = vi.fn()): Provid
setShowEndpoint: () => {},
keylessOk: new Set(),
credentialed: false,
activeCredentialed: false,
savedState: false,
secretFilled: true,
openProvider: () => {},
@@ -95,6 +130,32 @@ describe("ProviderForm auth-method choice", () => {
});
});
describe("ProviderForm Microsoft Entra ID choice", () => {
it("keeps the existing API-key form as the default", () => {
render(<ProviderForm ps={makePs({ auth_method: "api_key" }, vi.fn(), OPENAI)} tp="t" />);
expect(screen.getByTestId("t-field-api_key")).toBeTruthy();
expect(screen.queryByTestId("t-field-tenant_id")).toBeNull();
expect(screen.getByText(/Create one at platform\.openai\.com/)).toBeTruthy();
});
it("shows only service-principal fields for Microsoft Entra ID", () => {
render(<ProviderForm ps={makePs({ auth_method: "azure_ad" }, vi.fn(), OPENAI)} tp="t" />);
expect(screen.getByTestId("t-field-tenant_id")).toBeTruthy();
expect(screen.getByTestId("t-field-client_id")).toBeTruthy();
expect(screen.getByTestId("t-field-client_secret")).toBeTruthy();
expect(screen.queryByTestId("t-field-api_key")).toBeNull();
expect(screen.queryByText(/Create one at platform\.openai\.com/)).toBeNull();
expect(screen.getByTestId("t-endpoint-link")).toBeTruthy();
});
it("does not enable Test & save while the active required secret is empty", () => {
const ps = makePs({ auth_method: "azure_ad" }, vi.fn(), OPENAI);
ps.secretFilled = false;
render(<ProviderForm ps={ps} tp="t" />);
expect(screen.getByTestId("t-test").hasAttribute("disabled")).toBe(true);
});
});
describe("Ark provider presentation", () => {
it("uses separate BytePlus and Volcengine brand marks", () => {
const { container, rerender } = render(
+21 -8
View File
@@ -87,6 +87,7 @@ export interface ProviderSetupState {
setShowEndpoint: (v: boolean) => void;
keylessOk: Set<string>;
credentialed: boolean;
activeCredentialed: boolean;
savedState: boolean;
secretFilled: boolean;
openProvider: (name: string) => void;
@@ -134,6 +135,14 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
const info = providers.find((p) => p.name === sel);
const credentialed = !!info?.configured && !!info?.needs_key;
const authChoice = info?.fields.find((f) => f.choices && f.choices.length);
const selectedAuth = authChoice
? fields[authChoice.key] || authChoice.default || ""
: "";
const storedAuth = authChoice
? info?.values?.[authChoice.key] || authChoice.default || ""
: "";
const activeCredentialed = credentialed && (!authChoice || selectedAuth === storedAuth);
const openProvider = (name: string) => {
const p = providers.find((x) => x.name === name);
@@ -273,14 +282,18 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
setShowEndpoint,
keylessOk,
credentialed,
activeCredentialed,
// The in-field saved state (§39): green border + pill INSIDE the key box — shown
// for stored credentials and fresh test-passes alike; typing clears it.
savedState: (credentialed && !dirty) || verify.state === "ok",
savedState: (activeCredentialed && !dirty) || verify.state === "ok",
// Only REQUIRED secrets gate the Test button — cloud providers (Bedrock, Vertex)
// have optional key fields whose credentials may live in ~/.aws or ADC instead.
secretFilled: (info?.fields || []).every(
(f) => !f.secret || !f.required || (fields[f.key] || "").trim(),
),
secretFilled: (info?.fields || []).every((f) => {
const active =
!f.show_when ||
Object.entries(f.show_when).every(([key, value]) => (fields[key] || "") === value);
return !active || !f.secret || !f.required || !!(fields[f.key] || "").trim();
}),
openProvider,
backToGallery,
runTestAndSave,
@@ -470,7 +483,7 @@ export function ProviderForm({
<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}
placeholder={f.secret && ps.activeCredentialed && !ps.dirty ? "••••••••" : f.placeholder}
value={ps.fields[f.key] || ""}
data-testid={`${tp}-field-${f.key}`}
onChange={(e) => ps.setFieldValue(f.key, e.target.value)}
@@ -498,7 +511,7 @@ export function ProviderForm({
<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)}
disabled={ps.verify.state === "testing" || (!ps.secretFilled && !ps.activeCredentialed)}
data-testid={`${tp}-test`}
>
{ps.verify.state === "testing" ? "…" : info?.needs_key ? t("provider.test_btn") : t("provider.detect_btn")}
@@ -597,7 +610,7 @@ export function ProviderForm({
<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"}
disabled={ps.verify.state === "testing" || (!ps.secretFilled && !ps.activeCredentialed)}
data-testid={`${tp}-test`}
>
{ps.verify.state === "testing" ? "…" : t("provider.test_save_btn")}
@@ -607,7 +620,7 @@ export function ProviderForm({
</div>
)}
{info?.needs_key && KEY_HELP[sel] && (
{info?.needs_key && KEY_HELP[sel] && (!choice || method === "api_key") && (
<p className="text-[12px] text-faint mt-2">
{t("provider.no_key_yet")}{" "}
<button
+206
View File
@@ -0,0 +1,206 @@
"""Azure OpenAI v1 authentication through a Microsoft Entra service principal."""
from __future__ import annotations
from types import SimpleNamespace
from coworker.providers import OpenAIProvider
from coworker.providers.openai_provider import (
AZURE_OPENAI_SCOPE,
build_azure_ad_token_provider,
)
from coworker.providers.registry import (
build_provider_client,
descriptor_configured,
get_descriptor,
verify_provider_key,
)
AZURE_FIELDS = {
"auth_method": "azure_ad",
"base_url": "https://contoso.openai.azure.com/openai/v1",
"tenant_id": "tenant-1",
"client_id": "client-1",
"client_secret": "secret-1",
}
def test_token_provider_uses_client_secret_credential_and_azure_scope(monkeypatch):
captured: dict = {}
credential = object()
def token_provider():
return "token"
def fake_credential(**kwargs):
captured["credential_args"] = kwargs
return credential
def fake_provider(received_credential, scope):
captured["provider_args"] = (received_credential, scope)
return token_provider
monkeypatch.setattr("azure.identity.ClientSecretCredential", fake_credential)
monkeypatch.setattr("azure.identity.get_bearer_token_provider", fake_provider)
result = build_azure_ad_token_provider("tenant-1", "client-1", "secret-1")
assert result is token_provider
assert captured["credential_args"] == {
"tenant_id": "tenant-1",
"client_id": "client-1",
"client_secret": "secret-1",
}
assert captured["provider_args"] == (credential, AZURE_OPENAI_SCOPE)
def test_registry_builds_azure_openai_with_refreshable_token_callback(monkeypatch):
def token_provider():
return "fresh-token"
captured: dict = {}
monkeypatch.setattr(
"coworker.providers.registry.build_azure_ad_token_provider",
lambda tenant, client, secret: (
captured.update(tenant=tenant, client=client, secret=secret)
or token_provider
),
)
provider = build_provider_client("openai", AZURE_FIELDS, secrets=None)
assert isinstance(provider, OpenAIProvider)
assert provider._api_key is token_provider
assert provider._base_url == AZURE_FIELDS["base_url"]
assert captured == {
"tenant": "tenant-1",
"client": "client-1",
"secret": "secret-1",
}
def test_openai_sdk_receives_token_callback_unchanged(monkeypatch):
def token_provider():
return "fresh-token"
captured: dict = {}
client = object()
def fake_openai(**kwargs):
captured.update(kwargs)
return client
monkeypatch.setattr("openai.OpenAI", fake_openai)
provider = OpenAIProvider(
api_key=token_provider,
base_url=AZURE_FIELDS["base_url"],
)
assert provider._ensure_client() is client
assert captured == {
"api_key": token_provider,
"base_url": AZURE_FIELDS["base_url"],
}
def test_descriptor_configuration_is_auth_method_specific(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
descriptor = get_descriptor("openai")
assert descriptor is not None
assert not descriptor_configured(descriptor, {})
assert descriptor_configured(descriptor, {"api_key": "sk-existing"})
assert not descriptor_configured(descriptor, {**AZURE_FIELDS, "client_secret": ""})
assert descriptor_configured(descriptor, AZURE_FIELDS)
def test_verify_azure_openai_uses_bearer_token_and_v1_models(monkeypatch):
captured: dict = {}
monkeypatch.setattr(
"coworker.providers.registry.build_azure_ad_token_provider",
lambda *_args: lambda: "entra-token",
)
def fake_get(url, **kwargs):
captured["url"] = url
captured.update(kwargs)
return SimpleNamespace(status_code=200)
monkeypatch.setattr("httpx.get", fake_get)
assert verify_provider_key("openai", fields=AZURE_FIELDS) == {"ok": True}
assert captured["url"] == AZURE_FIELDS["base_url"] + "/models"
assert captured["headers"] == {"Authorization": "Bearer entra-token"}
def test_verify_azure_openai_reports_missing_fields_without_request(monkeypatch):
called = False
def fake_get(*_args, **_kwargs):
nonlocal called
called = True
monkeypatch.setattr("httpx.get", fake_get)
result = verify_provider_key(
"openai", fields={"auth_method": "azure_ad", "tenant_id": "tenant-1"}
)
assert result["ok"] is False
assert "Custom endpoint" in result["error"]
assert "Client secret" in result["error"]
assert called is False
def test_verify_azure_openai_maps_rbac_failure(monkeypatch):
monkeypatch.setattr(
"coworker.providers.registry.build_azure_ad_token_provider",
lambda *_args: lambda: "entra-token",
)
monkeypatch.setattr(
"httpx.get", lambda *_args, **_kwargs: SimpleNamespace(status_code=403)
)
result = verify_provider_key("openai", fields=AZURE_FIELDS)
assert result == {
"ok": False,
"error": "The service principal lacks access to this Azure OpenAI resource.",
}
def test_manager_saves_azure_credentials_without_exposing_secrets(
tmp_path, monkeypatch
):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
from coworker.server.manager import SessionManager
manager = SessionManager(data_dir=tmp_path)
captured: dict = {}
def fake_verify(name, **kwargs):
captured["name"] = name
captured.update(kwargs)
return {"ok": True}
monkeypatch.setattr("coworker.server.manager.verify_provider_key", fake_verify)
assert manager.verify_provider("openai", AZURE_FIELDS) == {"ok": True}
assert captured["api_key"] == ""
assert captured["fields"] == AZURE_FIELDS
assert manager.set_provider("openai", AZURE_FIELDS)["ok"] is True
openai = {p["name"]: p for p in manager.get_providers()}["openai"]
assert openai["configured"] is True
assert openai["values"] == {
"auth_method": "azure_ad",
"base_url": AZURE_FIELDS["base_url"],
"tenant_id": "tenant-1",
"client_id": "client-1",
}
assert "client_secret" not in openai["values"]
assert manager.secrets.get("provider:openai")["client_secret"] == "secret-1"