diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5388797..041d99a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,6 +3,15 @@ // ./internal and are deliberately not exported — do not add internal-only symbols here. export { UserError } from "./internal/errors.ts"; +// Server-side HTTP errors thrown by provider clients. Exported so hosts (CLIs, +// servers) can branch on them and surface statusCode/responseBody structurally +// instead of parsing the message string. +export { ApiError, ConflictError } from "./internal/providers/base-client.ts"; + +// Transport seam: hosts install a fetch-compatible implementation to add +// cross-cutting request concerns (tracking headers, logging) without touching +// globalThis.fetch. Unset → provider clients use the global fetch as before. +export { setDefaultFetch, type FetchLike } from "./internal/transport.ts"; export type { BackendRuntimeInput, diff --git a/packages/sdk/src/internal/executor/skill-resolver.ts b/packages/sdk/src/internal/executor/skill-resolver.ts index 2d8b2e2..f49c263 100644 --- a/packages/sdk/src/internal/executor/skill-resolver.ts +++ b/packages/sdk/src/internal/executor/skill-resolver.ts @@ -1,5 +1,6 @@ import { readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { resolveFetch } from "../transport.ts"; import type { SkillDecl } from "../types/config.ts"; import type { SkillFile } from "../types/skill-file.ts"; import { collectFiles } from "../utils/collect-files.ts"; @@ -10,7 +11,7 @@ import type { ExecContext } from "./context.ts"; // is read relative to configPath (zip, directory, or a single SKILL.md). export async function resolveSkillFiles(decl: SkillDecl, ctx: ExecContext): Promise { if (/^https?:\/\//i.test(decl.source)) { - const res = await fetch(decl.source); + const res = await resolveFetch()(decl.source); if (!res.ok) throw new Error(`skill source 下载失败:${res.status} ${decl.source}`); return extractSkillZipFiles(Buffer.from(await res.arrayBuffer())); } diff --git a/packages/sdk/src/internal/provider-config.ts b/packages/sdk/src/internal/provider-config.ts index 255f1f3..08e56c4 100644 --- a/packages/sdk/src/internal/provider-config.ts +++ b/packages/sdk/src/internal/provider-config.ts @@ -42,7 +42,14 @@ export function resolveActiveProvider(): ProviderConfigProvider { /** Whether the current process env has all required fields for the active provider. */ export function areRuntimeCredentialsReady(): boolean { const provider = resolveActiveProvider(); - return AGENTS_PROVIDER_FIELDS[provider].every((field) => process.env[field.key]?.trim()); + return AGENTS_PROVIDER_FIELDS[provider].every((field) => { + if (process.env[field.key]?.trim()) return true; + // bailian derives its endpoint from either workspace_id or base_url, so a + // configured BAILIAN_BASE_URL satisfies the workspace_id slot (mirrors the + // provider config schema's "at least one" rule). + if (field.key === "BAILIAN_WORKSPACE_ID") return Boolean(process.env.BAILIAN_BASE_URL?.trim()); + return false; + }); } function isProvider(value: string): value is ProviderConfigProvider { diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index 58b8a6b..c0e17da 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -80,7 +80,7 @@ export class BailianAdapter implements ProviderAdapter { private client: BailianClient; private projectName: string; - constructor(apiKey: string, workspaceId: string, baseUrl?: string, projectName?: string) { + constructor(apiKey: string, workspaceId?: string, baseUrl?: string, projectName?: string) { this.client = new BailianClient({ apiKey, workspaceId, baseUrl }); this.projectName = projectName ?? ""; } diff --git a/packages/sdk/src/internal/providers/bailian/client.ts b/packages/sdk/src/internal/providers/bailian/client.ts index 158a75a..9c70034 100644 --- a/packages/sdk/src/internal/providers/bailian/client.ts +++ b/packages/sdk/src/internal/providers/bailian/client.ts @@ -1,8 +1,9 @@ +import { UserError } from "../../errors.ts"; import { BaseApiClient } from "../base-client.ts"; export interface BailianClientConfig { apiKey: string; - workspaceId: string; + workspaceId?: string; baseUrl?: string; } @@ -15,7 +16,13 @@ export class BailianClient extends BaseApiClient { constructor(config: BailianClientConfig) { super(); this.apiKey = config.apiKey; - this.baseUrl = config.baseUrl ?? `https://${config.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`; + if (config.baseUrl) { + this.baseUrl = config.baseUrl; + } else if (config.workspaceId) { + this.baseUrl = `https://${config.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`; + } else { + throw new UserError("bailian provider requires either base_url or workspace_id"); + } } protected headers(): Record { diff --git a/packages/sdk/src/internal/providers/bailian/config.ts b/packages/sdk/src/internal/providers/bailian/config.ts index 74070fe..1c582ad 100644 --- a/packages/sdk/src/internal/providers/bailian/config.ts +++ b/packages/sdk/src/internal/providers/bailian/config.ts @@ -1,9 +1,17 @@ import { z } from "zod"; -export const bailianConfigSchema = z.object({ - api_key: z.string(), - workspace_id: z.string(), - base_url: z.string().optional(), -}); +// workspace_id and base_url are both optional but at least one is required: the +// client derives the AgentStudio base URL from workspace_id, or takes base_url +// verbatim. Hosts that only know the full endpoint (e.g. bl's --agentstudio-base-url) +// can supply base_url and skip workspace_id entirely. +export const bailianConfigSchema = z + .object({ + api_key: z.string(), + workspace_id: z.string().optional(), + base_url: z.string().optional(), + }) + .refine((config) => Boolean(config.workspace_id || config.base_url), { + message: "either workspace_id or base_url is required", + }); export type BailianConfig = z.infer; diff --git a/packages/sdk/src/internal/providers/base-client.ts b/packages/sdk/src/internal/providers/base-client.ts index a506f3a..02e25cf 100644 --- a/packages/sdk/src/internal/providers/base-client.ts +++ b/packages/sdk/src/internal/providers/base-client.ts @@ -1,3 +1,4 @@ +import { resolveFetch } from "../transport.ts"; import type { RemoteResource } from "./interface.ts"; export class ApiError extends Error { @@ -34,7 +35,7 @@ export abstract class BaseApiClient { } async post(path: string, body: unknown): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "POST", headers: this.headers(), body: JSON.stringify(body), @@ -44,7 +45,7 @@ export abstract class BaseApiClient { } async put(path: string, body: unknown): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "PUT", headers: this.headers(), body: JSON.stringify(body), @@ -54,7 +55,7 @@ export abstract class BaseApiClient { } async delete(path: string): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "DELETE", headers: this.headers(), }); @@ -62,7 +63,7 @@ export abstract class BaseApiClient { } async get(path: string): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "GET", headers: this.headers(), }); @@ -71,7 +72,7 @@ export abstract class BaseApiClient { } async getBuffer(path: string): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "GET", headers: this.headers(), }); @@ -81,7 +82,7 @@ export abstract class BaseApiClient { async *sse(path: string, options?: { headers?: Record }): AsyncGenerator> { const controller = new AbortController(); - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "GET", headers: { ...this.headers(), Accept: "text/event-stream", ...options?.headers }, signal: controller.signal, @@ -140,7 +141,7 @@ export abstract class BaseApiClient { // boundary itself. Every provider's multipart headers are exactly its JSON // headers minus Content-Type, so derive them here. const { "Content-Type": _contentType, ...headers } = this.headers(); - const res = await fetch(`${this.baseUrl}${path}`, { + const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "POST", headers, body: formData, diff --git a/packages/sdk/src/internal/providers/registry.ts b/packages/sdk/src/internal/providers/registry.ts index 1c75e8c..9e454c8 100644 --- a/packages/sdk/src/internal/providers/registry.ts +++ b/packages/sdk/src/internal/providers/registry.ts @@ -51,6 +51,22 @@ export function allProviders(): ProviderDefinition[] { return Array.from(registry.values()); } +/** + * Validate a raw provider config against its schema, mapping zod issues to a + * readable UserError. Providers call `.parse()`-style validation through here so + * config mistakes surface as `provider 'x' config invalid: field: message` lines + * instead of a raw ZodError JSON dump leaking to the host's stderr. + */ +function parseProviderConfig(def: ProviderDefinition, rawConfig: unknown): unknown { + const result = def.configSchema.safeParse(rawConfig); + if (result.success) return result.data; + const details = result.error.issues.map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }); + throw new UserError(`Provider '${def.name}' config invalid:\n${details.join("\n")}`); +} + export function buildProviders( providersConfig: Record, projectName?: string, @@ -62,7 +78,7 @@ export function buildProviders( if (!def) { throw new UserError(`Unknown provider '${name}'. Registered: ${Array.from(registry.keys()).join(", ")}`); } - const parsed = def.configSchema.parse(rawConfig); + const parsed = parseProviderConfig(def, rawConfig); const adapter = def.createAdapter(parsed, projectName); validateProviderFacets(def, adapter); adapters.set(name, adapter); @@ -75,11 +91,14 @@ export function buildProviders( * Environment variable mappings for each provider. * Used to construct a provider adapter without a agents.yaml config file. */ -const PROVIDER_ENV_VARS: Record> = { +const PROVIDER_ENV_VARS: Record> = { bailian: { api_key: { env: ["DASHSCOPE_API_KEY", "BAILIAN_API_KEY"], required: true }, - workspace_id: { env: ["BAILIAN_WORKSPACE_ID"], required: true }, - base_url: { env: ["BAILIAN_BASE_URL"], required: false }, + // Neither workspace_id nor base_url is required on its own; the config schema + // enforces "at least one" so a host can supply just base_url (BAILIAN_BASE_URL). + // base_url is the preferred placeholder emitted by `agents sync`. + workspace_id: { env: ["BAILIAN_WORKSPACE_ID"], required: false }, + base_url: { env: ["BAILIAN_BASE_URL"], required: false, placeholder: true }, }, qoder: { api_key: { env: ["QODER_PAT", "QODER_API_KEY"], required: true }, @@ -97,15 +116,17 @@ const PROVIDER_ENV_VARS: Record { const envMap = PROVIDER_ENV_VARS[providerName]; if (!envMap) return {}; const out: Record = {}; - for (const [field, { env, required }] of Object.entries(envMap)) { - if (required && env[0]) out[field] = `\${${env[0]}}`; + for (const [field, { env, required, placeholder }] of Object.entries(envMap)) { + if ((required || placeholder) && env[0]) out[field] = `\${${env[0]}}`; } return out; } @@ -160,7 +181,7 @@ export function buildProviderFromEnv(providerName: string, projectName?: string) } const config = resolveProviderConfigFromEnv(providerName); - const parsed = def.configSchema.parse(config); + const parsed = parseProviderConfig(def, config); const adapter = def.createAdapter(parsed, projectName); validateProviderFacets(def, adapter); return adapter; diff --git a/packages/sdk/src/internal/transport.ts b/packages/sdk/src/internal/transport.ts new file mode 100644 index 0000000..8baa617 --- /dev/null +++ b/packages/sdk/src/internal/transport.ts @@ -0,0 +1,22 @@ +// Host-injectable transport seam. A host embedding the SDK (a CLI, a server) +// can install a fetch-compatible implementation — e.g. to add tracking headers +// or request logging — without monkey-patching globalThis.fetch. Response +// semantics (ApiError classification, SSE parsing, pagination) stay in the +// provider clients regardless of the installed implementation. + +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +let defaultFetch: FetchLike | undefined; + +/** Install the fetch implementation used by all provider clients; pass undefined to reset. */ +export function setDefaultFetch(fetchImpl: FetchLike | undefined): void { + defaultFetch = fetchImpl; +} + +/** + * Resolve the active fetch implementation. Falls back to the *current* + * globalThis.fetch (resolved per call, so test-time fetch mocks keep working). + */ +export function resolveFetch(): FetchLike { + return defaultFetch ?? ((input, init) => fetch(input, init)); +} diff --git a/packages/sdk/tests/unit/bailian.test.ts b/packages/sdk/tests/unit/bailian.test.ts index b714597..47055ad 100644 --- a/packages/sdk/tests/unit/bailian.test.ts +++ b/packages/sdk/tests/unit/bailian.test.ts @@ -38,8 +38,19 @@ describe("bailianConfigSchema", () => { expect(() => bailianConfigSchema.parse({ workspace_id: "ws-123" })).toThrow(); }); - test("rejects config missing workspace_id", () => { - expect(() => bailianConfigSchema.parse({ api_key: "sk-abc" })).toThrow(); + test("accepts base_url without workspace_id", () => { + const result = bailianConfigSchema.parse({ + api_key: "sk-abc", + base_url: "https://custom.example.com/api/v1/agentstudio", + }); + expect(result.workspace_id).toBeUndefined(); + expect(result.base_url).toBe("https://custom.example.com/api/v1/agentstudio"); + }); + + test("rejects config with neither workspace_id nor base_url", () => { + expect(() => bailianConfigSchema.parse({ api_key: "sk-abc" })).toThrow( + /either workspace_id or base_url is required/, + ); }); }); @@ -328,7 +339,10 @@ describe("Bailian mapEnvironment", () => { test("rejects 'limited' networking instead of silently widening it", () => { const decl: EnvironmentDecl = { - config: { type: "cloud", networking: { type: "limited", allowed_hosts: ["api.github.com"] } }, + config: { + type: "cloud", + networking: { type: "limited", allowed_hosts: ["api.github.com"] }, + }, }; // The real API rejects non-unrestricted networking; silently dropping the // restriction would widen a declared egress boundary, so we must throw. diff --git a/packages/sdk/tests/unit/provider-config.test.ts b/packages/sdk/tests/unit/provider-config.test.ts index 2d07691..525b8e2 100644 --- a/packages/sdk/tests/unit/provider-config.test.ts +++ b/packages/sdk/tests/unit/provider-config.test.ts @@ -126,6 +126,29 @@ describe("provider-config bootstrap", () => { } }); + test("areRuntimeCredentialsReady accepts BAILIAN_BASE_URL in place of workspace_id", () => { + const prevProvider = process.env.AGENTS_PROVIDER; + const prevKey = process.env.DASHSCOPE_API_KEY; + const prevWs = process.env.BAILIAN_WORKSPACE_ID; + const prevBaseUrl = process.env.BAILIAN_BASE_URL; + process.env.AGENTS_PROVIDER = "bailian"; + process.env.DASHSCOPE_API_KEY = "sk-x"; + delete process.env.BAILIAN_WORKSPACE_ID; + process.env.BAILIAN_BASE_URL = "https://ws.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio"; + try { + expect(areRuntimeCredentialsReady()).toBe(true); + } finally { + if (prevProvider === undefined) delete process.env.AGENTS_PROVIDER; + else process.env.AGENTS_PROVIDER = prevProvider; + if (prevKey === undefined) delete process.env.DASHSCOPE_API_KEY; + else process.env.DASHSCOPE_API_KEY = prevKey; + if (prevWs === undefined) delete process.env.BAILIAN_WORKSPACE_ID; + else process.env.BAILIAN_WORKSPACE_ID = prevWs; + if (prevBaseUrl === undefined) delete process.env.BAILIAN_BASE_URL; + else process.env.BAILIAN_BASE_URL = prevBaseUrl; + } + }); + test("applyProviderConfigToEnv respects force flag", () => { const prevProvider = process.env.AGENTS_PROVIDER; const prevKey = process.env.ARK_API_KEY; diff --git a/packages/sdk/tests/unit/provider-registry.test.ts b/packages/sdk/tests/unit/provider-registry.test.ts new file mode 100644 index 0000000..f21826e --- /dev/null +++ b/packages/sdk/tests/unit/provider-registry.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { placeholderProviderConfig } from "../../src/internal/providers/registry.ts"; + +// Placeholder values are literal "${ENV_VAR}" tokens; assert their shape with a +// regex instead of literal strings so biome's noTemplateCurlyInString rule (which +// guards against accidental template placeholders) stays happy. +const PLACEHOLDER_RE = /^\$\{[A-Z_]+\}$/; + +// `agents sync` falls back to these placeholders when the source providers block +// is unavailable. bailian must emit base_url (its preferred field) so the emitted +// config is valid without a workspace_id. +test("placeholderProviderConfig emits api_key + base_url for bailian, not workspace_id", () => { + const block = placeholderProviderConfig("bailian"); + expect(Object.keys(block).sort()).toEqual(["api_key", "base_url"]); + expect(block.workspace_id).toBeUndefined(); + for (const value of Object.values(block)) { + expect(value).toMatch(PLACEHOLDER_RE); + } +}); + +test("placeholderProviderConfig emits only required fields for other providers", () => { + expect(Object.keys(placeholderProviderConfig("claude"))).toEqual(["api_key"]); + expect(Object.keys(placeholderProviderConfig("ark"))).toEqual(["api_key"]); +}); + +test("placeholderProviderConfig returns empty for an unknown provider", () => { + expect(placeholderProviderConfig("nope")).toEqual({}); +}); diff --git a/packages/sdk/tests/unit/transport.test.ts b/packages/sdk/tests/unit/transport.test.ts new file mode 100644 index 0000000..a114766 --- /dev/null +++ b/packages/sdk/tests/unit/transport.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test"; +import { BaseApiClient } from "../../src/internal/providers/base-client.ts"; +import { type FetchLike, resolveFetch, setDefaultFetch } from "../../src/internal/transport.ts"; + +class TestClient extends BaseApiClient { + protected baseUrl = "https://example.test"; + protected errorPrefix = "test"; + protected paginationStrategy = "page" as const; + + protected headers(): Record { + return { "Content-Type": "application/json", Authorization: "Bearer k" }; + } +} + +afterEach(() => { + setDefaultFetch(undefined); +}); + +test("resolveFetch falls back to the current globalThis.fetch when nothing is installed", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ id: "from-global" }), { + status: 200, + })) as unknown as typeof fetch; + + try { + const res = await new TestClient().get("/x"); + expect(res).toEqual({ id: "from-global" }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("setDefaultFetch routes provider client requests through the installed implementation", async () => { + const captured: Array<{ + url: string; + method?: string; + headers: Record; + }> = []; + const installed: FetchLike = async (input, init) => { + captured.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + method: init?.method, + headers: (init?.headers ?? {}) as Record, + }); + return new Response(JSON.stringify({ id: "from-installed" }), { + status: 200, + }); + }; + setDefaultFetch(installed); + + const res = await new TestClient().post("/agents", { name: "a" }); + expect(res).toEqual({ id: "from-installed" }); + expect(captured).toEqual([ + { + url: "https://example.test/agents", + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer k", + }, + }, + ]); +}); + +test("installed implementation still gets ApiError semantics from the client (non-ok response)", async () => { + setDefaultFetch(async () => new Response("missing", { status: 404 })); + + const err = (await new TestClient().get("/missing").catch((error: unknown) => error)) as Error & { + statusCode?: number; + }; + expect(err.statusCode).toBe(404); +}); + +test("setDefaultFetch(undefined) resets back to the global fetch", async () => { + setDefaultFetch(async () => new Response("{}", { status: 200 })); + setDefaultFetch(undefined); + + const originalFetch = globalThis.fetch; + let globalHit = false; + globalThis.fetch = (async () => { + globalHit = true; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + try { + await resolveFetch()("https://example.test/ping"); + expect(globalHit).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } +});