diff --git a/README.md b/README.md index b32a299d..72547b42 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Under the hood: Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box: -**OpenAI · Anthropic · Google Gemini · 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 · 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. diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index bf6a878c..8322f3b8 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -85,6 +85,21 @@ MATRIX: dict[str, ModelEntry] = { "gemini:gemini-2.5-flash": ModelEntry( "Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576 ), + # Ark Responses API providers (verified 2026-08-14). BytePlus pay-as-you-go and + # Volcengine Agent Plan intentionally use separate provider prefixes because their + # endpoints, credentials, regions, and model catalogs are not interchangeable. + "ark:dola-seed-evolving-latest-version": ModelEntry( + "Dola Seed Evolving · BytePlus Ark" + ), + "ark:dola-seed-2-1-turbo-260628": ModelEntry( + "Dola Seed 2.1 Turbo · BytePlus Ark" + ), + "ark-agent-plan-cn:doubao-seed-evolving": ModelEntry( + "Doubao Seed Evolving · Volcengine Agent Plan" + ), + "ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry( + "Doubao Seed 2.1 Turbo · Volcengine Agent Plan" + ), # -- direct OpenAI-compatible vendors ---------------------------------------- # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via # their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls diff --git a/coworker/providers/openai_responses.py b/coworker/providers/openai_responses.py index 566c620b..9d34f84a 100644 --- a/coworker/providers/openai_responses.py +++ b/coworker/providers/openai_responses.py @@ -1,4 +1,4 @@ -"""OpenAI Responses provider — native OpenAI models via `/v1/responses`. +"""OpenAI Responses provider — native and compatible models via `/responses`. Chat Completions rejects function tools combined with any `reasoning_effort` other than `none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI @@ -8,9 +8,10 @@ reasoning + tools at real effort levels, streamed reasoning summaries (→ the s chain-of-thought continuity across tool round-trips via `store: false` + `include: ["reasoning.encrypted_content"]` — nothing retained server-side. -Routing: the `openai` provider entry with NO custom base_url builds this class; a custom -endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the -Chat Completions `OpenAIProvider` (registry.py). +Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom +endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions +`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this +class with their own base URL (registry.py). Like the other native providers, this is mostly a pair of pure converters from the canonical OpenAI-chat-shaped history to Responses `input` items. What the converters @@ -289,14 +290,20 @@ class OpenAIResponsesProvider(ProviderClient): default_model: str = "gpt-5.6-sol", api_key: Optional[str] = None, secrets: Any = None, + base_url: Optional[str] = None, + reasoning_summary: bool = True, ): # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be # assembled before any key exists; key resolves at call time (explicit → env → - # SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint - # routes to the Chat Completions provider instead (registry.py). + # SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI + # leaves it unset, while Responses-compatible vendors can supply their own endpoint. self._client = client self._api_key = api_key self._secrets = secrets + self._base_url = (base_url or "").strip().rstrip("/") or None + if not isinstance(reasoning_summary, bool): + raise TypeError("reasoning_summary must be a bool") + self._reasoning_summary = reasoning_summary self.default_model = default_model def _ensure_client(self) -> Any: @@ -310,7 +317,10 @@ class OpenAIResponsesProvider(ProviderClient): "No model API key configured. Set OPENAI_API_KEY in the environment, " "or add your key in Manage → Settings." ) - self._client = OpenAI(api_key=key) + kwargs = {"api_key": key} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = OpenAI(**kwargs) return self._client def _request_kwargs( @@ -331,9 +341,10 @@ class OpenAIResponsesProvider(ProviderClient): # `_openai` sidecar instead, and summaries feed the GUI's thinking display. "store": False, "include": ["reasoning.encrypted_content"], - "reasoning": {"summary": "auto"}, **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST}, } + if self._reasoning_summary: + kwargs["reasoning"] = {"summary": "auto"} if instructions: kwargs["instructions"] = instructions if tools: diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 6c147705..1f7aea8e 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -210,6 +210,37 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = return build +def _openai_responses_compat( + vendor: str, + default_base_url: str, + env_key: Optional[str] = None, + *, + reasoning_summary: bool = True, +): + """Builder factory for vendors that explicitly implement the OpenAI Responses API. + + Credentials stay isolated to the vendor's own profile/environment variable, matching the + Chat Completions compat path above. In particular, an OpenAI key is never sent to Ark. + """ + + def build(profile: dict[str, Any], secrets: Any) -> ProviderClient: + base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url + api_key = ((profile or {}).get("api_key") or "").strip() or ( + os.environ.get(env_key, "").strip() if env_key else "" + ) + if not api_key: + raise RuntimeError( + f"No {vendor} API key configured — add it in Settings ▸ Models." + ) + return OpenAIResponsesProvider( + api_key=api_key, + base_url=base_url, + reasoning_summary=reasoning_summary, + ) + + return build + + def _compat( name: str, title: str, @@ -248,6 +279,49 @@ def _compat( ) +def _responses_compat( + name: str, + title: str, + *, + base_url: str, + recommended_model: str, + env_key: str, + endpoint_help: str = "", + reasoning_summary: bool = True, +) -> ProviderDescriptor: + """Descriptor for a vendor exposing the OpenAI Responses API.""" + return ProviderDescriptor( + name=name, + title=title, + needs_key=True, + fields=[ + ProviderField( + "api_key", + f"{title} API key", + secret=True, + ), + ProviderField( + "base_url", + "Endpoint", + required=False, + default=base_url, + placeholder=base_url, + help=endpoint_help + or f"Prefilled with {title}'s official Responses endpoint.", + ), + ], + build=_openai_responses_compat( + title, + base_url, + env_key, + reasoning_summary=reasoning_summary, + ), + recommended_model=recommended_model, + env_key=env_key, + blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", + ) + + DESCRIPTORS: list[ProviderDescriptor] = [ ProviderDescriptor( name="openai", @@ -462,6 +536,26 @@ DESCRIPTORS: list[ProviderDescriptor] = [ blurb="Runs models inside your own Google Cloud project. Gemini and Claude use " "their native APIs; open-weight models go through the Vertex MaaS endpoint.", ), + # Ark has two intentionally separate provider identities. BytePlus pay-as-you-go and + # Volcengine Agent Plan use different regions, endpoints, credentials, and model catalogs; + # combining them would let one provider profile route a model to the wrong service. + _responses_compat( + "ark", + "BytePlus Ark", + base_url="https://ark.ap-southeast.bytepluses.com/api/v3", + recommended_model="dola-seed-evolving-latest-version", + env_key="ARK_API_KEY", + endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.", + reasoning_summary=False, + ), + _responses_compat( + "ark-agent-plan-cn", + "Volcengine Ark Agent Plan", + base_url="https://ark.cn-beijing.volces.com/api/plan/v3", + recommended_model="doubao-seed-evolving", + env_key="ARK_AGENT_PLAN_CN_API_KEY", + endpoint_help="Volcengine Ark Agent Plan's China (Beijing) endpoint. It requires an Agent Plan API key.", + ), # OpenAI-compatible vendors, listed as first-class providers so users don't need to know the # "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps # its own key profile; the endpoint is prefilled and editable (regional variants in `help`). @@ -803,9 +897,11 @@ def verify_provider_key( fields: Optional[dict[str, Any]] = None, timeout: float = 10.0, ) -> dict[str, Any]: - """Validate a provider's credentials with one cheap, read-only call (list models) — the same - pattern connectors use to validate tokens. Transient: callers pass the key directly so a user - can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers + """Validate a provider's credentials with one cheap call — usually list models. + + Ark's Responses-compatible data plane does not document a `/models` probe, so its Test button + sends a non-persisted one-token Responses request instead. Callers pass the key directly so a + user can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers (Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url. """ import httpx @@ -832,6 +928,22 @@ def verify_provider_key( elif name == "ollama": base = _normalize_ollama_url(base_url) resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout) + elif name in ("ark", "ark-agent-plan-cn"): + default_base = next( + (f.default for f in d.fields if f.key == "base_url" and f.default), "" + ) + base = (base_url or "").strip().rstrip("/") or default_base.rstrip("/") + resp = httpx.post( + base + "/responses", + headers={"Authorization": f"Bearer {key}"}, + json={ + "model": d.recommended_model, + "input": "Reply with OK.", + "max_output_tokens": 1, + "store": False, + }, + timeout=timeout, + ) else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…) default_base = next( (f.default for f in d.fields if f.key == "base_url" and f.default), "" diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index e6dfa409..ec4deba7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -45,6 +45,10 @@ const SETTINGS = { model_labels: { "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "zai:glm-5.2": "GLM-5.2 · Z AI", + "ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark", + "ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark", + "ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan", + "ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", }, // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the // composer usage chip's context-fill meter. @@ -336,6 +340,10 @@ const PROVIDERS = [ { 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. { name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null }, + // Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN + // have independent credentials, endpoints, and strict curated model lists. + { name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null }, + { name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null }, // ollama: keyless local provider — "configured" without proving anything runs; the // onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39). { name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null }, diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index 1bee81fa..1d1b6a5e 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -71,6 +71,44 @@ test("Models: provider gallery states; vendor form previews models", async ({ pa await expect(page.getByTestId("set-provider-openai")).toBeVisible(); }); +test("Models: BytePlus and Volcengine Ark stay visually and operationally separate", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models", exact: true }).click(); + + const byteplusCard = page.getByTestId("set-provider-ark"); + const volcengineCard = page.getByTestId("set-provider-ark-agent-plan-cn"); + await expect(byteplusCard).toContainText("BytePlus Ark"); + await expect(volcengineCard).toContainText("Volcengine Ark Agent Plan"); + const byteplusLogo = await byteplusCard.locator("img").getAttribute("src"); + const volcengineLogo = await volcengineCard.locator("img").getAttribute("src"); + expect(byteplusLogo).toBeTruthy(); + expect(volcengineLogo).toBeTruthy(); + expect(byteplusLogo).not.toBe(volcengineLogo); + + await byteplusCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.ap-southeast.bytepluses.com/api/v3", + ); + let preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Dola Seed Evolving · BytePlus Ark"); + await expect(preview).toContainText("Dola Seed 2.1 Turbo · BytePlus Ark"); + await expect(preview).not.toContainText("Doubao Seed"); + + await page.getByTestId("set-back").click(); + await volcengineCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.cn-beijing.volces.com/api/plan/v3", + ); + preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Doubao Seed Evolving · Volcengine Agent Plan"); + await expect(preview).toContainText("Doubao Seed 2.1 Turbo · Volcengine Agent Plan"); + await expect(preview).not.toContainText("Dola Seed"); +}); + // UX-021: a configured provider's form shows the in-field saved state and the Remove key… // affordance; removing reverts the card to "Not set up". test("Models: Remove key reverts a configured provider", async ({ page }) => { diff --git a/surfaces/gui/src/providers/ProviderSetup.test.tsx b/surfaces/gui/src/providers/ProviderSetup.test.tsx index 870aa7a0..89d0e8d2 100644 --- a/surfaces/gui/src/providers/ProviderSetup.test.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.test.tsx @@ -2,7 +2,7 @@ // only the selected method's fields render, and clicking a segment switches them. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { ProviderForm, type ProviderSetupState } from "./ProviderSetup"; +import { KEY_HELP, ProviderForm, ProviderMark, type ProviderSetupState } from "./ProviderSetup"; import type { ProviderInfo } from "../api"; vi.mock("../tauri", () => ({ openExternal: vi.fn() })); @@ -94,3 +94,29 @@ describe("ProviderForm auth-method choice", () => { expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull(); }); }); + +describe("Ark provider presentation", () => { + it("uses separate BytePlus and Volcengine brand marks", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("img")).toBeTruthy(); + + rerender( + , + ); + expect(container.querySelector("img")).toBeTruthy(); + }); + + it("links each provider to its own API key console", () => { + expect(KEY_HELP.ark.url).toBe( + "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", + ); + expect(KEY_HELP["ark-agent-plan-cn"].url).toContain( + "advancedActiveKey=agentPlan", + ); + }); +}); diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 1d8dc881..99aeed53 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -21,6 +21,8 @@ export const KEY_HELP: Record = { anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" }, openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" }, gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" }, + ark: { url: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", label: "console.byteplus.com" }, + "ark-agent-plan-cn": { url: "https://console.volcengine.com/ark/region:cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=agentPlan", label: "console.volcengine.com" }, openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" }, bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" }, fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" }, diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index 093c4fdd..f688315d 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -1,13 +1,15 @@ // Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding -// provider gallery, vendored from the MIT-licensed lobe-icons set (same -// bundled-asset posture as the connector registry — no CDN at runtime). Keys are -// /v1/providers names; unknown names get no mark (the gallery falls back to a -// neutral monogram). PROVIDER_ORDER is the gallery order — recognition first, -// long tail behind the scroll fold. +// provider gallery. Most are vendored from the MIT-licensed lobe-icons set; BytePlus is +// its official website mark, used with permission. All stay bundled like connector assets +// (no CDN at runtime). Keys are /v1/providers names; unknown names get no mark (the gallery +// falls back to a neutral monogram). PROVIDER_ORDER is the gallery order — recognition +// first, long tail behind the scroll fold. import anthropic from "./logos/anthropic.svg"; import openai from "./logos/openai.svg"; import gemini from "./logos/gemini.svg"; +import byteplus from "./logos/byteplus.svg"; +import volcengine from "./logos/volcengine.svg"; import ollama from "./logos/ollama.svg"; import bedrock from "./logos/bedrock.svg"; import vertex from "./logos/vertex.svg"; @@ -27,6 +29,8 @@ export const PROVIDER_LOGOS: Record = { anthropic, openai, gemini, + ark: byteplus, + "ark-agent-plan-cn": volcengine, meta, ollama, bedrock, @@ -47,6 +51,8 @@ export const PROVIDER_ORDER = [ "anthropic", "openai", "gemini", + "ark", + "ark-agent-plan-cn", "meta", "ollama", "bedrock", diff --git a/surfaces/gui/src/providers/logos/byteplus.svg b/surfaces/gui/src/providers/logos/byteplus.svg new file mode 100644 index 00000000..b2d64e33 --- /dev/null +++ b/surfaces/gui/src/providers/logos/byteplus.svg @@ -0,0 +1 @@ +BytePlus diff --git a/surfaces/gui/src/providers/logos/volcengine.svg b/surfaces/gui/src/providers/logos/volcengine.svg new file mode 100644 index 00000000..1c944722 --- /dev/null +++ b/surfaces/gui/src/providers/logos/volcengine.svg @@ -0,0 +1 @@ +Volcengine diff --git a/tests/test_openai_responses.py b/tests/test_openai_responses.py index 9e871d57..64edfc90 100644 --- a/tests/test_openai_responses.py +++ b/tests/test_openai_responses.py @@ -17,6 +17,43 @@ from coworker.providers.openai_responses import ( convert_tools, ) + +def test_responses_custom_base_url_reaches_sdk(monkeypatch): + captured: dict = {} + + def fake_openai(**kwargs): + captured.update(kwargs) + return SimpleNamespace() + + monkeypatch.setattr("openai.OpenAI", fake_openai) + provider = OpenAIResponsesProvider( + api_key="ark-key", + base_url="https://ark.example/api/v3/", + ) + + provider._ensure_client() + + assert captured == { + "api_key": "ark-key", + "base_url": "https://ark.example/api/v3", + } + + +def test_stock_openai_responses_path_unchanged(monkeypatch): + """Lockdown: stock OpenAI must not receive a vendor base URL.""" + captured: dict = {} + + def fake_openai(**kwargs): + captured.update(kwargs) + return SimpleNamespace() + + monkeypatch.setattr("openai.OpenAI", fake_openai) + provider = OpenAIResponsesProvider(api_key="openai-key") + + provider._ensure_client() + + assert captured == {"api_key": "openai-key"} + # -- fakes ------------------------------------------------------------------------ @@ -254,7 +291,7 @@ def test_convert_tools_flattens_function_schemas(): # -- complete() ---------------------------------------------------------------------- -def test_complete_text_turn_and_request_shape(): +def test_complete_default_request_shape_PathsUnchanged(): fake = _FakeClient(response=_response([_message_item("hello")])) provider = OpenAIResponsesProvider(client=fake) turn = provider.complete( @@ -273,6 +310,22 @@ def test_complete_text_turn_and_request_shape(): assert fake.kwargs["reasoning"] == {"summary": "auto"} +def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content(): + """BytePlus accepts encrypted reasoning output but rejects reasoning.summary.""" + fake = _FakeClient(response=_response([_message_item("hello")])) + provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False) + + provider.complete(model="m", messages=[{"role": "user", "content": "hi"}]) + + assert "reasoning" not in fake.kwargs + assert fake.kwargs["include"] == ["reasoning.encrypted_content"] + + +def test_reasoning_summary_capability_rejects_unknown_mode(): + with pytest.raises(TypeError, match="reasoning_summary must be a bool"): + OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto") + + def test_complete_parses_function_calls_with_call_ids(): fake = _FakeClient( response=_response( diff --git a/tests/test_provider_verify.py b/tests/test_provider_verify.py index 8da7fe76..a6e553af 100644 --- a/tests/test_provider_verify.py +++ b/tests/test_provider_verify.py @@ -41,6 +41,18 @@ def _patch_get(monkeypatch, status=200, capture=None, raise_exc=None): monkeypatch.setattr("httpx.get", fake_get) +def _patch_post(monkeypatch, status=200, capture=None, raise_exc=None): + def fake_post(url, **kwargs): + if capture is not None: + capture["url"] = url + capture.update(kwargs) + if raise_exc is not None: + raise raise_exc + return SimpleNamespace(status_code=status) + + monkeypatch.setattr("httpx.post", fake_post) + + def test_verify_openai_ok(monkeypatch): cap: dict = {} _patch_get(monkeypatch, status=200, capture=cap) @@ -91,6 +103,52 @@ def test_verify_ollama_uses_v1_models_no_key(monkeypatch): assert "headers" not in cap # keyless +@pytest.mark.parametrize( + "name,base_url,model", + [ + ( + "ark", + "https://ark.ap-southeast.bytepluses.com/api/v3", + "dola-seed-evolving-latest-version", + ), + ( + "ark-agent-plan-cn", + "https://ark.cn-beijing.volces.com/api/plan/v3", + "doubao-seed-evolving", + ), + ], +) +def test_verify_ark_uses_non_persisted_responses_probe( + monkeypatch, name, base_url, model +): + """Reverse-verified probe: the captured fixture must be non-empty and provider-specific.""" + cap: dict = {} + _patch_post(monkeypatch, status=200, capture=cap) + + assert verify_provider_key(name, api_key="ark-key") == {"ok": True} + assert cap["url"] == base_url + "/responses" + assert cap["headers"]["Authorization"] == "Bearer ark-key" + assert cap["json"] == { + "model": model, + "input": "Reply with OK.", + "max_output_tokens": 1, + "store": False, + } + + +def test_verify_ark_profile_endpoint_override(monkeypatch): + cap: dict = {} + _patch_post(monkeypatch, status=200, capture=cap) + + verify_provider_key( + "ark", + api_key="ark-key", + base_url="https://gateway.example/ark/v3/", + ) + + assert cap["url"] == "https://gateway.example/ark/v3/responses" + + def test_verify_network_error_is_clean(monkeypatch): _patch_get(monkeypatch, raise_exc=ConnectionError("boom")) res = verify_provider_key("openai", api_key="sk-x") diff --git a/tests/test_providers.py b/tests/test_providers.py index 943c6e4c..064926db 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -338,6 +338,121 @@ def test_compat_builder_never_leaks_the_openai_key(monkeypatch): build_provider_client("kimi", {}, None) +ARK_RESPONSES_VENDORS = { + "ark": { + "base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", + "env_key": "ARK_API_KEY", + "recommended_model": "dola-seed-evolving-latest-version", + "reasoning_summary": False, + }, + "ark-agent-plan-cn": { + "base_url": "https://ark.cn-beijing.volces.com/api/plan/v3", + "env_key": "ARK_AGENT_PLAN_CN_API_KEY", + "recommended_model": "doubao-seed-evolving", + "reasoning_summary": True, + }, +} + + +def test_ark_responses_descriptors_are_separate(): + from coworker.providers.registry import get_descriptor + + for name, expected in ARK_RESPONSES_VENDORS.items(): + d = get_descriptor(name) + assert d is not None and d.needs_key, name + assert d.env_key == expected["env_key"] + assert d.recommended_model == expected["recommended_model"] + assert "Responses API" in d.blurb + base = next(f for f in d.fields if f.key == "base_url") + assert base.default == expected["base_url"] + assert not base.required + + +def test_ark_responses_builder_capabilities_PathsUnchanged(monkeypatch): + from coworker.providers.openai_responses import OpenAIResponsesProvider + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("ARK_AGENT_PLAN_CN_API_KEY", "plan-key") + bp = build_provider_client("ark", {"api_key": "bp-key"}, None) + plan = build_provider_client("ark-agent-plan-cn", {}, None) + + assert isinstance(bp, OpenAIResponsesProvider) + assert (bp._api_key, bp._base_url) == ( + "bp-key", + ARK_RESPONSES_VENDORS["ark"]["base_url"], + ) + assert isinstance(plan, OpenAIResponsesProvider) + assert (plan._api_key, plan._base_url) == ( + "plan-key", + ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"], + ) + assert bp._reasoning_summary is ARK_RESPONSES_VENDORS["ark"]["reasoning_summary"] + assert plan._reasoning_summary is ARK_RESPONSES_VENDORS["ark-agent-plan-cn"][ + "reasoning_summary" + ] + + +def test_ark_responses_never_leak_the_openai_key(monkeypatch): + import pytest + + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real") + monkeypatch.delenv("ARK_API_KEY", raising=False) + with pytest.raises(RuntimeError, match="BytePlus Ark"): + build_provider_client("ark", {}, None) + + +def test_existing_chat_compat_paths_unchanged(): + """Lockdown: adding Responses vendors must not migrate existing compat providers.""" + from coworker.providers.registry import build_provider_client + + provider = build_provider_client("deepseek", {"api_key": "ds-key"}, None) + assert isinstance(provider, OpenAIProvider) + assert provider._base_url == COMPAT_VENDORS["deepseek"] + + +def test_ark_curated_models_are_strict_allowlists(): + from coworker.providers.matrix import models_for_provider + + assert models_for_provider("ark") == [ + "dola-seed-evolving-latest-version", + "dola-seed-2-1-turbo-260628", + ] + assert models_for_provider("ark-agent-plan-cn") == [ + "doubao-seed-evolving", + "doubao-seed-2.1-turbo", + ] + + +def test_ark_models_route_and_get_verified_agent_capabilities(): + from coworker.providers.router import ProviderRouter + + models = ( + "ark:dola-seed-evolving-latest-version", + "ark:dola-seed-2-1-turbo-260628", + "ark-agent-plan-cn:doubao-seed-evolving", + "ark-agent-plan-cn:doubao-seed-2.1-turbo", + ) + router = ProviderRouter.__new__(ProviderRouter) + for model in models: + prefix, bare = model.split(":", 1) + assert router._provider_name(model) == prefix + assert ProviderRouter._bare(model) == bare + caps = capabilities_for(model) + assert caps.tools and caps.parallel_tool_calls and caps.streaming + assert not caps.vision + + +def test_ark_recommended_models_are_curated(): + from coworker.providers.matrix import models_for_provider + from coworker.providers.registry import get_descriptor + + for name in ARK_RESPONSES_VENDORS: + d = get_descriptor(name) + assert d.recommended_model in models_for_provider(name) + + def test_compat_models_route_and_get_tool_capabilities(): from coworker.providers.router import ProviderRouter