mirror of
https://github.com/andrewyng/openworker.git
synced 2026-08-30 22:53:41 +00:00
Merge 3a38cb8ce7 into 9e145d9ceb
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user