Skip to content

Commit c5d4259

Browse files
fix(anthropic): guard SDK client transport (#101357)
Co-authored-by: wangmiao0668000666 <[email protected]>
1 parent 5909778 commit c5d4259

4 files changed

Lines changed: 158 additions & 1 deletion

File tree

extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe("createMantleAnthropicStreamFn", () => {
8585
expect(defaultHeaders["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
8686
expect(defaultHeaders["X-Test"]).toBe("model-header");
8787
expect(defaultHeaders["X-Caller"]).toBe("caller-header");
88+
expect(clientOptions.fetch).toEqual(expect.any(Function));
8889

8990
expectFirstStreamCall(deps, model, context);
9091
const streamOptions = firstStreamOptions(deps);

extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
resolveClaudeMythos5ModelIdentity,
1616
resolveClaudeSonnet5ModelIdentity,
1717
} from "openclaw/plugin-sdk/provider-model-shared";
18+
import { buildGuardedModelFetch } from "openclaw/plugin-sdk/provider-transport-runtime";
1819

1920
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
2021
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
@@ -177,6 +178,7 @@ export function createMantleAnthropicStreamFn(deps?: {
177178
model.headers,
178179
options?.headers,
179180
),
181+
fetch: buildGuardedModelFetch(model),
180182
});
181183
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
182184
// Plugin package deps can give this plugin a distinct physical SDK copy.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { createServer } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { configureAiTransportHost } from "../host.js";
5+
import type { Context, Model } from "../types.js";
6+
import { streamAnthropic } from "./anthropic.js";
7+
8+
type CapturedRequest = {
9+
method: string;
10+
path: string;
11+
authorization?: string;
12+
apiKey?: string;
13+
};
14+
15+
const context = {
16+
messages: [{ role: "user", content: "hello", timestamp: 1 }],
17+
} satisfies Context;
18+
19+
function makeModel(overrides: Partial<Model<"anthropic-messages">>) {
20+
return {
21+
id: "claude-sonnet-4-6",
22+
name: "Claude Sonnet 4.6",
23+
provider: "anthropic",
24+
api: "anthropic-messages",
25+
baseUrl: "https://api.anthropic.com",
26+
reasoning: true,
27+
input: ["text"],
28+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
29+
contextWindow: 200_000,
30+
maxTokens: 4_096,
31+
...overrides,
32+
} satisfies Model<"anthropic-messages">;
33+
}
34+
35+
afterEach(() => {
36+
configureAiTransportHost({});
37+
});
38+
39+
describe("Anthropic SDK host fetch wiring", () => {
40+
it("routes every non-Cloudflare client branch through the host fetch", async () => {
41+
const requests: CapturedRequest[] = [];
42+
const server = createServer((request, response) => {
43+
requests.push({
44+
method: request.method ?? "",
45+
path: request.url ?? "",
46+
authorization: request.headers.authorization,
47+
apiKey: request.headers["x-api-key"] as string | undefined,
48+
});
49+
response.writeHead(401, { "content-type": "application/json" });
50+
response.end(
51+
JSON.stringify({
52+
type: "error",
53+
error: { type: "authentication_error", message: "test rejection" },
54+
}),
55+
);
56+
});
57+
await new Promise<void>((resolve) => {
58+
server.listen(0, "127.0.0.1", () => resolve());
59+
});
60+
61+
const address = server.address() as AddressInfo;
62+
const baseUrl = `http://127.0.0.1:${address.port}`;
63+
const hostFetch = vi.fn<typeof fetch>((input, init) => globalThis.fetch(input, init));
64+
const buildModelFetch = vi.fn(() => hostFetch);
65+
configureAiTransportHost({ buildModelFetch });
66+
67+
const cases = [
68+
{
69+
model: makeModel({ provider: "github-copilot", baseUrl }),
70+
apiKey: "copilot-token",
71+
},
72+
{
73+
model: makeModel({ provider: "microsoft-foundry", baseUrl, authHeader: true }),
74+
apiKey: "foundry-token",
75+
},
76+
{
77+
model: makeModel({ baseUrl }),
78+
apiKey: "sk-ant-oat01-oauth-token", // pragma: allowlist secret
79+
},
80+
{
81+
model: makeModel({ baseUrl }),
82+
apiKey: "sk-ant-api03-api-key", // pragma: allowlist secret
83+
},
84+
{
85+
model: makeModel({ provider: "kimi-coding", baseUrl }),
86+
apiKey: "kimi-api-key",
87+
thinkingEnabled: true,
88+
},
89+
];
90+
91+
try {
92+
for (const testCase of cases) {
93+
const result = await streamAnthropic(testCase.model, context, {
94+
apiKey: testCase.apiKey,
95+
maxRetries: 0,
96+
thinkingEnabled: testCase.thinkingEnabled,
97+
}).result();
98+
expect(result.stopReason).toBe("error");
99+
}
100+
} finally {
101+
await new Promise<void>((resolve, reject) => {
102+
server.close((error) => (error ? reject(error) : resolve()));
103+
});
104+
}
105+
106+
expect(hostFetch).toHaveBeenCalledTimes(cases.length);
107+
expect(requests).toEqual([
108+
{
109+
method: "POST",
110+
path: "/v1/messages",
111+
authorization: "Bearer copilot-token",
112+
apiKey: undefined,
113+
},
114+
{
115+
method: "POST",
116+
path: "/v1/messages",
117+
authorization: "Bearer foundry-token",
118+
apiKey: undefined,
119+
},
120+
{
121+
method: "POST",
122+
path: "/v1/messages",
123+
authorization: "Bearer sk-ant-oat01-oauth-token", // pragma: allowlist secret
124+
apiKey: undefined,
125+
},
126+
{
127+
method: "POST",
128+
path: "/v1/messages",
129+
authorization: undefined,
130+
apiKey: "sk-ant-api03-api-key", // pragma: allowlist secret
131+
},
132+
{
133+
method: "POST",
134+
path: "/v1/messages",
135+
authorization: undefined,
136+
apiKey: "kimi-api-key",
137+
},
138+
]);
139+
expect(buildModelFetch).toHaveBeenLastCalledWith(cases.at(-1)?.model, undefined, {
140+
sanitizeSse: false,
141+
});
142+
});
143+
});

packages/ai/src/providers/anthropic.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
559559
const created = createClient(
560560
model,
561561
apiKey,
562+
options?.thinkingEnabled === true,
562563
options?.interleavedThinking ?? true,
563564
shouldUseFineGrainedToolStreamingBeta(model, requestContext),
564565
options?.headers,
@@ -1101,6 +1102,7 @@ function supportsAnthropicServerSideFallback(model: Model<"anthropic-messages">)
11011102
function createClient(
11021103
model: Model<"anthropic-messages">,
11031104
apiKey: string,
1105+
thinkingEnabled: boolean,
11041106
interleavedThinking: boolean,
11051107
useFineGrainedToolStreamingBeta: boolean,
11061108
optionsHeaders?: Record<string, string>,
@@ -1117,6 +1119,11 @@ function createClient(
11171119
if (needsInterleavedBeta) {
11181120
betaFeatures.push(INTERLEAVED_THINKING_BETA);
11191121
}
1122+
const fetchOptions =
1123+
/^kimi(?:-|$)/.test(model.provider) && thinkingEnabled
1124+
? { sanitizeSse: false as const }
1125+
: undefined;
1126+
const fetch = getAiTransportHost().buildModelFetch(model, undefined, fetchOptions);
11201127

11211128
if (model.provider === "cloudflare-ai-gateway") {
11221129
const client = new Anthropic({
@@ -1134,7 +1141,7 @@ function createClient(
11341141
model.headers,
11351142
optionsHeaders,
11361143
),
1137-
fetch: getAiTransportHost().buildModelFetch(model),
1144+
fetch,
11381145
});
11391146

11401147
return { client, isOAuthToken: false, serverSideFallback: false };
@@ -1157,6 +1164,7 @@ function createClient(
11571164
dynamicHeaders,
11581165
optionsHeaders,
11591166
),
1167+
fetch,
11601168
});
11611169

11621170
return { client, isOAuthToken: false, serverSideFallback: false };
@@ -1178,6 +1186,7 @@ function createClient(
11781186
dynamicHeaders,
11791187
optionsHeaders,
11801188
),
1189+
fetch,
11811190
});
11821191

11831192
return { client, isOAuthToken: false, serverSideFallback: false };
@@ -1201,6 +1210,7 @@ function createClient(
12011210
model.headers,
12021211
optionsHeaders,
12031212
),
1213+
fetch,
12041214
});
12051215

12061216
return { client, isOAuthToken: true, serverSideFallback: false };
@@ -1230,6 +1240,7 @@ function createClient(
12301240
model.headers,
12311241
optionsHeaders,
12321242
),
1243+
fetch,
12331244
});
12341245

12351246
return { client, isOAuthToken: false, serverSideFallback };

0 commit comments

Comments
 (0)