Skip to content

Commit 2913d3a

Browse files
authored
fix(anthropic): restore Fable 5 Vertex simple completions (#98932)
* fix(anthropic): restore Fable 5 Vertex simple completions * test(agents): satisfy custom API model types * test(ci): route reliability test from temp helper * test(agents): satisfy custom API model types
1 parent 4d7d34e commit 2913d3a

4 files changed

Lines changed: 104 additions & 9 deletions

File tree

extensions/anthropic-vertex/stream-runtime.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,23 @@ describe("createAnthropicVertexStreamFn", () => {
177177
});
178178
});
179179

180+
it("restores the canonical API before calling the shared Anthropic transport", () => {
181+
const { deps, streamAnthropicMock } = createStreamDeps();
182+
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
183+
const model = {
184+
...makeModel({ id: "claude-fable-5", maxTokens: 128000 }),
185+
api: "openclaw-anthropic-vertex-simple:default",
186+
};
187+
188+
void streamFn(model as never, { messages: [] }, {});
189+
190+
expect(streamAnthropicCall(streamAnthropicMock)[0]).toMatchObject({
191+
api: "anthropic-messages",
192+
provider: "anthropic-vertex",
193+
id: "claude-fable-5",
194+
});
195+
});
196+
180197
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
181198
const { deps, streamAnthropicMock } = createStreamDeps();
182199
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);

extensions/anthropic-vertex/stream-runtime.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,11 @@ export function createAnthropicVertexStreamFn(
135135
});
136136

137137
return (model, context, options) => {
138-
const transportModel = model as Model<"anthropic-messages"> & {
139-
api: string;
138+
// Simple completions use a synthetic registry API to select this plugin.
139+
// The shared Anthropic transport must receive its canonical API or it recurses.
140+
const transportModel = (
141+
model.api === "anthropic-messages" ? model : { ...model, api: "anthropic-messages" as const }
142+
) as Model<"anthropic-messages"> & {
140143
baseUrl?: string;
141144
provider: string;
142145
};

src/agents/custom-api-registry.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../llm/providers/register-builtins.js";
1313
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
1414
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
15+
import { buildAssistantMessageWithZeroUsage } from "./stream-message-shared.js";
1516

1617
function getRegisteredTestProvider() {
1718
const provider = getApiProvider("test-custom-api");
@@ -56,6 +57,50 @@ describe("ensureCustomApiRegistered", () => {
5657
expect(streamFn).toHaveBeenCalledTimes(2);
5758
});
5859

60+
it("adapts async stream factories to the synchronous provider contract", async () => {
61+
const message = buildAssistantMessageWithZeroUsage({
62+
model: { api: "test-custom-api", provider: "custom", id: "m" },
63+
content: [{ type: "text", text: "done" }],
64+
stopReason: "stop",
65+
});
66+
const streamFn = vi.fn(async () => {
67+
await Promise.resolve();
68+
const stream = createAssistantMessageEventStream();
69+
stream.push({ type: "done", reason: "stop", message });
70+
return stream;
71+
});
72+
ensureCustomApiRegistered("test-custom-api", streamFn);
73+
74+
const provider = getRegisteredTestProvider();
75+
const stream = provider.stream(
76+
{ api: "test-custom-api", provider: "custom", id: "m" } as never,
77+
{ messages: [] },
78+
{},
79+
);
80+
81+
expect(stream).not.toBeInstanceOf(Promise);
82+
await expect(stream.result()).resolves.toBe(message);
83+
});
84+
85+
it("converts async stream factory failures into terminal stream errors", async () => {
86+
const streamFn = vi.fn(async () => {
87+
throw new Error("factory failed");
88+
});
89+
ensureCustomApiRegistered("test-custom-api", streamFn);
90+
91+
const provider = getRegisteredTestProvider();
92+
const stream = provider.stream(
93+
{ api: "test-custom-api", provider: "custom", id: "m" } as never,
94+
{ messages: [] },
95+
{},
96+
);
97+
98+
await expect(stream.result()).resolves.toMatchObject({
99+
stopReason: "error",
100+
errorMessage: "factory failed",
101+
});
102+
});
103+
59104
it("keeps plugin api providers when refreshing built-ins", () => {
60105
// Built-in refresh should preserve plugin-owned API providers while
61106
// repopulating core providers.

src/agents/custom-api-registry.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22
* Registers caller-supplied custom API stream functions with the LLM registry.
33
*/
44
import { getApiProvider, registerApiProvider } from "../llm/api-registry.js";
5-
import type { Api, StreamOptions } from "../llm/types.js";
5+
import type {
6+
Api,
7+
AssistantMessageEventStreamContract,
8+
Model,
9+
StreamOptions,
10+
} from "../llm/types.js";
11+
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
612
import type { StreamFn } from "./runtime/index.js";
13+
import { buildStreamErrorAssistantMessage } from "./stream-message-shared.js";
714

815
const CUSTOM_API_SOURCE_PREFIX = "openclaw-custom-api:";
916

@@ -12,6 +19,33 @@ function getCustomApiRegistrySourceId(api: Api): string {
1219
return `${CUSTOM_API_SOURCE_PREFIX}${api}`;
1320
}
1421

22+
function adaptCustomStream(
23+
model: Model,
24+
stream: ReturnType<StreamFn>,
25+
): AssistantMessageEventStreamContract {
26+
if (!(stream instanceof Promise)) {
27+
return stream as AssistantMessageEventStreamContract;
28+
}
29+
30+
const adapted = createAssistantMessageEventStream();
31+
void (async () => {
32+
try {
33+
// Registry providers must return a stream immediately, while plugin
34+
// hooks may resolve one lazily. Bridge that lifecycle at the boundary.
35+
const resolved = await stream;
36+
for await (const event of resolved) {
37+
adapted.push(event);
38+
}
39+
adapted.end(await resolved.result());
40+
} catch (error) {
41+
const errorMessage = error instanceof Error ? error.message : String(error);
42+
const message = buildStreamErrorAssistantMessage({ model, errorMessage });
43+
adapted.push({ type: "error", reason: "error", error: message });
44+
}
45+
})();
46+
return adapted;
47+
}
48+
1549
/** Registers a custom API stream function when no provider already owns it. */
1650
export function ensureCustomApiRegistered(api: Api, streamFn: StreamFn): boolean {
1751
if (getApiProvider(api)) {
@@ -22,13 +56,9 @@ export function ensureCustomApiRegistered(api: Api, streamFn: StreamFn): boolean
2256
{
2357
api,
2458
stream: (model, context, options) =>
25-
streamFn(model, context, options) as unknown as ReturnType<
26-
NonNullable<ReturnType<typeof getApiProvider>>["stream"]
27-
>,
59+
adaptCustomStream(model, streamFn(model, context, options)),
2860
streamSimple: (model, context, options) =>
29-
streamFn(model, context, options as StreamOptions) as unknown as ReturnType<
30-
NonNullable<ReturnType<typeof getApiProvider>>["stream"]
31-
>,
61+
adaptCustomStream(model, streamFn(model, context, options as StreamOptions)),
3262
},
3363
getCustomApiRegistrySourceId(api),
3464
);

0 commit comments

Comments
 (0)