Skip to content

Commit 8d9edcd

Browse files
authored
适配bailian cli能力 (#57)
* feat(sdk): add injectable fetch transport seam and route all provider requests through it * feat(sdk): let bailian provider accept base_url instead of workspace_id * fix(sdk): honor base_url in bailian runtime readiness and sync placeholder
1 parent 316bfba commit 8d9edcd

13 files changed

Lines changed: 262 additions & 29 deletions

File tree

packages/sdk/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
// ./internal and are deliberately not exported — do not add internal-only symbols here.
44

55
export { UserError } from "./internal/errors.ts";
6+
// Server-side HTTP errors thrown by provider clients. Exported so hosts (CLIs,
7+
// servers) can branch on them and surface statusCode/responseBody structurally
8+
// instead of parsing the message string.
9+
export { ApiError, ConflictError } from "./internal/providers/base-client.ts";
10+
11+
// Transport seam: hosts install a fetch-compatible implementation to add
12+
// cross-cutting request concerns (tracking headers, logging) without touching
13+
// globalThis.fetch. Unset → provider clients use the global fetch as before.
14+
export { setDefaultFetch, type FetchLike } from "./internal/transport.ts";
615

716
export type {
817
BackendRuntimeInput,

packages/sdk/src/internal/executor/skill-resolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync, statSync } from "node:fs";
22
import { dirname, resolve } from "node:path";
3+
import { resolveFetch } from "../transport.ts";
34
import type { SkillDecl } from "../types/config.ts";
45
import type { SkillFile } from "../types/skill-file.ts";
56
import { collectFiles } from "../utils/collect-files.ts";
@@ -10,7 +11,7 @@ import type { ExecContext } from "./context.ts";
1011
// is read relative to configPath (zip, directory, or a single SKILL.md).
1112
export async function resolveSkillFiles(decl: SkillDecl, ctx: ExecContext): Promise<SkillFile[]> {
1213
if (/^https?:\/\//i.test(decl.source)) {
13-
const res = await fetch(decl.source);
14+
const res = await resolveFetch()(decl.source);
1415
if (!res.ok) throw new Error(`skill source 下载失败:${res.status} ${decl.source}`);
1516
return extractSkillZipFiles(Buffer.from(await res.arrayBuffer()));
1617
}

packages/sdk/src/internal/provider-config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@ export function resolveActiveProvider(): ProviderConfigProvider {
4242
/** Whether the current process env has all required fields for the active provider. */
4343
export function areRuntimeCredentialsReady(): boolean {
4444
const provider = resolveActiveProvider();
45-
return AGENTS_PROVIDER_FIELDS[provider].every((field) => process.env[field.key]?.trim());
45+
return AGENTS_PROVIDER_FIELDS[provider].every((field) => {
46+
if (process.env[field.key]?.trim()) return true;
47+
// bailian derives its endpoint from either workspace_id or base_url, so a
48+
// configured BAILIAN_BASE_URL satisfies the workspace_id slot (mirrors the
49+
// provider config schema's "at least one" rule).
50+
if (field.key === "BAILIAN_WORKSPACE_ID") return Boolean(process.env.BAILIAN_BASE_URL?.trim());
51+
return false;
52+
});
4653
}
4754

4855
function isProvider(value: string): value is ProviderConfigProvider {

packages/sdk/src/internal/providers/bailian/adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class BailianAdapter implements ProviderAdapter {
8080
private client: BailianClient;
8181
private projectName: string;
8282

83-
constructor(apiKey: string, workspaceId: string, baseUrl?: string, projectName?: string) {
83+
constructor(apiKey: string, workspaceId?: string, baseUrl?: string, projectName?: string) {
8484
this.client = new BailianClient({ apiKey, workspaceId, baseUrl });
8585
this.projectName = projectName ?? "";
8686
}

packages/sdk/src/internal/providers/bailian/client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { UserError } from "../../errors.ts";
12
import { BaseApiClient } from "../base-client.ts";
23

34
export interface BailianClientConfig {
45
apiKey: string;
5-
workspaceId: string;
6+
workspaceId?: string;
67
baseUrl?: string;
78
}
89

@@ -15,7 +16,13 @@ export class BailianClient extends BaseApiClient {
1516
constructor(config: BailianClientConfig) {
1617
super();
1718
this.apiKey = config.apiKey;
18-
this.baseUrl = config.baseUrl ?? `https://${config.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`;
19+
if (config.baseUrl) {
20+
this.baseUrl = config.baseUrl;
21+
} else if (config.workspaceId) {
22+
this.baseUrl = `https://${config.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`;
23+
} else {
24+
throw new UserError("bailian provider requires either base_url or workspace_id");
25+
}
1926
}
2027

2128
protected headers(): Record<string, string> {
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { z } from "zod";
22

3-
export const bailianConfigSchema = z.object({
4-
api_key: z.string(),
5-
workspace_id: z.string(),
6-
base_url: z.string().optional(),
7-
});
3+
// workspace_id and base_url are both optional but at least one is required: the
4+
// client derives the AgentStudio base URL from workspace_id, or takes base_url
5+
// verbatim. Hosts that only know the full endpoint (e.g. bl's --agentstudio-base-url)
6+
// can supply base_url and skip workspace_id entirely.
7+
export const bailianConfigSchema = z
8+
.object({
9+
api_key: z.string(),
10+
workspace_id: z.string().optional(),
11+
base_url: z.string().optional(),
12+
})
13+
.refine((config) => Boolean(config.workspace_id || config.base_url), {
14+
message: "either workspace_id or base_url is required",
15+
});
816

917
export type BailianConfig = z.infer<typeof bailianConfigSchema>;

packages/sdk/src/internal/providers/base-client.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveFetch } from "../transport.ts";
12
import type { RemoteResource } from "./interface.ts";
23

34
export class ApiError extends Error {
@@ -34,7 +35,7 @@ export abstract class BaseApiClient {
3435
}
3536

3637
async post(path: string, body: unknown): Promise<unknown> {
37-
const res = await fetch(`${this.baseUrl}${path}`, {
38+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
3839
method: "POST",
3940
headers: this.headers(),
4041
body: JSON.stringify(body),
@@ -44,7 +45,7 @@ export abstract class BaseApiClient {
4445
}
4546

4647
async put(path: string, body: unknown): Promise<unknown> {
47-
const res = await fetch(`${this.baseUrl}${path}`, {
48+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
4849
method: "PUT",
4950
headers: this.headers(),
5051
body: JSON.stringify(body),
@@ -54,15 +55,15 @@ export abstract class BaseApiClient {
5455
}
5556

5657
async delete(path: string): Promise<void> {
57-
const res = await fetch(`${this.baseUrl}${path}`, {
58+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
5859
method: "DELETE",
5960
headers: this.headers(),
6061
});
6162
await this.throwIfError(res);
6263
}
6364

6465
async get(path: string): Promise<unknown> {
65-
const res = await fetch(`${this.baseUrl}${path}`, {
66+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
6667
method: "GET",
6768
headers: this.headers(),
6869
});
@@ -71,7 +72,7 @@ export abstract class BaseApiClient {
7172
}
7273

7374
async getBuffer(path: string): Promise<Buffer> {
74-
const res = await fetch(`${this.baseUrl}${path}`, {
75+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
7576
method: "GET",
7677
headers: this.headers(),
7778
});
@@ -81,7 +82,7 @@ export abstract class BaseApiClient {
8182

8283
async *sse(path: string, options?: { headers?: Record<string, string> }): AsyncGenerator<Record<string, unknown>> {
8384
const controller = new AbortController();
84-
const res = await fetch(`${this.baseUrl}${path}`, {
85+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
8586
method: "GET",
8687
headers: { ...this.headers(), Accept: "text/event-stream", ...options?.headers },
8788
signal: controller.signal,
@@ -148,7 +149,7 @@ export abstract class BaseApiClient {
148149
// boundary itself. Every provider's multipart headers are exactly its JSON
149150
// headers minus Content-Type, so derive them here.
150151
const { "Content-Type": _contentType, ...headers } = this.headers();
151-
const res = await fetch(`${this.baseUrl}${path}`, {
152+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
152153
method: "POST",
153154
headers,
154155
body: formData,

packages/sdk/src/internal/providers/registry.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ export function allProviders(): ProviderDefinition[] {
5151
return Array.from(registry.values());
5252
}
5353

54+
/**
55+
* Validate a raw provider config against its schema, mapping zod issues to a
56+
* readable UserError. Providers call `.parse()`-style validation through here so
57+
* config mistakes surface as `provider 'x' config invalid: field: message` lines
58+
* instead of a raw ZodError JSON dump leaking to the host's stderr.
59+
*/
60+
function parseProviderConfig(def: ProviderDefinition, rawConfig: unknown): unknown {
61+
const result = def.configSchema.safeParse(rawConfig);
62+
if (result.success) return result.data;
63+
const details = result.error.issues.map((issue) => {
64+
const path = issue.path.join(".");
65+
return path ? `${path}: ${issue.message}` : issue.message;
66+
});
67+
throw new UserError(`Provider '${def.name}' config invalid:\n${details.join("\n")}`);
68+
}
69+
5470
export function buildProviders(
5571
providersConfig: Record<string, unknown>,
5672
projectName?: string,
@@ -62,7 +78,7 @@ export function buildProviders(
6278
if (!def) {
6379
throw new UserError(`Unknown provider '${name}'. Registered: ${Array.from(registry.keys()).join(", ")}`);
6480
}
65-
const parsed = def.configSchema.parse(rawConfig);
81+
const parsed = parseProviderConfig(def, rawConfig);
6682
const adapter = def.createAdapter(parsed, projectName);
6783
validateProviderFacets(def, adapter);
6884
adapters.set(name, adapter);
@@ -75,11 +91,14 @@ export function buildProviders(
7591
* Environment variable mappings for each provider.
7692
* Used to construct a provider adapter without a agents.yaml config file.
7793
*/
78-
const PROVIDER_ENV_VARS: Record<string, Record<string, { env: string[]; required: boolean }>> = {
94+
const PROVIDER_ENV_VARS: Record<string, Record<string, { env: string[]; required: boolean; placeholder?: boolean }>> = {
7995
bailian: {
8096
api_key: { env: ["DASHSCOPE_API_KEY", "BAILIAN_API_KEY"], required: true },
81-
workspace_id: { env: ["BAILIAN_WORKSPACE_ID"], required: true },
82-
base_url: { env: ["BAILIAN_BASE_URL"], required: false },
97+
// Neither workspace_id nor base_url is required on its own; the config schema
98+
// enforces "at least one" so a host can supply just base_url (BAILIAN_BASE_URL).
99+
// base_url is the preferred placeholder emitted by `agents sync`.
100+
workspace_id: { env: ["BAILIAN_WORKSPACE_ID"], required: false },
101+
base_url: { env: ["BAILIAN_BASE_URL"], required: false, placeholder: true },
83102
},
84103
qoder: {
85104
api_key: { env: ["QODER_PAT", "QODER_API_KEY"], required: true },
@@ -97,15 +116,17 @@ const PROVIDER_ENV_VARS: Record<string, Record<string, { env: string[]; required
97116

98117
/**
99118
* Build a `providers` config block for a single provider using `${ENV}`
100-
* placeholders for its required fields. Used by `agents sync` to emit a providers
101-
* block without leaking resolved secrets when the original file is unavailable.
119+
* placeholders. Emits fields that are either required for env resolution or
120+
* explicitly marked as the provider's preferred placeholder (e.g. bailian's
121+
* base_url). Used by `agents sync` to emit a providers block without leaking
122+
* resolved secrets when the original file is unavailable.
102123
*/
103124
export function placeholderProviderConfig(providerName: string): Record<string, string> {
104125
const envMap = PROVIDER_ENV_VARS[providerName];
105126
if (!envMap) return {};
106127
const out: Record<string, string> = {};
107-
for (const [field, { env, required }] of Object.entries(envMap)) {
108-
if (required && env[0]) out[field] = `\${${env[0]}}`;
128+
for (const [field, { env, required, placeholder }] of Object.entries(envMap)) {
129+
if ((required || placeholder) && env[0]) out[field] = `\${${env[0]}}`;
109130
}
110131
return out;
111132
}
@@ -160,7 +181,7 @@ export function buildProviderFromEnv(providerName: string, projectName?: string)
160181
}
161182

162183
const config = resolveProviderConfigFromEnv(providerName);
163-
const parsed = def.configSchema.parse(config);
184+
const parsed = parseProviderConfig(def, config);
164185
const adapter = def.createAdapter(parsed, projectName);
165186
validateProviderFacets(def, adapter);
166187
return adapter;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Host-injectable transport seam. A host embedding the SDK (a CLI, a server)
2+
// can install a fetch-compatible implementation — e.g. to add tracking headers
3+
// or request logging — without monkey-patching globalThis.fetch. Response
4+
// semantics (ApiError classification, SSE parsing, pagination) stay in the
5+
// provider clients regardless of the installed implementation.
6+
7+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
8+
9+
let defaultFetch: FetchLike | undefined;
10+
11+
/** Install the fetch implementation used by all provider clients; pass undefined to reset. */
12+
export function setDefaultFetch(fetchImpl: FetchLike | undefined): void {
13+
defaultFetch = fetchImpl;
14+
}
15+
16+
/**
17+
* Resolve the active fetch implementation. Falls back to the *current*
18+
* globalThis.fetch (resolved per call, so test-time fetch mocks keep working).
19+
*/
20+
export function resolveFetch(): FetchLike {
21+
return defaultFetch ?? ((input, init) => fetch(input, init));
22+
}

packages/sdk/tests/unit/bailian.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,19 @@ describe("bailianConfigSchema", () => {
3838
expect(() => bailianConfigSchema.parse({ workspace_id: "ws-123" })).toThrow();
3939
});
4040

41-
test("rejects config missing workspace_id", () => {
42-
expect(() => bailianConfigSchema.parse({ api_key: "sk-abc" })).toThrow();
41+
test("accepts base_url without workspace_id", () => {
42+
const result = bailianConfigSchema.parse({
43+
api_key: "sk-abc",
44+
base_url: "https://custom.example.com/api/v1/agentstudio",
45+
});
46+
expect(result.workspace_id).toBeUndefined();
47+
expect(result.base_url).toBe("https://custom.example.com/api/v1/agentstudio");
48+
});
49+
50+
test("rejects config with neither workspace_id nor base_url", () => {
51+
expect(() => bailianConfigSchema.parse({ api_key: "sk-abc" })).toThrow(
52+
/either workspace_id or base_url is required/,
53+
);
4354
});
4455
});
4556

@@ -328,7 +339,10 @@ describe("Bailian mapEnvironment", () => {
328339

329340
test("rejects 'limited' networking instead of silently widening it", () => {
330341
const decl: EnvironmentDecl = {
331-
config: { type: "cloud", networking: { type: "limited", allowed_hosts: ["api.github.com"] } },
342+
config: {
343+
type: "cloud",
344+
networking: { type: "limited", allowed_hosts: ["api.github.com"] },
345+
},
332346
};
333347
// The real API rejects non-unrestricted networking; silently dropping the
334348
// restriction would widen a declared egress boundary, so we must throw.

0 commit comments

Comments
 (0)