diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2f483b8214ff..380be9440ef7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"1207fce625d4bdc4d3c369e7ad124d595caa65655a3c754b4a6cd9ff4987c96e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"66cc8d088b99fc543585ebc69c2149ec31cde693d580926faeeb56a756002ce0","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index f7975f6b6698..1b75e5f4e97a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"87cbc73539e9269ad9c50075617ae88a788dbdf5f36b0353a9ede3d5274b23d1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"98f0954479848e96da80f4fe4dec84580ea02eb3c99c4bfb8b8650f560852b8a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index d78923298116..a9c820967c32 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"4f2a3bb150dc6146acb955e014280553d93eb81a65dd688ac31dbc2994861eff","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"f4818fe0f464f65b8c564d6d14ef0867c7ad933a888827acf97944c18789a15c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 66eaeee07500..df16308996ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6383e2142e408da60738625a3088e49ab5e4330235185aee229d635c31210518","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"c704b85a419f1d4cb581c4a17e8d30ad2893cd1e177f46245c33e8b83be70edf","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 960e4e919946..6c7a8243fcca 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"682e3809e8e7425ca4153c8fab7506066aecaef876dd5db03bee0be2933792b8","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"0d442cf852cb35129f2eccfee5c4031946404fc691bb3690b7aa54dd4c7d550d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 1a86492c816c..7cfc3cfae364 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"65dfbfffad17e99767475777a5971239e3709b828bd32cd0956da07cf77ba561","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"3a937d8039d8ebe37f71b7fce57251adc0e3eb9c5aa6b138cd695a39350b1991","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index d0f6b4d3ae91..4c8c6894d727 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"2a38a18ec11bc0cda62fe57565c4785305a80fd6607339029d5cd2d996d15b23","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"3965e6960af8ac5f36f2132c218b5c38e4aa5274c96bc2e659ec2929d137beca","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index d8ef1751d628..e8b2bb960fa8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"b2c4f491e5ce945f9d2c882ddfc3ca5d14ea83aef36413068ef4ba8985cbbc9e","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"4d030c8d32ee935043063a15692eba9e15020182144f9770a76b520351d53b9e","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index b4b0fe460766..9e1bb43352bd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"762beb61043c5c24153cd19b05994db159e7c9fd7736aefa04449d09100c412d","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"a3d10effa80491699a93aabbc6cb3ac404913fde87b52a9566f1360a679a4e03","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 52b375c8797f..c4cc8d6a42a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"e9396f9a763e82a68d9307d60b5fbc2e63adc76c060aedc6675cd7760f92642d","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"31e609e0fda8b6561856f788419ca055659a19fbe724397d8e12510408339ca9","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index f4c08d899e1a..de0d9860f036 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"66a282f06206f78b88fba05f82e74e40d19188896b39d4af4fc80fc4eb3e3279","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"65291a0f9266f239812fd732535c0e63f21eb723527e6e930398067b85283607","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 64e847bf48a4..d67d94531089 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"f202742b71496ec4042df3121e8f6e99b27e6c5b9db430b2e3bef81ce6c8d87e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"d929e7a1c19c0403bb260610c881e9a98b3ce7b307729d3c40f72b16476a06c9","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 71d19dfba892..2b1bf571b774 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"012ae0b34dd7ae900b8c899967f4230a11482228e1f844f1445a3bd6aaab87b5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"eef16bc3eca8d5d9cea265e4b09b3cb0a78b08d5238cea9e3df1c6901b46ad37","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index c3e8f23a39b2..1ba79211b9cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"b8196472e5e369784943e6c4e6b001e976b95871c0ee31005f2cdf78404f9c37","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"70229ab9b920b19dd7715d54418294f4071a870d23325b9e6e8623895cdaf5e5","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 6d2475eb1cbc..d74cf75edd0e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"a7b7110ac3274061034766039c5e40e27c111f1b32666e5885938d2746305867","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"c9e8af2c4fb225217736662fb3b82900523d3e7b1eba766cc1f96d9ef4d2279a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index cd94992797c2..4a7ab62f9eb3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"de0cfbef34e85ffb3b4fc69a290fdb470606c3dbafa25078662567fd8b5b202b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"d34db3cba2e1fd488996b6820ba6e422d17472cde8d4f8077704234e8e96d7d8","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/providers/meta.md b/docs/providers/meta.md index 025f618d7b24..82a0468f2f86 100644 --- a/docs/providers/meta.md +++ b/docs/providers/meta.md @@ -1,5 +1,5 @@ --- -summary: "Meta setup (auth + muse-spark-1.1 model selection)" +summary: "Meta setup, authentication, and Muse Spark model selection" title: "Meta" read_when: - You want to use Meta with OpenClaw @@ -7,20 +7,20 @@ read_when: --- The **Meta API** uses the OpenAI-compatible **Responses API** (`POST /v1/responses`) -for the `muse-spark-1.1` reasoning model. OpenClaw provides Meta as an official -external plugin. +for the Muse Spark reasoning models. OpenClaw provides Meta as an official external +plugin. -| Property | Value | -| ----------------- | ---------------------------------- | -| Provider id | `meta` | -| Plugin | `@openclaw/meta-provider` | -| Auth env var | `MODEL_API_KEY` | -| Onboarding flag | `--auth-choice meta-api-key` | -| Direct CLI flag | `--meta-api-key ` | -| API | Responses API (`openai-responses`) | -| Base URL | `https://api.meta.ai/v1` | -| Default model | `meta/muse-spark-1.1` | -| Default reasoning | `high` (`reasoning.effort`) | +| Property | Value | +| -------------------------- | ---------------------------------- | +| Provider id | `meta` | +| Plugin | `@openclaw/meta-provider` | +| Auth env var | `MODEL_API_KEY` | +| Onboarding flag | `--auth-choice meta-api-key` | +| Direct CLI flag | `--meta-api-key ` | +| API | Responses API (`openai-responses`) | +| Base URL | `https://api.meta.ai/v1` | +| Default model | `meta/muse-spark-1.1` | +| OpenClaw reasoning default | `high` (`reasoning.effort`) | ## Getting started @@ -56,7 +56,7 @@ export MODEL_API_KEY= openclaw models list --provider meta ``` - Lists the static `muse-spark-1.1` catalog entry. If `MODEL_API_KEY` is unresolved, + Lists the static Muse Spark catalog entries. If `MODEL_API_KEY` is unresolved, `openclaw models status --json` reports the missing credential under `auth.unusableProfiles`. @@ -74,19 +74,54 @@ openclaw onboard --non-interactive --accept-risk --skip-health \ ## Built-in catalog -| Model ref | Name | Input | Reasoning | Context window | Max output | Input / cached input / output per 1M tokens | -| --------------------- | -------------- | ----------- | --------- | -------------- | ---------- | ------------------------------------------- | -| `meta/muse-spark-1.1` | Muse Spark 1.1 | text, image | yes | 1,048,576 | 131,072 | $1.25 / $0.15 / $4.25 | +Prices and data-use terms come from Meta's +[pricing and rate limits](https://dev.meta.ai/docs/pricing-rate-limits/) +documentation. + +| Model ref | Name | OpenClaw input | Reasoning | Context window | Input / cached input / output per 1M tokens | +| --------------------------------- | -------------------------- | -------------- | --------- | -------------- | ------------------------------------------- | +| `meta/muse-spark-1.1` | Muse Spark 1.1 | text, image | yes | 1,048,576 | $1.25 / $0.15 / $4.25 | +| `meta/muse-spark-1.2` | Muse Spark 1.2 | text, image | yes | 1,048,576 | $1.25 / $0.15 / $4.25 | +| `meta/muse-spark-1.2-contributor` | Muse Spark 1.2 Contributor | text, image | yes | 1,048,576 | $0.10 / $0.002 / $0.20 | + + +Meta's [pricing documentation](https://dev.meta.ai/docs/pricing-rate-limits/) and +[Terms of Service](https://dev.meta.ai/legal/terms-of-service) distinguish Standard +Services from Contributor/Discounted Services: + +- Standard Services are the default. Meta says prompts and completions submitted to + Standard Services are not used to train Meta models. +- By using Contributor/Discounted Services, you permit Meta to use Content submitted + to and generated by those services as described in the Terms. Under the Terms, use + of Discounted Services acknowledges that permission. You must not submit sensitive, + confidential, or personal information to the Discounted Services. + +Meta's [Geographic Use Policy](https://dev.meta.ai/legal/geographic-use-policy) +governs availability. It limits API access in some jurisdictions and adds end-user +deployment restrictions for products built with the Contributor/Discounted model; +those additional restrictions do not apply to your own use or products built with +Standard Services. + Capabilities: -- Text and image input +- Text and image input through OpenClaw - Tool calling and streaming -- Reasoning effort: `minimal`, `low`, `medium`, `high`, `xhigh` (default: `high`) +- Reasoning effort: `minimal`, `low`, `medium`, `high`, `xhigh` (OpenClaw default: `high`) - Stateless encrypted reasoning replay (`store: false`, `include: ["reasoning.encrypted_content"]`) +Meta's [model catalog](https://dev.meta.ai/docs/models) lists text, image, video, +audio, and PDF input for these models. OpenClaw's model catalog directly represents +text and image input only; the other upstream modalities are not model-manifest input +values. + +OpenClaw explicitly selects `high` when no thinking level is configured. This is an +OpenClaw default, not Meta's omitted-parameter behavior: Meta's +[reasoning documentation](https://dev.meta.ai/docs/reasoning/) says that when +`reasoning.effort` is omitted, the model reasons at a model-determined level. + -`muse-spark-1.1` does not accept `reasoning.effort: "none"`. OpenClaw maps +Muse Spark does not accept `reasoning.effort: "none"`. OpenClaw maps `--thinking off` to `minimal` for this provider. @@ -121,7 +156,7 @@ export MODEL_API_KEY= pnpm test:live -- extensions/meta/meta.live.test.ts ``` -Live tests use `muse-spark-1.1` against `POST /v1/responses`. +The live suite exercises enabled Meta cases against `POST /v1/responses`. ## Related @@ -130,7 +165,7 @@ Live tests use `muse-spark-1.1` against `POST /v1/responses`. Choosing providers, model refs, and failover behavior. - Reasoning effort levels for muse-spark-1.1. + Reasoning effort levels for Muse Spark. Agent defaults and model configuration. diff --git a/extensions/meta/README.md b/extensions/meta/README.md index 6441f3b14368..f84ded321210 100644 --- a/extensions/meta/README.md +++ b/extensions/meta/README.md @@ -5,14 +5,36 @@ Official OpenClaw provider plugin for the **Meta API** — an OpenAI-compatible - **Base URL:** `https://api.meta.ai/v1` - **Auth:** `Authorization: Bearer $MODEL_API_KEY` -- **Model:** `muse-spark-1.1` (reasoning model) +- **Models:** `muse-spark-1.1`, `muse-spark-1.2`, `muse-spark-1.2-contributor` (reasoning models) - Context window: 1,048,576 tokens (input + output share the budget) - - Maximum output: 131,072 tokens - - Reasoning effort: `minimal | low | medium | high | xhigh` (default: `high`) + - Reasoning effort: `minimal | low | medium | high | xhigh` (OpenClaw default: `high`) - Vision: image input in `user` messages - Tool calling + streaming - Stateless encrypted reasoning replay (`store: false`) +> [!WARNING] +> Meta's [pricing documentation](https://dev.meta.ai/docs/pricing-rate-limits/) and +> [Terms of Service](https://dev.meta.ai/legal/terms-of-service) distinguish Standard +> Services from Contributor/Discounted Services: +> +> - Standard Services are the default. Meta says prompts and completions submitted to +> Standard Services are not used to train Meta models. +> - By using Contributor/Discounted Services, you permit Meta to use Content submitted +> to and generated by those services as described in the Terms. Under the Terms, use +> of Discounted Services acknowledges that permission. You must not submit sensitive, +> confidential, or personal information to the Discounted Services. +> +> Meta's [Geographic Use Policy](https://dev.meta.ai/legal/geographic-use-policy) +> governs availability. It limits API access in some jurisdictions and adds end-user +> deployment restrictions for products built with the Contributor/Discounted model; +> those additional restrictions do not apply to your own use or products built with +> Standard Services. + +Meta's [model catalog](https://dev.meta.ai/docs/models) lists text, image, video, +audio, and PDF input for these models. OpenClaw's model catalog directly represents +text and image input only; the other upstream modalities are not model-manifest input +values. Meta does not publish an exact maximum-output figure for these catalog rows. + ## Usage Install the plugin and restart Gateway: @@ -44,7 +66,10 @@ Or run onboarding and choose **Meta**. ## Thinking / reasoning `--thinking ` and `/think ` map to Responses API `reasoning.effort`. -Default thinking level is `high`. `off` maps to `minimal` because Muse Spark does +OpenClaw defaults to `high`. Meta's +[reasoning documentation](https://dev.meta.ai/docs/reasoning/) says that an +omitted effort uses a model-determined level, so `high` is an OpenClaw policy rather +than Meta's omitted-parameter default. `off` maps to `minimal` because Muse Spark does not accept `none`. ## Docs @@ -58,4 +83,4 @@ export MODEL_API_KEY= pnpm test:live -- extensions/meta/meta.live.test.ts ``` -Live tests call `muse-spark-1.1` on `/v1/responses`. +The live suite exercises enabled Meta cases against `POST /v1/responses`. diff --git a/extensions/meta/index.test.ts b/extensions/meta/index.test.ts index 1dbb65f9d74a..8fb5a924975e 100644 --- a/extensions/meta/index.test.ts +++ b/extensions/meta/index.test.ts @@ -1,8 +1,71 @@ // Meta tests cover plugin registration and catalog shape. +import { + configureAiTransportHost, + createApiRegistry, + createAssistantMessageEventStream, + createLlmRuntime, + getAiTransportHost, + type Api, + type AssistantMessageEventStreamContract, + type SimpleStreamOptions, + type StreamFunction, +} from "@openclaw/ai"; +import { prepareModelForSimpleCompletion } from "@openclaw/ai/transports"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple, type Context, type Model } from "openclaw/plugin-sdk/llm"; import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildMetaProvider } from "./api.js"; import plugin from "./index.js"; +import { wrapMetaProviderStream } from "./stream.js"; + +const CATALOG_CAP_MODEL_ID = "muse-spark-1.2"; +const initialAiTransportHost = getAiTransportHost(); + +function resolveCatalogModel(modelId: string): Model<"openai-responses"> { + const provider = buildMetaProvider(); + const catalogModel = provider.models.find((model) => model.id === modelId); + if (!catalogModel) { + throw new Error(`Expected ${modelId} in Meta catalog`); + } + return { + provider: "meta", + baseUrl: provider.baseUrl, + ...catalogModel, + api: "openai-responses", + } as Model<"openai-responses">; +} + +function completedSseResponse(): Response { + const completed = { + type: "response.completed", + response: { + id: "resp_meta_catalog_cap", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 }, + }, + }; + return new Response(`data: ${JSON.stringify(completed)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function requireSynchronousStream( + stream: ReturnType, +): AssistantMessageEventStreamContract { + if ( + stream instanceof Promise || + !("push" in stream) || + !("end" in stream) || + typeof stream.push !== "function" || + typeof stream.end !== "function" + ) { + throw new Error("Expected synchronous assistant event stream"); + } + return stream as AssistantMessageEventStreamContract; +} function requireThinkingProfileResolver( provider: ReturnType["providers"][number], @@ -14,6 +77,11 @@ function requireThinkingProfileResolver( } describe("meta provider", () => { + afterEach(() => { + vi.unstubAllGlobals(); + configureAiTransportHost(initialAiTransportHost); + }); + it("registers the Meta provider with api-key auth", () => { const captured = capturePluginRegistration(plugin); const [provider] = captured.providers; @@ -25,6 +93,8 @@ describe("meta provider", () => { label: "Meta", docsPath: "/providers/meta", }); + expect(provider.wrapStreamFn).toBe(wrapMetaProviderStream); + expect(provider.wrapSimpleCompletionStreamFn).toBe(wrapMetaProviderStream); expect(provider.auth).toHaveLength(1); expect(provider.auth[0]).toMatchObject({ id: "api-key", @@ -34,6 +104,79 @@ describe("meta provider", () => { }); }); + it("does not wrap projected non-Responses Meta models for either stream hook", () => { + const captured = capturePluginRegistration(plugin); + const [provider] = captured.providers; + if (!provider) { + throw new Error("Expected Meta provider"); + } + const model = { + ...resolveCatalogModel(CATALOG_CAP_MODEL_ID), + api: "openclaw-provider-stream:meta:muse-spark-1.2", + } as Model; + + for (const hook of [provider.wrapStreamFn, provider.wrapSimpleCompletionStreamFn]) { + if (!hook) { + throw new Error("Expected Meta stream hook"); + } + let capturedPayload: Record | undefined; + const baseStreamFn: StreamFn = (streamModel, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload, streamModel); + capturedPayload = payload; + return {} as ReturnType; + }; + const wrapped = hook({ + provider: "meta", + modelId: model.id, + model, + sourceApi: "openai-completions", + streamFn: baseStreamFn, + }); + + expect(wrapped).toBeUndefined(); + void (wrapped ?? baseStreamFn)(model, { messages: [] }, { maxTokens: 0 }); + expect(capturedPayload).toEqual({}); + } + }); + + it("wraps projected direct completions from a Responses source API", () => { + const captured = capturePluginRegistration(plugin); + const [provider] = captured.providers; + if (!provider?.wrapSimpleCompletionStreamFn) { + throw new Error("Expected Meta direct completion stream wrapper"); + } + const model = { + ...resolveCatalogModel(CATALOG_CAP_MODEL_ID), + api: "openclaw-provider-stream:meta:muse-spark-1.2", + } as Model; + let capturedPayload: Record | undefined; + const baseStreamFn: StreamFn = (streamModel, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload, streamModel); + capturedPayload = payload; + return {} as ReturnType; + }; + const wrapped = provider.wrapSimpleCompletionStreamFn({ + provider: "meta", + modelId: model.id, + model, + sourceApi: "openai-responses", + streamFn: baseStreamFn, + }); + if (!wrapped) { + throw new Error("Expected projected Meta Responses stream wrapper"); + } + + void wrapped(model, { messages: [] }, { maxTokens: 0 }); + + expect(capturedPayload).toMatchObject({ + include: ["reasoning.encrypted_content"], + max_output_tokens: 131072, + store: false, + }); + }); + it("builds the muse-spark-1.1 catalog entry over openai-responses", () => { const providerConfig = buildMetaProvider(); expect(providerConfig.baseUrl).toBe("https://api.meta.ai/v1"); @@ -54,25 +197,302 @@ describe("meta provider", () => { }); }); - it("advertises a high default thinking profile for muse-spark-1.1", () => { + it("builds the muse-spark-1.2 catalog entry over openai-responses", () => { + const providerConfig = buildMetaProvider(); + expect(providerConfig.baseUrl).toBe("https://api.meta.ai/v1"); + expect(providerConfig.api).toBe("openai-responses"); + const model = providerConfig.models.find((m) => m.id === "muse-spark-1.2"); + if (!model) { + throw new Error("Expected muse-spark-1.2 model"); + } + expect(model.contextWindow).toBe(1048576); + expect(model.maxTokens).toBe(131072); + expect(model.reasoning).toBe(true); + expect(model.input).toEqual(["text", "image"]); + expect(model.cost).toEqual({ + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }); + }); + + it("preserves the provider-selected output cap when the caller omits it", async () => { + const model = resolveCatalogModel(CATALOG_CAP_MODEL_ID); + let capturedPayload: Record | undefined; + const fetchMock = vi.fn(async () => completedSseResponse()); + vi.stubGlobal("fetch", fetchMock); + const streamFn = wrapMetaProviderStream({ + provider: "meta", + modelId: model.id, + model, + streamFn: streamSimple, + }); + if (!streamFn) { + throw new Error("Expected Meta Responses stream wrapper"); + } + + const context: Context = { + messages: [{ role: "user", content: "Catalog cap probe", timestamp: 0 }], + }; + const stream = await streamFn(model, context, { + apiKey: "unit-test-token", + maxRetries: 0, + onPayload: (payload) => { + capturedPayload = payload as Record; + }, + }); + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(capturedPayload).not.toHaveProperty("max_output_tokens"); + }); + + it("preserves Meta replay fields through canonical simple-completion aliases", async () => { + const captured = capturePluginRegistration(plugin); + const [provider] = captured.providers; + if (!provider?.wrapSimpleCompletionStreamFn) { + throw new Error("Expected Meta direct completion stream wrapper"); + } + + const registry = createApiRegistry(); + const runtime = createLlmRuntime(registry); + const model = resolveCatalogModel(CATALOG_CAP_MODEL_ID); + let capturedPayload: Record | undefined; + let sourceModelApi: Api | undefined; + const sourceStreamFn: StreamFunction<"openai-responses", SimpleStreamOptions> = ( + streamModel, + _context, + options, + ) => { + sourceModelApi = streamModel.api; + const payload: Record = {}; + options?.onPayload?.(payload, streamModel); + capturedPayload = payload; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "stop", + message: { stopReason: "stop" } as never, + }); + stream.end(); + }); + return stream; + }; + registry.registerApiProvider({ + api: "openai-responses", + stream: sourceStreamFn, + streamSimple: sourceStreamFn, + }); + configureAiTransportHost({ + ...initialAiTransportHost, + registerCustomApi: (apiRegistry, api, streamFn) => { + if (apiRegistry.getApiProvider(api)) { + return false; + } + apiRegistry.registerApiProvider({ + api, + stream: (streamModel, streamContext, options) => + requireSynchronousStream(streamFn(streamModel, streamContext, options)), + streamSimple: (streamModel, streamContext, options) => + requireSynchronousStream(streamFn(streamModel, streamContext, options)), + }); + return true; + }, + plugin: { + ...initialAiTransportHost.plugin, + resolveProviderStream: () => undefined, + wrapSimpleCompletionStream: ({ provider: providerId, context }) => { + if (providerId !== "meta") { + return undefined; + } + return ( + provider.wrapSimpleCompletionStreamFn?.({ + agentDir: context.agentDir, + workspaceDir: context.workspaceDir, + provider: context.provider, + modelId: context.modelId, + model: context.model, + streamFn: context.streamFn, + }) ?? undefined + ); + }, + }, + }); + + const preparedModel = prepareModelForSimpleCompletion({ apiRegistry: registry, model }); + expect(preparedModel.api).toMatch(/^openclaw-provider-simple:/); + + const result = await runtime.completeSimple(preparedModel, { messages: [] }); + + expect(result.stopReason).toBe("stop"); + expect(sourceModelApi).toBe("openai-responses"); + expect(capturedPayload).toMatchObject({ + store: false, + include: ["reasoning.encrypted_content"], + }); + expect(capturedPayload).not.toHaveProperty("max_output_tokens"); + }); + + it.each([ + { + label: "an omitted override", + callerMaxTokens: undefined, + prepopulatedMaxOutputTokens: undefined, + expectedMaxOutputTokens: undefined, + }, + { + label: "a positive caller override", + callerMaxTokens: 4096, + prepopulatedMaxOutputTokens: undefined, + expectedMaxOutputTokens: 4096, + }, + { + label: "an explicit zero treated as unset by the Responses transport", + callerMaxTokens: 0, + prepopulatedMaxOutputTokens: undefined, + expectedMaxOutputTokens: 131072, + }, + { + label: "a pre-populated payload cap", + callerMaxTokens: undefined, + prepopulatedMaxOutputTokens: 2048, + expectedMaxOutputTokens: 2048, + }, + { + label: "a pre-populated payload cap with an explicit zero caller cap", + callerMaxTokens: 0, + prepopulatedMaxOutputTokens: 2048, + expectedMaxOutputTokens: 2048, + }, + ])( + "preserves catalog cap precedence through the direct completion hook for $label", + (testCase) => { + const captured = capturePluginRegistration(plugin); + const [provider] = captured.providers; + if (!provider?.wrapSimpleCompletionStreamFn) { + throw new Error("Expected Meta direct completion stream wrapper"); + } + const model = resolveCatalogModel(CATALOG_CAP_MODEL_ID); + let capturedPayload: Record | undefined; + const baseStreamFn: StreamFn = (streamModel, _context, options) => { + const payload: Record = {}; + if (testCase.prepopulatedMaxOutputTokens !== undefined) { + payload.max_output_tokens = testCase.prepopulatedMaxOutputTokens; + } + if (options?.maxTokens) { + payload.max_output_tokens = options.maxTokens; + } + options?.onPayload?.(payload, streamModel); + return {} as ReturnType; + }; + const streamFn = provider.wrapSimpleCompletionStreamFn({ + provider: "meta", + modelId: model.id, + model, + streamFn: baseStreamFn, + }); + if (!streamFn) { + throw new Error("Expected Meta Responses stream wrapper"); + } + + void streamFn( + model, + { messages: [] }, + { + ...(testCase.callerMaxTokens === undefined + ? {} + : { maxTokens: testCase.callerMaxTokens }), + onPayload: (payload) => { + capturedPayload = payload as Record; + }, + }, + ); + + expect(capturedPayload?.max_output_tokens).toBe(testCase.expectedMaxOutputTokens); + }, + ); + + it("builds the discounted muse-spark-1.2-contributor catalog entry", () => { + const providerConfig = buildMetaProvider(); + const model = providerConfig.models.find((m) => m.id === "muse-spark-1.2-contributor"); + if (!model) { + throw new Error("Expected muse-spark-1.2-contributor model"); + } + expect(model.contextWindow).toBe(1048576); + expect(model.maxTokens).toBe(131072); + expect(model.reasoning).toBe(true); + expect(model.input).toEqual(["text", "image"]); + expect(model.cost).toEqual({ + input: 0.1, + output: 0.2, + cacheRead: 0.002, + cacheWrite: 0, + }); + }); + + it("publishes a non-empty display name for every catalog model", () => { + const models = buildMetaProvider().models; + expect(models.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: "muse-spark-1.1", name: "Muse Spark 1.1" }, + { id: "muse-spark-1.2", name: "Muse Spark 1.2" }, + { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor" }, + ]); + expect(models.every((model) => model.name.trim().length > 0)).toBe(true); + }); + + it("advertises a high default thinking profile for every reasoning model", () => { const captured = capturePluginRegistration(plugin); const [provider] = captured.providers; if (!provider) { throw new Error("Expected Meta provider"); } const resolveThinkingProfile = requireThinkingProfileResolver(provider); - const profile = resolveThinkingProfile({ - provider: "meta", - modelId: "muse-spark-1.1", - } as never); - expect(profile?.defaultLevel).toBe("high"); - expect(profile?.levels.map((level) => level.id)).toEqual([ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", + const reasoningModels = buildMetaProvider().models.filter((model) => model.reasoning); + expect(reasoningModels.map((model) => model.id)).toEqual([ + "muse-spark-1.1", + "muse-spark-1.2", + "muse-spark-1.2-contributor", ]); + for (const model of reasoningModels) { + const profile = resolveThinkingProfile({ + provider: "meta", + modelId: model.id, + reasoning: model.reasoning, + }); + expect(profile?.defaultLevel).toBe("high"); + expect(profile?.levels.map((level) => level.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + expect( + resolveThinkingProfile({ + provider: "meta", + modelId: model.id, + })?.defaultLevel, + ).toBe("high"); + } + }); + + it("respects an explicit non-reasoning catalog fact", () => { + const captured = capturePluginRegistration(plugin); + const [provider] = captured.providers; + if (!provider) { + throw new Error("Expected Meta provider"); + } + const resolveThinkingProfile = requireThinkingProfileResolver(provider); + expect( + resolveThinkingProfile({ + provider: "meta", + modelId: "muse-spark-1.2", + reasoning: false, + }), + ).toBeUndefined(); }); }); diff --git a/extensions/meta/index.ts b/extensions/meta/index.ts index f03f1012eb2d..86011412bdf0 100644 --- a/extensions/meta/index.ts +++ b/extensions/meta/index.ts @@ -30,7 +30,8 @@ export default defineSingleProviderPluginEntry({ liveModelDiscovery: true, }, ...buildProviderReplayFamilyHooks({ family: "openai-compatible" }), + wrapSimpleCompletionStreamFn: wrapMetaProviderStream, wrapStreamFn: wrapMetaProviderStream, - resolveThinkingProfile: ({ modelId }) => resolveMetaThinkingProfile(modelId), + resolveThinkingProfile: resolveMetaThinkingProfile, }, }); diff --git a/extensions/meta/meta.live.test.ts b/extensions/meta/meta.live.test.ts index 08dff9a293ce..a0d25e03b5d8 100644 --- a/extensions/meta/meta.live.test.ts +++ b/extensions/meta/meta.live.test.ts @@ -1,21 +1,33 @@ -// Meta live tests prove muse-spark-1.1 auth and Responses API completion. -import { streamSimple, type Model } from "openclaw/plugin-sdk/llm"; +// Meta live tests prove muse-spark auth and Responses API completion. +import { createHash } from "node:crypto"; +import { streamSimple, type Context, type Model } from "openclaw/plugin-sdk/llm"; import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; import { describe, expect, it } from "vitest"; import { buildMetaProvider } from "./provider-catalog.js"; import { wrapMetaProviderStream } from "./stream.js"; const MODEL_API_KEY = process.env.MODEL_API_KEY?.trim() ?? ""; -const LIVE_MODEL_ID = "muse-spark-1.1"; +const STANDARD_LIVE_MODEL_IDS = ["muse-spark-1.1", "muse-spark-1.2"] as const; +const CONTRIBUTOR_LIVE_MODEL_ID = "muse-spark-1.2-contributor"; +// Validated 96x96 PNG: white background with a 20x72 green vertical center bar. +const GREEN_VERTICAL_CENTER_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAABlBMVEUAsUD///8TauZEAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAQklEQVRo3u3ZMQEAAAjDsOHfNAY44SI1EAFNHRcAAABYAzIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwHymoEAACXNba4HmGuMYsrAAAAAElFTkSuQmCC"; +const GREEN_VERTICAL_CENTER_DATA_URL = `data:image/png;base64,${GREEN_VERTICAL_CENTER_PNG_BASE64}`; +// This bounds the live request; it is not an advertised model limit. +const LIVE_TEST_MAX_OUTPUT_TOKENS = 4_000; const LIVE = isLiveTestEnabled(["META_LIVE_TEST", "MODEL_API_LIVE_TEST"]) && MODEL_API_KEY.length > 0; +// Contributor prompts and completions may train future Meta models, so sending live +// test content requires deliberate opt-in even though the fixture is synthetic. +const CONTRIBUTOR_LIVE = LIVE && process.env.OPENCLAW_LIVE_META_CONTRIBUTOR === "1"; const describeLive = LIVE ? describe : describe.skip; +const describeContributorLive = CONTRIBUTOR_LIVE ? describe : describe.skip; -function resolveLiveModel(): Model<"openai-responses"> { +function resolveLiveModel(modelId: string): Model<"openai-responses"> { const provider = buildMetaProvider(); - const catalogModel = provider.models?.find((entry) => entry.id === LIVE_MODEL_ID); + const catalogModel = provider.models?.find((entry) => entry.id === modelId); if (!catalogModel) { - throw new Error(`Meta catalog does not include ${LIVE_MODEL_ID}`); + throw new Error(`Meta catalog does not include ${modelId}`); } return { provider: "meta", @@ -25,8 +37,8 @@ function resolveLiveModel(): Model<"openai-responses"> { } as Model<"openai-responses">; } -function resolveLiveStreamFn() { - const model = resolveLiveModel(); +function resolveLiveStreamFn(modelId: string) { + const model = resolveLiveModel(modelId); return ( wrapMetaProviderStream({ provider: "meta", @@ -37,53 +49,231 @@ function resolveLiveStreamFn() { ); } +async function fetchLiveModelIds(): Promise { + const provider = buildMetaProvider(); + const response = await fetch(`${provider.baseUrl}/models`, { + headers: { Authorization: `Bearer ${MODEL_API_KEY}` }, + }); + expect(response.ok).toBe(true); + const body = (await response.json()) as { data?: Array<{ id: string }> }; + return (body.data ?? []).map((entry) => entry.id); +} + +function containsExpectedImagePayload(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(containsExpectedImagePayload); + } + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + if (record.type === "input_image" && record.image_url === GREEN_VERTICAL_CENTER_DATA_URL) { + return true; + } + return Object.values(record).some(containsExpectedImagePayload); +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function expectLiveCompletion(params: { + modelId: string; + context: Context; + expectedText: string | RegExp; + expectImagePayload?: boolean; + useZeroMaxTokens?: boolean; + emitRedactedCapProof?: boolean; + emitRedactedImageProof?: boolean; +}): Promise { + const { + modelId, + context, + expectedText, + expectImagePayload = false, + useZeroMaxTokens = false, + emitRedactedCapProof = false, + emitRedactedImageProof = false, + } = params; + const model = resolveLiveModel(modelId); + let capturedPayload: Record | undefined; + let responseStatus: number | undefined; + const stream = await resolveLiveStreamFn(modelId)(model, context, { + apiKey: MODEL_API_KEY, + maxTokens: useZeroMaxTokens ? 0 : LIVE_TEST_MAX_OUTPUT_TOKENS, + reasoning: "high", + onPayload: (payload) => { + capturedPayload = payload as Record; + }, + onResponse: (response) => { + responseStatus = response.status; + }, + }); + const result = await stream.result(); + + if (result.stopReason === "error") { + throw new Error(result.errorMessage || "Meta returned an error"); + } + + expect(capturedPayload?.store).toBe(false); + expect(capturedPayload?.include).toEqual(expect.arrayContaining(["reasoning.encrypted_content"])); + const reasoning = capturedPayload?.reasoning as { effort?: string } | undefined; + expect(reasoning?.effort).toBe("high"); + if (expectImagePayload) { + expect(containsExpectedImagePayload(capturedPayload)).toBe(true); + } + const assistantText = extractNonEmptyAssistantText(result.content).trim(); + if (typeof expectedText === "string") { + expect(assistantText).toBe(expectedText); + } else { + expect(assistantText).toMatch(expectedText); + } + if (useZeroMaxTokens) { + expect(model.maxTokens).toBe(131072); + expect(capturedPayload?.max_output_tokens).toBe(model.maxTokens); + expect(responseStatus).toBe(200); + expect(result.stopReason).toBe("stop"); + } + if (emitRedactedCapProof) { + console.info( + `[meta:catalog-cap:live] ${JSON.stringify({ + model: modelId, + callerMaxTokens: 0, + requestedMaxOutputTokens: model.maxTokens, + observedMaxOutputTokens: capturedPayload?.max_output_tokens, + httpStatus: responseStatus, + completion: "completed", + semanticMatch: true, + requestPayloadSha256: sha256(JSON.stringify(capturedPayload)), + responseTextSha256: sha256(assistantText), + claimScope: "request cap and short semantic completion only", + })}`, + ); + } + if (emitRedactedImageProof) { + expect(responseStatus).toBe(200); + expect(result.stopReason).toBe("stop"); + console.info( + `[meta:image:live] ${JSON.stringify({ + model: modelId, + imageFixtureSha256: sha256(Buffer.from(GREEN_VERTICAL_CENTER_PNG_BASE64, "base64")), + requestPayloadSha256: sha256(JSON.stringify(capturedPayload)), + httpStatus: responseStatus, + completion: "completed", + semanticMatch: true, + responseTextSha256: sha256(assistantText), + claimScope: "image request encoding and short semantic completion only", + })}`, + ); + } +} + +async function expectLiveTextCompletion(modelId: string): Promise { + await expectLiveCompletion({ + modelId, + context: { + messages: [ + { + role: "user", + content: "Reply with exactly: PATCH_OK", + timestamp: Date.now(), + }, + ], + }, + expectedText: /PATCH_OK/i, + }); +} + +async function expectLiveCatalogCapCompletion(modelId: string): Promise { + await expectLiveCompletion({ + modelId, + context: { + messages: [ + { + role: "user", + content: "Reply with exactly: CATALOG_CAP_OK", + timestamp: Date.now(), + }, + ], + }, + expectedText: /CATALOG_CAP_OK/i, + useZeroMaxTokens: true, + emitRedactedCapProof: true, + }); +} + +async function expectLiveImageCompletion(modelId: string): Promise { + await expectLiveCompletion({ + modelId, + context: { + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Inspect the image and identify the colored bar's basic color. Reply only with COLOR= in uppercase.", + }, + { + type: "image", + data: GREEN_VERTICAL_CENTER_PNG_BASE64, + mimeType: "image/png", + }, + ], + timestamp: Date.now(), + }, + ], + }, + expectedText: "COLOR=GREEN", + expectImagePayload: true, + emitRedactedImageProof: true, + }); +} + describeLive("meta plugin live", () => { - it("lists muse-spark-1.1 via the /models endpoint", async () => { - const provider = buildMetaProvider(); - const response = await fetch(`${provider.baseUrl}/models`, { - headers: { Authorization: `Bearer ${MODEL_API_KEY}` }, - }); - expect(response.ok).toBe(true); - const body = (await response.json()) as { data?: Array<{ id: string }> }; - const ids = (body.data ?? []).map((entry) => entry.id); - expect(ids).toContain(LIVE_MODEL_ID); + it("lists the standard catalog models via the /models endpoint", async () => { + const ids = await fetchLiveModelIds(); + for (const modelId of STANDARD_LIVE_MODEL_IDS) { + expect(ids).toContain(modelId); + } }, 30_000); - it("completes a muse-spark-1.1 Responses API turn with high reasoning effort", async () => { - const model = resolveLiveModel(); - let capturedPayload: Record | undefined; - const stream = await resolveLiveStreamFn()( - model, - { - messages: [ - { - role: "user", - content: "Reply with exactly: PATCH_OK", - timestamp: Date.now(), - }, - ], - }, - { - apiKey: MODEL_API_KEY, - maxTokens: 4000, // fix: high reasoning needs ~300 tokens - reasoning: "high", - onPayload: (payload) => { - capturedPayload = payload as Record; - }, - }, - ); - const result = await stream.result(); + it.each(STANDARD_LIVE_MODEL_IDS)( + "completes a %s Responses API turn with high reasoning effort", + async (modelId) => { + await expectLiveTextCompletion(modelId); + }, + 120_000, + ); - if (result.stopReason === "error") { - throw new Error(result.errorMessage || "Meta returned an error"); - } + it("uses the 131072 catalog output cap for muse-spark-1.2 when maxTokens is zero", async () => { + await expectLiveCatalogCapCompletion("muse-spark-1.2"); + }, 120_000); - expect(capturedPayload?.store).toBe(false); - expect(capturedPayload?.include).toEqual( - expect.arrayContaining(["reasoning.encrypted_content"]), - ); - const reasoning = capturedPayload?.reasoning as { effort?: string } | undefined; - expect(reasoning?.effort).toBe("high"); - expect(extractNonEmptyAssistantText(result.content)).toMatch(/PATCH_OK/i); + it.each(STANDARD_LIVE_MODEL_IDS)( + "accepts image input for %s", + async (modelId) => { + await expectLiveImageCompletion(modelId); + }, + 120_000, + ); +}); + +describeContributorLive("meta contributor plugin live", () => { + it("lists the contributor model via the /models endpoint", async () => { + const ids = await fetchLiveModelIds(); + expect(ids).toContain(CONTRIBUTOR_LIVE_MODEL_ID); + }, 30_000); + + it("completes a contributor Responses API turn with high reasoning effort", async () => { + await expectLiveTextCompletion(CONTRIBUTOR_LIVE_MODEL_ID); + }, 120_000); + + it("uses the 131072 catalog output cap for contributor when maxTokens is zero", async () => { + await expectLiveCatalogCapCompletion(CONTRIBUTOR_LIVE_MODEL_ID); + }, 120_000); + + it("accepts image input for the contributor model", async () => { + await expectLiveImageCompletion(CONTRIBUTOR_LIVE_MODEL_ID); }, 120_000); }); diff --git a/extensions/meta/openclaw.plugin.json b/extensions/meta/openclaw.plugin.json index bd4c7a18dd34..321d457ed3f9 100644 --- a/extensions/meta/openclaw.plugin.json +++ b/extensions/meta/openclaw.plugin.json @@ -64,6 +64,78 @@ "cacheRead": 0.15, "cacheWrite": 0 } + }, + { + "id": "muse-spark-1.2", + "name": "Muse Spark 1.2", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1048576, + "maxTokens": 131072, + "thinkingLevelMap": { + "off": "minimal", + "minimal": "minimal", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh" + }, + "compat": { + "supportsTools": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "cost": { + "input": 1.25, + "output": 4.25, + "cacheRead": 0.15, + "cacheWrite": 0 + } + }, + { + "id": "muse-spark-1.2-contributor", + "name": "Muse Spark 1.2 Contributor", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1048576, + "maxTokens": 131072, + "thinkingLevelMap": { + "off": "minimal", + "minimal": "minimal", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh" + }, + "compat": { + "supportsTools": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "cost": { + "input": 0.1, + "output": 0.2, + "cacheRead": 0.002, + "cacheWrite": 0 + } } ] } diff --git a/extensions/meta/stream.ts b/extensions/meta/stream.ts index 962ce41d06a8..0a7c60dbd2d7 100644 --- a/extensions/meta/stream.ts +++ b/extensions/meta/stream.ts @@ -18,10 +18,15 @@ function ensureMetaResponsesReplayFields(payloadObj: Record): v } function createMetaResponsesWrapper(baseStreamFn: StreamFn | undefined): StreamFn { - return createPayloadPatchStreamWrapper(baseStreamFn, ({ payload, model }) => { - if (model.provider !== "meta" || model.api !== "openai-responses") { + return createPayloadPatchStreamWrapper(baseStreamFn, ({ payload, model, options }) => { + if (model.provider !== "meta") { return; } + // Responses treats zero as an unset caller cap. Restore the catalog limit + // without changing provider-selected behavior when the caller omits the field. + if (options?.maxTokens === 0 && payload.max_output_tokens === undefined) { + payload.max_output_tokens = model.maxTokens; + } if (!model.reasoning) { return; } @@ -30,7 +35,7 @@ function createMetaResponsesWrapper(baseStreamFn: StreamFn | undefined): StreamF } export function wrapMetaProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined { - if (ctx.provider !== "meta" || ctx.model?.api !== "openai-responses") { + if (ctx.provider !== "meta" || (ctx.sourceApi ?? ctx.model?.api) !== "openai-responses") { return undefined; } return createMetaResponsesWrapper(ctx.streamFn); diff --git a/extensions/meta/thinking.ts b/extensions/meta/thinking.ts index 806ec70a711b..70cb041c168b 100644 --- a/extensions/meta/thinking.ts +++ b/extensions/meta/thinking.ts @@ -1,11 +1,13 @@ // Meta plugin module implements thinking behavior. -import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry"; +import type { + ProviderDefaultThinkingPolicyContext, + ProviderThinkingProfile, +} from "openclaw/plugin-sdk/plugin-entry"; +import { META_MODEL_CATALOG } from "./models.js"; -const META_REASONING_MODEL_ID = "muse-spark-1.1"; - -function isMetaReasoningModelId(modelId: string): boolean { - return modelId.toLowerCase() === META_REASONING_MODEL_ID; -} +const META_REASONING_MODEL_IDS = new Set( + META_MODEL_CATALOG.filter((model) => model.reasoning).map((model) => model.id.toLowerCase()), +); const META_THINKING_LEVEL_IDS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -14,6 +16,12 @@ const META_THINKING_PROFILE = { defaultLevel: "high", } satisfies ProviderThinkingProfile; -export function resolveMetaThinkingProfile(modelId: string): ProviderThinkingProfile | undefined { - return isMetaReasoningModelId(modelId) ? META_THINKING_PROFILE : undefined; +export function resolveMetaThinkingProfile( + context: ProviderDefaultThinkingPolicyContext, +): ProviderThinkingProfile | undefined { + // Some control-plane callers have no runtime catalog. Fall back to the + // process-stable manifest so those paths retain the same model policy. + const reasoning = + context.reasoning ?? META_REASONING_MODEL_IDS.has(context.modelId.toLowerCase()); + return reasoning ? META_THINKING_PROFILE : undefined; } diff --git a/packages/ai/src/host.ts b/packages/ai/src/host.ts index f7ee85619352..0f5aa904c7bd 100644 --- a/packages/ai/src/host.ts +++ b/packages/ai/src/host.ts @@ -36,6 +36,8 @@ export interface AiProviderStreamHookContext { provider: string; modelId: string; model: Model; + /** Wire-format API before simple completion projects an internal transport alias. */ + sourceApi?: Api; } /** Narrow plugin-runtime port used by package-owned transports. */ diff --git a/packages/ai/src/transports/simple-completion-transport.test.ts b/packages/ai/src/transports/simple-completion-transport.test.ts index b1c781f0dd2d..f91ca22cfbc6 100644 --- a/packages/ai/src/transports/simple-completion-transport.test.ts +++ b/packages/ai/src/transports/simple-completion-transport.test.ts @@ -1,5 +1,5 @@ import { createAssistantMessageEventStream } from "@openclaw/llm-core"; -import type { Api, Model, StreamFn } from "@openclaw/llm-core"; +import type { Api, AssistantMessageEventStreamContract, Model, StreamFn } from "@openclaw/llm-core"; // Simple completion transport tests cover provider-specific stream alias // selection before the generic completion helper invokes the LLM layer. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,7 +17,7 @@ const prepareTransportAwareSimpleModel = vi.fn(); const resolveTransportAwareSimpleApi = vi.fn(); const prepareGoogleSimpleCompletionModel = vi.fn((_registry: unknown, model: unknown) => model); const inheritManagedTransport = vi.fn((_source: Model, target: Model) => target); -const pluginStreamFn = vi.fn(() => "plugin-stream-result" as never); +const pluginStreamFn = vi.fn(() => createAssistantMessageEventStream()); const TEST_SECRET = "ollama-provider-secret"; const TEST_SECRET_SENTINEL = "test-secret-sentinel"; const initialHost = getAiTransportHost(); @@ -34,6 +34,21 @@ let prepareModelForSimpleCompletionImpl: typeof import("./simple-completion-tran let apiRegistry: ApiRegistry; const SIMPLE_COMPLETION_SOURCE_ID = "test:simple-completion-transport"; +function requireSynchronousStream( + stream: ReturnType, +): AssistantMessageEventStreamContract { + if ( + stream instanceof Promise || + !("push" in stream) || + !("end" in stream) || + typeof stream.push !== "function" || + typeof stream.end !== "function" + ) { + throw new Error("Expected synchronous assistant event stream"); + } + return stream as AssistantMessageEventStreamContract; +} + function prepareModelForSimpleCompletion( params: Omit< Parameters< @@ -288,6 +303,74 @@ describe("prepareModelForSimpleCompletion", () => { expect(pluginStreamFn).toHaveBeenCalledOnce(); }); + it("carries the source API into wrappers after provider stream projection", () => { + const builtInStream = vi.fn(() => createAssistantMessageEventStream()); + apiRegistry.registerApiProvider( + { + api: "openai-responses", + stream: builtInStream, + streamSimple: builtInStream, + }, + SIMPLE_COMPLETION_SOURCE_ID, + ); + const model: Model<"openai-responses"> = { + id: "muse-spark-1.2", + name: "Muse Spark 1.2", + api: "openai-responses", + provider: "meta", + baseUrl: "https://api.meta.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 131_072, + }; + resolveProviderStreamFn.mockReturnValueOnce(undefined); + createTransportAwareStreamFnForModel.mockReturnValueOnce(pluginStreamFn); + wrapProviderSimpleCompletionStreamFn.mockImplementationOnce(({ context }) => context.streamFn); + ensureCustomApiRegistered.mockImplementation( + (registry: ApiRegistry, api: Api, streamFn: StreamFn) => { + if (registry.getApiProvider(api)) { + return false; + } + registry.registerApiProvider({ + api, + stream: (runtimeModel, context, options) => + requireSynchronousStream(streamFn(runtimeModel, context, options)), + streamSimple: (runtimeModel, context, options) => + requireSynchronousStream(streamFn(runtimeModel, context, options)), + }); + return true; + }, + ); + + const result = prepareModelForSimpleCompletion({ model }); + const dispatchApi = + "openclaw-provider-stream:meta:muse-spark-1.2:openai-responses:https%3A%2F%2Fapi.meta.ai%2Fv1"; + + expect(wrapProviderSimpleCompletionStreamFn).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + model: expect.objectContaining({ api: dispatchApi }), + sourceApi: "openai-responses", + }), + }), + ); + expect(result.api).toMatch(/^openclaw-provider-simple:/); + const finalStreamFn = apiRegistry.getApiProvider(result.api)?.streamSimple; + if (!finalStreamFn) { + throw new Error("Expected projected simple completion stream"); + } + + void finalStreamFn(result, { messages: [] }, {}); + + expect(pluginStreamFn).toHaveBeenCalledWith( + expect.objectContaining({ api: dispatchApi }), + { messages: [] }, + {}, + ); + }); + it("uses a custom api alias for Anthropic Vertex simple completions", () => { const model: Model<"anthropic-messages"> = { id: "claude-sonnet", diff --git a/packages/ai/src/transports/simple-completion-transport.ts b/packages/ai/src/transports/simple-completion-transport.ts index c61b11ad08cb..bf8c3fc11ced 100644 --- a/packages/ai/src/transports/simple-completion-transport.ts +++ b/packages/ai/src/transports/simple-completion-transport.ts @@ -95,6 +95,7 @@ function applyProviderSimpleCompletionWrapper( registry: ApiRegistry, model: Model, cfg?: unknown, + hookSourceApi: Api = model.api, ): Model { if (model.api.startsWith(PROVIDER_SIMPLE_COMPLETION_API_PREFIX)) { return model; @@ -104,9 +105,9 @@ function applyProviderSimpleCompletionWrapper( return model; } - const sourceApi = model.api; + const dispatchApi = model.api; const sourceStreamFn: StreamFn = (runtimeModel, context, options) => - sourceProvider.streamSimple(projectModel(runtimeModel, { api: sourceApi }), context, options); + sourceProvider.streamSimple(projectModel(runtimeModel, { api: dispatchApi }), context, options); const streamFn = getAiTransportHost().plugin.wrapSimpleCompletionStream({ provider: model.provider, config: cfg, @@ -115,6 +116,7 @@ function applyProviderSimpleCompletionWrapper( provider: model.provider, modelId: model.id, model, + sourceApi: hookSourceApi, streamFn: sourceStreamFn, }, }); @@ -226,19 +228,19 @@ export function prepareModelForSimpleCompletion(params: { const { apiRegistry, model, cfg } = params; const providerStreamModel = prepareProviderStreamModel({ model, cfg, apiRegistry }); if (providerStreamModel) { - return applyProviderSimpleCompletionWrapper(apiRegistry, providerStreamModel, cfg); + return applyProviderSimpleCompletionWrapper(apiRegistry, providerStreamModel, cfg, model.api); } const codexTransportModel = prepareCodexSimpleTransportModel(apiRegistry, model, cfg); if (codexTransportModel) { - return applyProviderSimpleCompletionWrapper(apiRegistry, codexTransportModel, cfg); + return applyProviderSimpleCompletionWrapper(apiRegistry, codexTransportModel, cfg, model.api); } const transportAwareModel = prepareTransportAwareSimpleModel(model, { cfg }); if (transportAwareModel !== model) { const streamFn = buildTransportAwareSimpleStreamFn(model, { cfg }); if (streamFn && registerCustomApi(apiRegistry, transportAwareModel.api, streamFn)) { - return applyProviderSimpleCompletionWrapper(apiRegistry, transportAwareModel, cfg); + return applyProviderSimpleCompletionWrapper(apiRegistry, transportAwareModel, cfg, model.api); } } @@ -247,6 +249,7 @@ export function prepareModelForSimpleCompletion(params: { apiRegistry, getAiTransportHost().prepareGoogleSimpleCompletionModel(apiRegistry, model), cfg, + model.api, ); } @@ -256,7 +259,7 @@ export function prepareModelForSimpleCompletion(params: { const streamFn = host.plugin.createAnthropicVertexStream(model); if (registerCustomApi(apiRegistry, api, streamFn)) { const transportModel = projectModel(model, { api }); - return applyProviderSimpleCompletionWrapper(apiRegistry, transportModel, cfg); + return applyProviderSimpleCompletionWrapper(apiRegistry, transportModel, cfg, model.api); } } diff --git a/src/plugins/provider-transport.types.ts b/src/plugins/provider-transport.types.ts index d2ddca826428..47e96887d685 100644 --- a/src/plugins/provider-transport.types.ts +++ b/src/plugins/provider-transport.types.ts @@ -28,6 +28,8 @@ export type ProviderCreateStreamFnContext = { */ export type ProviderWrapStreamFnContext = ProviderPrepareExtraParamsContext & { model?: ProviderRuntimeModel; + /** Wire-format API before simple completion projects an internal transport alias. */ + sourceApi?: ProviderRuntimeModel["api"]; streamFn?: StreamFn; };