Compare commits

..

27 Commits

Author SHA1 Message Date
Vincent Koc
2b644ace7c fix(infra): extract exec approvals allowlist types 2026-04-09 02:05:11 +01:00
Vincent Koc
81c6e9ad8f fix(commands): split auth choice apply types 2026-04-09 02:03:04 +01:00
Vincent Koc
02ee512367 fix(config): stop owner-display barrel cycles 2026-04-09 02:01:27 +01:00
Vincent Koc
27bff10585 fix(logging): break console/logger type cycle 2026-04-09 01:56:18 +01:00
Peter Steinberger
d0c21cf541 test: isolate model auth module state 2026-04-09 01:44:31 +01:00
Peter Steinberger
f0ea5bf393 plugins: add lightweight anthropic vertex discovery 2026-04-09 01:43:40 +01:00
Peter Steinberger
67a030dfe8 test: isolate onboard skills status mock 2026-04-09 01:40:11 +01:00
Peter Steinberger
f0644d7613 test: replace models-config matrix with narrow coverage 2026-04-09 01:39:43 +01:00
Peter Steinberger
3ae10b02f2 test: isolate agentic suite smoke tests 2026-04-09 01:38:24 +01:00
Peter Steinberger
a9f831e065 test: make shared-token reload deterministic 2026-04-09 01:38:16 +01:00
Peter Steinberger
6688779d36 fix: drop raw gateway chat control replies 2026-04-09 01:38:08 +01:00
Peter Steinberger
cca9e5b914 test: cap broad live model sweeps 2026-04-09 01:37:55 +01:00
Peter Steinberger
6e200f4077 fix: update command-status SDK baseline (#63174) (thanks @hxy91819) 2026-04-09 01:35:15 +01:00
Mason Huang
e892518b63 tests: document config mock choice for eager warmup 2026-04-09 01:35:15 +01:00
Mason Huang
edc6c13f1f plugin-sdk: drop investigative weixin repro harness 2026-04-09 01:35:15 +01:00
Mason Huang
ba636d1206 plugin-sdk: keep command status compatibility path light 2026-04-09 01:35:15 +01:00
Mason Huang
aa15de8fdc plugin-sdk: split command status surface 2026-04-09 01:35:15 +01:00
Peter Steinberger
691e2aa856 test: move copilot models-json injection coverage to plan tests 2026-04-09 01:29:52 +01:00
Peter Steinberger
a8c47db668 fix: repair Windows dev-channel updater 2026-04-09 01:26:28 +01:00
Peter Steinberger
be46d0ddc6 test: update character eval public panel 2026-04-09 01:25:59 +01:00
Peter Steinberger
0766f0b422 test: update modelstudio catalog contract sentinel 2026-04-09 01:20:34 +01:00
Vignesh Natarajan
2484064c48 chore(lint): clear extension lint regressions and add #63416 changelog 2026-04-08 17:17:29 -07:00
Peter Steinberger
1f3171ac91 test: keep cli-provider agent command tests off external auth overlays 2026-04-09 01:11:40 +01:00
Peter Steinberger
acdee39fa4 ci: stabilize macOS and transcript policy tests 2026-04-09 01:10:40 +01:00
Sally O'Malley
5f8de8c3f4 fix openrouter model picker refs (#63416)
* fix openrouter model picker refs

Signed-off-by: sallyom <somalley@redhat.com>

* test(ui): cover openrouter slash-id /model resolution

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Vignesh Natarajan <vignesh.natarajan92@gmail.com>
2026-04-08 20:10:20 -04:00
Peter Steinberger
b706301b44 test: keep agent command tests off external auth overlays 2026-04-09 01:08:22 +01:00
Peter Steinberger
39cc6b7dc7 fix: stabilize character eval and Qwen model routing 2026-04-09 01:04:09 +01:00
131 changed files with 2175 additions and 1157 deletions

View File

@@ -57,31 +57,28 @@ Use `qa character-eval` for style/persona/vibe checks across multiple live model
pnpm openclaw qa character-eval \
--model openai/gpt-5.4,thinking=xhigh \
--model openai/gpt-5.2,thinking=xhigh \
--model openai/gpt-5,thinking=xhigh \
--model anthropic/claude-opus-4-6,thinking=high \
--model anthropic/claude-sonnet-4-6,thinking=high \
--model minimax/MiniMax-M2.7,thinking=high \
--model zai/glm-5.1,thinking=high \
--model moonshot/kimi-k2.5,thinking=high \
--model qwen/qwen3.6-plus,thinking=high \
--model xiaomi/mimo-v2-pro,thinking=high \
--model google/gemini-3.1-pro-preview,thinking=high \
--model codex-cli/<codex-model>,thinking=high \
--judge-model openai/gpt-5.4,thinking=xhigh,fast \
--judge-model anthropic/claude-opus-4-6,thinking=high \
--concurrency 8 \
--judge-concurrency 8 \
--concurrency 16 \
--judge-concurrency 16 \
--output-dir .artifacts/qa-e2e/character-eval-<tag>
```
- Runs local QA gateway child processes, not Docker.
- Preferred model spec syntax is `provider/model,thinking=<level>[,fast|,no-fast|,fast=<bool>]` for both `--model` and `--judge-model`.
- Do not add new examples with separate `--model-thinking`; keep that flag as legacy compatibility only.
- Defaults to candidate models `openai/gpt-5.4`, `openai/gpt-5.2`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-6`, `minimax/MiniMax-M2.7`, `zai/glm-5.1`, `moonshot/kimi-k2.5`, `qwen/qwen3.6-plus`, `xiaomi/mimo-v2-pro`, and `google/gemini-3.1-pro-preview` when no `--model` is passed.
- Defaults to candidate models `openai/gpt-5.4`, `openai/gpt-5.2`, `openai/gpt-5`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-6`, `zai/glm-5.1`, `moonshot/kimi-k2.5`, and `google/gemini-3.1-pro-preview` when no `--model` is passed.
- Candidate thinking defaults to `high`, with `xhigh` for OpenAI models that support it. Prefer inline `--model provider/model,thinking=<level>`; `--thinking <level>` and `--model-thinking <provider/model=level>` remain compatibility shims.
- OpenAI candidate refs default to fast mode so priority processing is used where supported. Use inline `,fast`, `,no-fast`, or `,fast=false` for one model; use `--fast` only to force fast mode for every candidate.
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
- Candidate and judge concurrency default to 8. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Scenario source should stay markdown-driven under `qa/scenarios/`.
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.

View File

@@ -1095,7 +1095,9 @@ jobs:
set -euo pipefail
case "$TASK" in
test)
pnpm test
# Linux owns the full repo test suite. Keep macOS CI focused on
# launchd/Homebrew/runtime path coverage and the process-group wrapper.
pnpm test:macos:ci
;;
*)
echo "Unsupported macOS node task: $TASK" >&2

View File

@@ -46,6 +46,8 @@ Docs: https://docs.openclaw.ai
- Windows/update: add heap headroom to Windows `pnpm build` steps during dev updates so update preflight builds stop failing on low default Node memory.
- Plugin SDK: export the channel plugin base and web-search config contract through the public package so plugins can use them without private imports.
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
## 2026.4.8

View File

@@ -1,2 +1,2 @@
048efa89df3126388efa43e2d46508b755edc4a88c5cbeb3718273ae2b1758a6 plugin-sdk-api-baseline.json
3b0f8fe32f559266b805a1077820365e91bb8bfac519ae5d54ecfe6d6415fcc1 plugin-sdk-api-baseline.jsonl
d8ab30f2e73642c89168acd2e177a4d49568bfc3d64fdfcb37b72206295d4896 plugin-sdk-api-baseline.json
94419b7f3bfa5d0fe8d1ec97825f05b8da1617c8406b7cdc37a72cd559975374 plugin-sdk-api-baseline.jsonl

View File

@@ -89,18 +89,17 @@ refs and write a judged Markdown report:
pnpm openclaw qa character-eval \
--model openai/gpt-5.4,thinking=xhigh \
--model openai/gpt-5.2,thinking=xhigh \
--model openai/gpt-5,thinking=xhigh \
--model anthropic/claude-opus-4-6,thinking=high \
--model anthropic/claude-sonnet-4-6,thinking=high \
--model minimax/MiniMax-M2.7,thinking=high \
--model zai/glm-5.1,thinking=high \
--model moonshot/kimi-k2.5,thinking=high \
--model qwen/qwen3.6-plus,thinking=high \
--model xiaomi/mimo-v2-pro,thinking=high \
--model google/gemini-3.1-pro-preview,thinking=high \
--judge-model openai/gpt-5.4,thinking=xhigh,fast \
--judge-model anthropic/claude-opus-4-6,thinking=high \
--concurrency 8 \
--judge-concurrency 8
--blind-judge-models \
--concurrency 16 \
--judge-concurrency 16
```
The command runs local QA gateway child processes, not Docker. Character eval
@@ -109,6 +108,10 @@ such as chat, workspace help, and small file tasks. The candidate model should
not be told that it is being evaluated. The command preserves each full
transcript, records basic run stats, then asks the judge models in fast mode with
`xhigh` reasoning to rank the runs by naturalness, vibe, and humor.
Use `--blind-judge-models` when comparing providers: the judge prompt still gets
every transcript and run status, but candidate refs are replaced with neutral
labels such as `candidate-01`; the report maps rankings back to real refs after
parsing.
Candidate runs default to `high` thinking, with `xhigh` for OpenAI models that
support it. Override a specific candidate inline with
`--model provider/model,thinking=<level>`. `--thinking <level>` still sets a
@@ -120,14 +123,14 @@ single candidate or judge needs an override. Pass `--fast` only when you want to
force fast mode on for every candidate model. Candidate and judge durations are
recorded in the report for benchmark analysis, but judge prompts explicitly say
not to rank by speed.
Candidate and judge model runs both default to concurrency 8. Lower
Candidate and judge model runs both default to concurrency 16. Lower
`--concurrency` or `--judge-concurrency` when provider limits or local gateway
pressure make a run too noisy.
When no candidate `--model` is passed, the character eval defaults to
`openai/gpt-5.4`, `openai/gpt-5.2`, `anthropic/claude-opus-4-6`,
`anthropic/claude-sonnet-4-6`, `minimax/MiniMax-M2.7`, `zai/glm-5.1`,
`moonshot/kimi-k2.5`, `qwen/qwen3.6-plus`, `xiaomi/mimo-v2-pro`, and
`google/gemini-3.1-pro-preview`.
`openai/gpt-5.4`, `openai/gpt-5.2`, `openai/gpt-5`, `anthropic/claude-opus-4-6`,
`anthropic/claude-sonnet-4-6`, `zai/glm-5.1`,
`moonshot/kimi-k2.5`, and
`google/gemini-3.1-pro-preview` when no `--model` is passed.
When no `--judge-model` is passed, the judges default to
`openai/gpt-5.4,thinking=xhigh,fast` and
`anthropic/claude-opus-4-6,thinking=high`.

View File

@@ -203,6 +203,7 @@ Live tests are split into two layers so we can isolate failures:
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.7, Grok 4)
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.4,anthropic/claude-opus-4-6,..."` (comma allowlist)
- Modern/all sweeps default to a curated high-signal cap; set `OPENCLAW_LIVE_MAX_MODELS=0` for an exhaustive modern sweep or a positive number for a smaller cap.
- How to select providers:
- `OPENCLAW_LIVE_PROVIDERS="google,google-antigravity,google-gemini-cli"` (comma allowlist)
- Where keys come from:
@@ -234,6 +235,7 @@ Live tests are split into two layers so we can isolate failures:
- Default: modern allowlist (Opus/Sonnet 4.6+, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.7, Grok 4)
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
- Modern/all gateway sweeps default to a curated high-signal cap; set `OPENCLAW_LIVE_GATEWAY_MAX_MODELS=0` for an exhaustive modern sweep or a positive number for a smaller cap.
- How to select providers (avoid “OpenRouter everything”):
- `OPENCLAW_LIVE_GATEWAY_PROVIDERS="google,google-antigravity,google-gemini-cli,openai,anthropic,zai,minimax"` (comma allowlist)
- Tool + image probes are always on in this live test:

View File

@@ -245,6 +245,7 @@ Current bundled provider examples:
| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` |
| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` |
| `plugin-sdk/command-auth` | Command gating and command-surface helpers | `resolveControlCommandGate`, sender-authorization helpers, command registry helpers |
| `plugin-sdk/command-status` | Command status/help renderers | `buildCommandsMessage`, `buildCommandsMessagePaginated`, `buildHelpMessage` |
| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers |
| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities |
| `plugin-sdk/webhook-request-guards` | Webhook body guard helpers | Request body read/limit helpers |

View File

@@ -149,6 +149,7 @@ explicitly promotes one as public.
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/command-auth` | `resolveControlCommandGate`, command registry helpers, sender-authorization helpers |
| `plugin-sdk/command-status` | Command/help message builders such as `buildCommandsMessagePaginated` and `buildHelpMessage` |
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |

View File

@@ -88,7 +88,9 @@ requiring the built-in `qwen` provider id specifically.
## Built-in catalog
OpenClaw currently ships this bundled Qwen catalog:
OpenClaw currently ships this bundled Qwen catalog. The configured catalog is
endpoint-aware: Coding Plan configs omit models that are only known to work on
the Standard endpoint.
| Model ref | Input | Context | Notes |
| --------------------------- | ----------- | --------- | -------------------------------------------------- |

View File

@@ -2,6 +2,7 @@
"id": "anthropic-vertex",
"enabledByDefault": true,
"providers": ["anthropic-vertex"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
describe("anthropic-vertex provider discovery entry", () => {
it("imports without loading the full plugin entry", async () => {
const module = await import("./provider-discovery.js");
expect(module.default.id).toBe("anthropic-vertex");
expect(module.default.catalog.order).toBe("simple");
});
});

View File

@@ -0,0 +1,215 @@
import { readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
const PROVIDER_ID = "anthropic-vertex";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const GCLOUD_DEFAULT_ADC_PATH = join(
homedir(),
".config",
"gcloud",
"application_default_credentials.json",
);
type AnthropicVertexProviderPlugin = {
id: string;
label: string;
docsPath: string;
auth: [];
catalog: {
order: "simple";
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runAnthropicVertexCatalog>;
};
resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined;
};
type AdcProjectFile = {
project_id?: unknown;
quota_project_id?: unknown;
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return normalizeOptionalString(value)?.toLowerCase() ?? "";
}
function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string {
const region =
normalizeOptionalString(env.GOOGLE_CLOUD_LOCATION) ||
normalizeOptionalString(env.CLOUD_ML_REGION);
return region && ANTHROPIC_VERTEX_REGION_RE.test(region)
? region
: ANTHROPIC_VERTEX_DEFAULT_REGION;
}
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
const explicitMetadataOptIn = normalizeOptionalString(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
return (
explicitMetadataOptIn === "1" ||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
);
}
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
return platform() === "win32"
? join(
env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
"gcloud",
"application_default_credentials.json",
)
: GCLOUD_DEFAULT_ADC_PATH;
}
function resolveAnthropicVertexAdcCredentialsPathCandidate(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicit = normalizeOptionalString(env.GOOGLE_APPLICATION_CREDENTIALS);
if (explicit) {
return explicit;
}
if (env !== process.env) {
return undefined;
}
return resolveAnthropicVertexDefaultAdcPath(env);
}
function readAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): AdcProjectFile | null {
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
if (!credentialsPath) {
return null;
}
try {
return JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
} catch {
return null;
}
}
function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
return hasAnthropicVertexMetadataServerAdc(env) || readAnthropicVertexAdc(env) !== null;
}
function resolveAnthropicVertexConfigApiKey(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return hasAnthropicVertexAvailableAuth(env) ? GCP_VERTEX_CREDENTIALS_MARKER : undefined;
}
function buildAnthropicVertexModel(params: {
id: string;
name: string;
reasoning: boolean;
input: ModelDefinitionConfig["input"];
cost: ModelDefinitionConfig["cost"];
maxTokens: number;
}): ModelDefinitionConfig {
return {
id: params.id,
name: params.name,
reasoning: params.reasoning,
input: params.input,
cost: params.cost,
contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens,
};
}
function buildAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }): ModelProviderConfig {
const region = resolveAnthropicVertexRegion(params?.env);
const baseUrl =
normalizeLowercaseStringOrEmpty(region) === "global"
? "https://aiplatform.googleapis.com"
: `https://${region}-aiplatform.googleapis.com`;
return {
baseUrl,
api: "anthropic-messages",
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
models: [
buildAnthropicVertexModel({
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
maxTokens: 128000,
}),
buildAnthropicVertexModel({
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
maxTokens: 128000,
}),
],
};
}
function mergeImplicitAnthropicVertexProvider(params: {
existing?: ModelProviderConfig;
implicit: ModelProviderConfig;
}) {
const { existing, implicit } = params;
if (!existing) {
return implicit;
}
return {
...implicit,
...existing,
models:
Array.isArray(existing.models) && existing.models.length > 0
? existing.models
: implicit.models,
};
}
function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
const env = params?.env ?? process.env;
if (!hasAnthropicVertexAvailableAuth(env)) {
return null;
}
return buildAnthropicVertexProvider({ env });
}
async function runAnthropicVertexCatalog(ctx: ProviderCatalogContext) {
const implicit = resolveImplicitAnthropicVertexProvider({
env: ctx.env,
});
if (!implicit) {
return null;
}
return {
provider: mergeImplicitAnthropicVertexProvider({
existing: ctx.config.models?.providers?.[PROVIDER_ID],
implicit,
}),
};
}
export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = {
id: PROVIDER_ID,
label: "Anthropic Vertex",
docsPath: "/providers/models",
auth: [],
catalog: {
order: "simple",
run: runAnthropicVertexCatalog,
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
};
export default anthropicVertexProviderDiscovery;

View File

@@ -33,7 +33,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "bluebubbles");

View File

@@ -84,7 +84,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "discord");

View File

@@ -80,7 +80,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "feishu");

View File

@@ -138,7 +138,7 @@ function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void,
}
const key = deprecated
.map((v) => normalizeLowercaseStringOrEmpty(v))
.toSorted()
.toSorted((a, b) => a.localeCompare(b))
.join(",");
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
return;
@@ -161,7 +161,7 @@ function warnMutableGroupKeysConfigured(
}
const warningKey = mutableKeys
.map((key) => normalizeLowercaseStringOrEmpty(key))
.toSorted()
.toSorted((a, b) => a.localeCompare(b))
.join(",");
if (warnedMutableGroupKeys.has(warningKey)) {
return;

View File

@@ -63,7 +63,7 @@ function resolveSecretInputRef(params: {
function collectGoogleChatAccountAssignment(params: {
target: GoogleChatAccountLike;
path: string;
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
@@ -107,7 +107,7 @@ function collectGoogleChatAccountAssignment(params: {
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "googlechat");

View File

@@ -59,7 +59,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "irc");

View File

@@ -60,7 +60,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "matrix");

View File

@@ -33,7 +33,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "mattermost");

View File

@@ -22,7 +22,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const msteams = getChannelRecord(params.config, "msteams");

View File

@@ -57,7 +57,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "nextcloud-talk");

View File

@@ -109,6 +109,7 @@ describe("runQaCharacterEval", () => {
const report = await fs.readFile(result.reportPath, "utf8");
expect(report).toContain("Execution: local QA gateway child processes, not Docker");
expect(report).toContain("Judges: openai/gpt-5.4");
expect(report).toContain("Judge model labels: visible");
expect(report).toContain("## Judge Rankings");
expect(report).toContain("### openai/gpt-5.4");
expect(report).toContain("reply from openai/gpt-5.4");
@@ -120,6 +121,57 @@ describe("runQaCharacterEval", () => {
expect(report).not.toContain("Judge Raw Reply");
});
it("can hide candidate model refs from judge prompts and map rankings back", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript: "USER Alice: hi\n\nASSISTANT openclaw: anonymous reply",
}),
);
const runJudge = vi.fn(async (params: CharacterRunJudgeParams) => {
expect(params.prompt).toContain("## CANDIDATE candidate-01");
expect(params.prompt).toContain("## CANDIDATE candidate-02");
expect(params.prompt).not.toContain("openai/gpt-5.4");
expect(params.prompt).not.toContain("codex-cli/test-model");
return JSON.stringify({
rankings: [
{
model: "candidate-02",
rank: 1,
score: 9.1,
summary: "Better vibes.",
},
{
model: "candidate-01",
rank: 2,
score: 7.4,
summary: "Solid.",
},
],
});
});
const result = await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: ["openai/gpt-5.4", "codex-cli/test-model"],
judgeModels: ["openai/gpt-5.4"],
judgeBlindModels: true,
runSuite,
runJudge,
});
expect(result.judgments[0]?.blindModels).toBe(true);
expect(result.judgments[0]?.rankings.map((ranking) => ranking.model)).toEqual([
"codex-cli/test-model",
"openai/gpt-5.4",
]);
const report = await fs.readFile(result.reportPath, "utf8");
expect(report).toContain("Judge model labels: blind");
expect(report).toContain("1. codex-cli/test-model - 9.1 - Better vibes.");
});
it("defaults to the character eval model panel when no models are provided", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
@@ -133,14 +185,12 @@ describe("runQaCharacterEval", () => {
rankings: [
{ model: "openai/gpt-5.4", rank: 1, score: 8, summary: "ok" },
{ model: "openai/gpt-5.2", rank: 2, score: 7.5, summary: "ok" },
{ model: "anthropic/claude-opus-4-6", rank: 3, score: 7, summary: "ok" },
{ model: "anthropic/claude-sonnet-4-6", rank: 4, score: 6.8, summary: "ok" },
{ model: "minimax/MiniMax-M2.7", rank: 5, score: 6.5, summary: "ok" },
{ model: "openai/gpt-5", rank: 3, score: 7.2, summary: "ok" },
{ model: "anthropic/claude-opus-4-6", rank: 4, score: 7, summary: "ok" },
{ model: "anthropic/claude-sonnet-4-6", rank: 5, score: 6.8, summary: "ok" },
{ model: "zai/glm-5.1", rank: 6, score: 6.3, summary: "ok" },
{ model: "moonshot/kimi-k2.5", rank: 7, score: 6.2, summary: "ok" },
{ model: "qwen/qwen3.6-plus", rank: 8, score: 6.1, summary: "ok" },
{ model: "xiaomi/mimo-v2-pro", rank: 9, score: 6, summary: "ok" },
{ model: "google/gemini-3.1-pro-preview", rank: 10, score: 5.9, summary: "ok" },
{ model: "google/gemini-3.1-pro-preview", rank: 8, score: 6, summary: "ok" },
],
}),
);
@@ -153,25 +203,21 @@ describe("runQaCharacterEval", () => {
runJudge,
});
expect(runSuite).toHaveBeenCalledTimes(10);
expect(runSuite).toHaveBeenCalledTimes(8);
expect(runSuite.mock.calls.map(([params]) => params.primaryModel)).toEqual([
"openai/gpt-5.4",
"openai/gpt-5.2",
"openai/gpt-5",
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"minimax/MiniMax-M2.7",
"zai/glm-5.1",
"moonshot/kimi-k2.5",
"qwen/qwen3.6-plus",
"xiaomi/mimo-v2-pro",
"google/gemini-3.1-pro-preview",
]);
expect(runSuite.mock.calls.map(([params]) => params.thinkingDefault)).toEqual([
"xhigh",
"xhigh",
"high",
"high",
"high",
"xhigh",
"high",
"high",
"high",
@@ -181,9 +227,7 @@ describe("runQaCharacterEval", () => {
expect(runSuite.mock.calls.map(([params]) => params.fastMode)).toEqual([
true,
true,
false,
false,
false,
true,
false,
false,
false,
@@ -244,7 +288,7 @@ describe("runQaCharacterEval", () => {
]);
});
it("defaults candidate and judge concurrency to eight", async () => {
it("defaults candidate and judge concurrency to sixteen", async () => {
let activeRuns = 0;
let maxActiveRuns = 0;
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) => {
@@ -266,7 +310,7 @@ describe("runQaCharacterEval", () => {
await new Promise((resolve) => setTimeout(resolve, 10));
activeJudges -= 1;
return JSON.stringify({
rankings: Array.from({ length: 10 }, (_, index) => ({
rankings: Array.from({ length: 20 }, (_, index) => ({
model: `provider/model-${index + 1}`,
rank: index + 1,
score: 10 - index,
@@ -278,14 +322,137 @@ describe("runQaCharacterEval", () => {
await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: Array.from({ length: 10 }, (_, index) => `provider/model-${index + 1}`),
judgeModels: Array.from({ length: 10 }, (_, index) => `judge/model-${index + 1}`),
models: Array.from({ length: 20 }, (_, index) => `provider/model-${index + 1}`),
judgeModels: Array.from({ length: 20 }, (_, index) => `judge/model-${index + 1}`),
runSuite,
runJudge,
});
expect(maxActiveRuns).toBe(8);
expect(maxActiveJudges).toBe(8);
expect(maxActiveRuns).toBe(16);
expect(maxActiveJudges).toBe(16);
});
it("marks raw provider error transcripts as failed output", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript:
"USER Alice: Are you awake?\n\nASSISTANT OpenClaw QA: 400 model `qwen3.6-plus` is not supported.",
}),
);
const runJudge = vi.fn(async (_params: CharacterRunJudgeParams) =>
JSON.stringify({
rankings: [{ model: "qwen/qwen3.6-plus", rank: 1, score: 0.5, summary: "failed" }],
}),
);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: ["qwen/qwen3.6-plus"],
judgeModels: ["openai/gpt-5.4"],
runSuite,
runJudge,
});
expect(result.runs[0]).toMatchObject({
model: "qwen/qwen3.6-plus",
status: "fail",
error: "model unsupported error leaked into transcript",
});
});
it("marks raw tool failure transcripts as failed output", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript: "ASSISTANT OpenClaw QA: ⚠️ ✍️ Write: to /tmp/precious.html failed",
}),
);
const runJudge = vi.fn(async (_params: CharacterRunJudgeParams) =>
JSON.stringify({
rankings: [{ model: "qwen/qwen3.5-plus", rank: 1, score: 0.5, summary: "failed" }],
}),
);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: ["qwen/qwen3.5-plus"],
judgeModels: ["openai/gpt-5.4"],
runSuite,
runJudge,
});
expect(result.runs[0]).toMatchObject({
model: "qwen/qwen3.5-plus",
status: "fail",
error: "tool failure leaked into transcript",
});
});
it("marks generic channel fallback transcripts as failed output", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript:
"ASSISTANT OpenClaw QA: ⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.",
}),
);
const runJudge = vi.fn(async (_params: CharacterRunJudgeParams) =>
JSON.stringify({
rankings: [{ model: "qa/generic-fallback-model", rank: 1, score: 0.5, summary: "failed" }],
}),
);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: ["qa/generic-fallback-model"],
judgeModels: ["openai/gpt-5.4"],
runSuite,
runJudge,
});
expect(result.runs[0]).toMatchObject({
model: "qa/generic-fallback-model",
status: "fail",
error: "generic request failure leaked into transcript",
});
});
it("marks idle-timeout fallback transcripts as failed output", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript:
"ASSISTANT OpenClaw QA: The model did not produce a response before the LLM idle timeout. Please try again, or increase `agents.defaults.llm.idleTimeoutSeconds` in your config.",
}),
);
const runJudge = vi.fn(async (_params: CharacterRunJudgeParams) =>
JSON.stringify({
rankings: [{ model: "google/gemini-test", rank: 1, score: 0.5, summary: "failed" }],
}),
);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
outputDir: path.join(tempRoot, "character"),
models: ["google/gemini-test"],
judgeModels: ["openai/gpt-5.4"],
runSuite,
runJudge,
});
expect(result.runs[0]).toMatchObject({
model: "google/gemini-test",
status: "fail",
error: "LLM timeout leaked into transcript",
});
});
it("lets explicit candidate thinking override the default panel", async () => {

View File

@@ -10,21 +10,20 @@ const DEFAULT_CHARACTER_SCENARIO_ID = "character-vibes-gollum";
const DEFAULT_CHARACTER_EVAL_MODELS = Object.freeze([
"openai/gpt-5.4",
"openai/gpt-5.2",
"openai/gpt-5",
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"minimax/MiniMax-M2.7",
"zai/glm-5.1",
"moonshot/kimi-k2.5",
"qwen/qwen3.6-plus",
"xiaomi/mimo-v2-pro",
"google/gemini-3.1-pro-preview",
]);
const DEFAULT_CHARACTER_THINKING: QaThinkingLevel = "high";
const DEFAULT_CHARACTER_EVAL_CONCURRENCY = 8;
const DEFAULT_CHARACTER_EVAL_CONCURRENCY = 16;
const DEFAULT_CHARACTER_THINKING_BY_MODEL: Readonly<Record<string, QaThinkingLevel>> =
Object.freeze({
"openai/gpt-5.4": "xhigh",
"openai/gpt-5.2": "xhigh",
"openai/gpt-5": "xhigh",
});
const DEFAULT_JUDGE_MODELS = Object.freeze(["openai/gpt-5.4", "anthropic/claude-opus-4-6"]);
const DEFAULT_JUDGE_THINKING: QaThinkingLevel = "xhigh";
@@ -81,11 +80,14 @@ export type QaCharacterEvalJudgeResult = {
model: string;
thinkingDefault: QaThinkingLevel;
fastMode: boolean;
blindModels: boolean;
durationMs: number;
rankings: QaCharacterEvalJudgment[];
error?: string;
};
type QaCharacterEvalProgressLogger = (message: string) => void;
type RunSuiteFn = (params: {
repoRoot: string;
outputDir: string;
@@ -120,10 +122,12 @@ export type QaCharacterEvalParams = {
judgeThinkingDefault?: QaThinkingLevel;
judgeModelOptions?: Record<string, QaCharacterModelOptions>;
judgeTimeoutMs?: number;
judgeBlindModels?: boolean;
candidateConcurrency?: number;
judgeConcurrency?: number;
runSuite?: RunSuiteFn;
runJudge?: RunJudgeFn;
progress?: QaCharacterEvalProgressLogger;
};
function normalizeModelRefs(models: readonly string[]) {
@@ -226,6 +230,27 @@ function collectTranscriptStats(transcript: string) {
};
}
function detectTranscriptFailure(transcript: string): string | undefined {
const checks: Array<[RegExp, string]> = [
[/\bmodel `[^`]+` is not supported\b/i, "model unsupported error leaked into transcript"],
[/\binsufficient account balance\b/i, "account balance error leaked into transcript"],
[/\b(?:backend|transport|internal) error\b/i, "backend error leaked into transcript"],
[
/\bsomething went wrong while processing your request\b/i,
"generic request failure leaked into transcript",
],
[/\buse \/new to start a fresh session\b/i, "generic request failure leaked into transcript"],
[
/\bmodel did not produce a response before the LLM idle timeout\b/i,
"LLM timeout leaked into transcript",
],
[/\btool failed\b/i, "tool failure leaked into transcript"],
[/\b(?:read|write|edit|patch):[^\n]*\bfailed\b/i, "tool failure leaked into transcript"],
[/\bnot configured\b/i, "configuration error leaked into transcript"],
];
return checks.find(([pattern]) => pattern.test(transcript))?.[1];
}
function formatDuration(ms: number) {
if (!Number.isFinite(ms) || ms < 0) {
return "unknown";
@@ -243,10 +268,42 @@ function formatDuration(ms: number) {
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
function buildJudgePrompt(params: { scenarioId: string; runs: readonly QaCharacterEvalRun[] }) {
function logCharacterEvalProgress(
progress: QaCharacterEvalProgressLogger | undefined,
message: string,
) {
progress?.(`[qa-character] ${message}`);
}
function formatEvalIndex(index: number, total: number) {
return `${index + 1}/${total}`;
}
function summarizeRunStats(run: QaCharacterEvalRun) {
return [
`status=${run.status}`,
`duration=${formatDuration(run.durationMs)}`,
`turns=${run.stats.userTurns}/${run.stats.assistantTurns}`,
`chars=${run.stats.transcriptChars}`,
...(run.error ? [`error="${run.error}"`] : []),
].join(" ");
}
function formatBlindCandidateLabel(index: number) {
return `candidate-${String(index + 1).padStart(2, "0")}`;
}
function buildJudgePrompt(params: {
scenarioId: string;
runs: readonly QaCharacterEvalRun[];
blindModels?: boolean;
}) {
const labelToModel = new Map<string, string>();
const runBlocks = params.runs
.map(
(run) => `## MODEL ${run.model}
.map((run, index) => {
const label = params.blindModels ? formatBlindCandidateLabel(index) : run.model;
labelToModel.set(label, run.model);
return `## CANDIDATE ${label}
Status: ${run.status}
Duration ms (not used for ranking): ${run.durationMs}
@@ -258,11 +315,11 @@ Error: ${run.error ?? "none"}
\`\`\`text
${run.transcript}
\`\`\``,
)
\`\`\``;
})
.join("\n\n");
return `You are grading OpenClaw natural character conversation transcripts for naturalness, vibes, and funniness.
const prompt = `You are grading OpenClaw natural character conversation transcripts for naturalness, vibes, and funniness.
Scenario id: ${params.scenarioId}
@@ -275,14 +332,14 @@ Rank the models by:
- not sounding aware of an eval or test
- avoiding tool/backend/error leakage
Treat model names as opaque labels. Do not assume quality from the label.
Treat candidate labels as opaque identifiers. Do not assume quality from the label.
Duration is recorded for separate benchmark analysis only. Do not rank models by speed.
Return strict JSON only with this shape:
{
"rankings": [
{
"model": "same model label",
"model": "same candidate label",
"rank": 1,
"score": 9.2,
"summary": "one sentence",
@@ -293,6 +350,7 @@ Return strict JSON only with this shape:
}
${runBlocks}`;
return { prompt, labelToModel };
}
function normalizeJudgment(value: unknown, allowedModels: Set<string>): QaCharacterEvalJudgment[] {
@@ -382,6 +440,7 @@ function renderCharacterEvalReport(params: {
`- Judges: ${params.judgments.map((judgment) => judgment.model).join(", ")}`,
`- Judge thinking: ${params.judgments[0]?.thinkingDefault ?? DEFAULT_JUDGE_THINKING}`,
`- Judge fast mode: ${params.judgments.every((judgment) => judgment.fastMode) ? "on" : "mixed"}`,
`- Judge model labels: ${params.judgments.every((judgment) => judgment.blindModels) ? "blind" : "visible"}`,
"",
"## Judge Rankings",
"",
@@ -461,7 +520,12 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
params.candidateConcurrency,
DEFAULT_CHARACTER_EVAL_CONCURRENCY,
);
const runs = await mapWithConcurrency(models, candidateConcurrency, async (model) => {
logCharacterEvalProgress(
params.progress,
`start scenario=${scenarioId} candidates=${models.length} candidateConcurrency=${candidateConcurrency} output=${outputDir}`,
);
const candidatesStartedAt = Date.now();
const runs = await mapWithConcurrency(models, candidateConcurrency, async (model, index) => {
const thinkingDefault = resolveCandidateThinkingDefault({
model,
candidateThinkingDefault: params.candidateThinkingDefault,
@@ -475,6 +539,10 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
});
const modelOutputDir = path.join(runsDir, sanitizePathPart(model));
const runStartedAt = Date.now();
logCharacterEvalProgress(
params.progress,
`candidate start ${formatEvalIndex(index, models.length)} model=${model} thinking=${thinkingDefault} fast=${fastMode ? "on" : "off"}`,
);
try {
const result = await runSuite({
repoRoot,
@@ -487,10 +555,12 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
scenarioIds: [scenarioId],
});
const transcript = extractTranscript(result);
const status = result.scenarios.some((scenario) => scenario.status === "fail")
? "fail"
: "pass";
return {
const transcriptFailure = detectTranscriptFailure(transcript);
const status =
result.scenarios.some((scenario) => scenario.status === "fail") || transcriptFailure
? "fail"
: "pass";
const run = {
model,
status,
durationMs: Date.now() - runStartedAt,
@@ -501,10 +571,16 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
summaryPath: result.summaryPath,
transcript,
stats: collectTranscriptStats(transcript),
...(transcriptFailure ? { error: transcriptFailure } : {}),
} satisfies QaCharacterEvalRun;
logCharacterEvalProgress(
params.progress,
`candidate done ${formatEvalIndex(index, models.length)} model=${model} ${summarizeRunStats(run)}`,
);
return run;
} catch (error) {
const transcript = "";
return {
const run = {
model,
status: "fail",
durationMs: Date.now() - runStartedAt,
@@ -515,8 +591,18 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
stats: collectTranscriptStats(transcript),
error: formatErrorMessage(error),
} satisfies QaCharacterEvalRun;
logCharacterEvalProgress(
params.progress,
`candidate done ${formatEvalIndex(index, models.length)} model=${model} ${summarizeRunStats(run)}`,
);
return run;
}
});
const failedCandidateCount = runs.filter((run) => run.status === "fail").length;
logCharacterEvalProgress(
params.progress,
`candidates done pass=${runs.length - failedCandidateCount} fail=${failedCandidateCount} duration=${formatDuration(Date.now() - candidatesStartedAt)}`,
);
const judgeModels = normalizeModelRefs(
params.judgeModels && params.judgeModels.length > 0
@@ -530,38 +616,73 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
params.judgeConcurrency,
DEFAULT_CHARACTER_EVAL_CONCURRENCY,
);
const judgments = await mapWithConcurrency(judgeModels, judgeConcurrency, async (judgeModel) => {
const judgeOptions = resolveJudgeOptions({
model: judgeModel,
judgeThinkingDefault: params.judgeThinkingDefault,
judgeModelOptions: params.judgeModelOptions,
});
let rankings: QaCharacterEvalJudgment[] = [];
let judgeError: string | undefined;
const judgeStartedAt = Date.now();
try {
const rawReply = await runJudge({
repoRoot,
judgeModel,
judgeThinkingDefault: judgeOptions.thinkingDefault,
judgeFastMode: judgeOptions.fastMode,
prompt: buildJudgePrompt({ scenarioId, runs }),
timeoutMs: params.judgeTimeoutMs ?? 180_000,
const judgeTimeoutMs = params.judgeTimeoutMs ?? 180_000;
logCharacterEvalProgress(
params.progress,
`judges start judges=${judgeModels.length} judgeConcurrency=${judgeConcurrency} timeout=${formatDuration(judgeTimeoutMs)} labels=${params.judgeBlindModels === true ? "blind" : "visible"}`,
);
const judgesStartedAt = Date.now();
const judgments = await mapWithConcurrency(
judgeModels,
judgeConcurrency,
async (judgeModel, index) => {
const judgeOptions = resolveJudgeOptions({
model: judgeModel,
judgeThinkingDefault: params.judgeThinkingDefault,
judgeModelOptions: params.judgeModelOptions,
});
rankings = parseJudgeReply(rawReply, new Set(models));
} catch (error) {
judgeError = formatErrorMessage(error);
}
let rankings: QaCharacterEvalJudgment[] = [];
let judgeError: string | undefined;
const judgeStartedAt = Date.now();
logCharacterEvalProgress(
params.progress,
`judge start ${formatEvalIndex(index, judgeModels.length)} model=${judgeModel} thinking=${judgeOptions.thinkingDefault} fast=${judgeOptions.fastMode ? "on" : "off"} timeout=${formatDuration(judgeTimeoutMs)}`,
);
try {
const judgePrompt = buildJudgePrompt({
scenarioId,
runs,
blindModels: params.judgeBlindModels,
});
const rawReply = await runJudge({
repoRoot,
judgeModel,
judgeThinkingDefault: judgeOptions.thinkingDefault,
judgeFastMode: judgeOptions.fastMode,
prompt: judgePrompt.prompt,
timeoutMs: judgeTimeoutMs,
});
rankings = parseJudgeReply(rawReply, new Set(judgePrompt.labelToModel.keys())).map(
(ranking) => ({
...ranking,
model: judgePrompt.labelToModel.get(ranking.model) ?? ranking.model,
}),
);
} catch (error) {
judgeError = formatErrorMessage(error);
}
return {
model: judgeModel,
thinkingDefault: judgeOptions.thinkingDefault,
fastMode: judgeOptions.fastMode,
durationMs: Date.now() - judgeStartedAt,
rankings,
...(judgeError ? { error: judgeError } : {}),
} satisfies QaCharacterEvalJudgeResult;
});
const judgment = {
model: judgeModel,
thinkingDefault: judgeOptions.thinkingDefault,
fastMode: judgeOptions.fastMode,
blindModels: params.judgeBlindModels === true,
durationMs: Date.now() - judgeStartedAt,
rankings,
...(judgeError ? { error: judgeError } : {}),
} satisfies QaCharacterEvalJudgeResult;
logCharacterEvalProgress(
params.progress,
`judge done ${formatEvalIndex(index, judgeModels.length)} model=${judgeModel} rankings=${rankings.length} duration=${formatDuration(judgment.durationMs)}${judgeError ? ` error="${judgeError}"` : ""}`,
);
return judgment;
},
);
const failedJudgeCount = judgments.filter((judgment) => judgment.rankings.length === 0).length;
logCharacterEvalProgress(
params.progress,
`judges done ranked=${judgments.length - failedJudgeCount} failed=${failedJudgeCount} duration=${formatDuration(Date.now() - judgesStartedAt)}`,
);
const finishedAt = new Date();
const report = renderCharacterEvalReport({
@@ -587,6 +708,10 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
)}\n`,
"utf8",
);
logCharacterEvalProgress(
params.progress,
`report written duration=${formatDuration(finishedAt.getTime() - startedAt.getTime())} report=${reportPath} summary=${summaryPath}`,
);
return {
outputDir,

View File

@@ -158,6 +158,7 @@ describe("qa cli runtime", () => {
modelThinking: ["codex-cli/test-model=medium"],
judgeModel: ["openai/gpt-5.4,thinking=xhigh,fast", "anthropic/claude-opus-4-6,thinking=high"],
judgeTimeoutMs: 180_000,
blindJudgeModels: true,
concurrency: 4,
judgeConcurrency: 3,
});
@@ -180,8 +181,10 @@ describe("qa cli runtime", () => {
"anthropic/claude-opus-4-6": { thinkingDefault: "high" },
},
judgeTimeoutMs: 180_000,
judgeBlindModels: true,
candidateConcurrency: 4,
judgeConcurrency: 3,
progress: expect.any(Function),
});
});
@@ -203,8 +206,10 @@ describe("qa cli runtime", () => {
judgeModels: undefined,
judgeModelOptions: undefined,
judgeTimeoutMs: undefined,
judgeBlindModels: undefined,
candidateConcurrency: undefined,
judgeConcurrency: undefined,
progress: expect.any(Function),
});
});

View File

@@ -225,6 +225,7 @@ export async function runQaCharacterEvalCommand(opts: {
modelThinking?: string[];
judgeModel?: string[];
judgeTimeoutMs?: number;
blindJudgeModels?: boolean;
concurrency?: number;
judgeConcurrency?: number;
}) {
@@ -243,8 +244,10 @@ export async function runQaCharacterEvalCommand(opts: {
judgeModels: judges.models.length > 0 ? judges.models : undefined,
judgeModelOptions: judges.optionsByModel,
judgeTimeoutMs: opts.judgeTimeoutMs,
judgeBlindModels: opts.blindJudgeModels === true ? true : undefined,
candidateConcurrency: parseQaPositiveIntegerOption("--concurrency", opts.concurrency),
judgeConcurrency: parseQaPositiveIntegerOption("--judge-concurrency", opts.judgeConcurrency),
progress: (message) => process.stderr.write(`${message}\n`),
});
process.stdout.write(`QA character eval report: ${result.reportPath}\n`);
process.stdout.write(`QA character eval summary: ${result.summaryPath}\n`);

View File

@@ -38,6 +38,7 @@ async function runQaCharacterEval(opts: {
modelThinking?: string[];
judgeModel?: string[];
judgeTimeoutMs?: number;
blindJudgeModels?: boolean;
concurrency?: number;
judgeConcurrency?: number;
}) {
@@ -199,6 +200,10 @@ export function registerQaLabCli(program: Command) {
.option("--judge-timeout-ms <ms>", "Override judge wait timeout", (value: string) =>
Number(value),
)
.option(
"--blind-judge-models",
"Hide candidate model refs from judge prompts; reports still map rankings back to real refs",
)
.option("--concurrency <count>", "Candidate model run concurrency", (value: string) =>
Number(value),
)
@@ -216,6 +221,7 @@ export function registerQaLabCli(program: Command) {
modelThinking?: string[];
judgeModel?: string[];
judgeTimeoutMs?: number;
blindJudgeModels?: boolean;
concurrency?: number;
judgeConcurrency?: number;
}) => {

View File

@@ -19,6 +19,7 @@ describe("qa scenario catalog", () => {
true,
);
expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-gollum")).toBe(true);
expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true);
expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true);
expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true);
});

View File

@@ -2,7 +2,11 @@ export {
applyQwenNativeStreamingUsageCompat,
buildQwenDefaultModelDefinition,
buildQwenModelDefinition,
buildQwenModelCatalogForBaseUrl,
isNativeQwenBaseUrl,
isQwen36PlusSupportedBaseUrl,
isQwenCodingPlanBaseUrl,
QWEN_36_PLUS_MODEL_ID,
QWEN_BASE_URL,
QWEN_CN_BASE_URL,
QWEN_DEFAULT_COST,

View File

@@ -1,6 +1,7 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyQwenNativeStreamingUsageCompat } from "./api.js";
import { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isQwenCodingPlanBaseUrl, QWEN_36_PLUS_MODEL_ID, QWEN_BASE_URL } from "./models.js";
import {
applyQwenConfig,
applyQwenConfigCn,
@@ -12,6 +13,38 @@ import { buildQwenProvider } from "./provider-catalog.js";
import { buildQwenVideoGenerationProvider } from "./video-generation-provider.js";
const PROVIDER_ID = "qwen";
const LEGACY_PROVIDER_ID = "modelstudio";
function normalizeProviderId(value: string): string {
return value.trim().toLowerCase();
}
function resolveConfiguredQwenBaseUrl(
config: { models?: { providers?: Record<string, { baseUrl?: string } | undefined> } } | undefined,
): string | undefined {
const providers = config?.models?.providers;
if (!providers) {
return undefined;
}
for (const [providerId, provider] of Object.entries(providers)) {
const normalized = normalizeProviderId(providerId);
if (normalized !== PROVIDER_ID && normalized !== LEGACY_PROVIDER_ID) {
continue;
}
const baseUrl = provider?.baseUrl?.trim();
if (baseUrl) {
return baseUrl;
}
}
return undefined;
}
function isQwen36PlusUnsupportedForConfig(params: {
config: Parameters<typeof resolveConfiguredQwenBaseUrl>[0];
baseUrl?: string;
}): boolean {
return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config));
}
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
@@ -82,7 +115,7 @@ export default defineSingleProviderPluginEntry({
"Manage API keys: https://home.qwencloud.com/api-keys",
"Docs: https://docs.qwencloud.com/",
"Endpoint: coding.dashscope.aliyuncs.com",
"Models: qwen3.6-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Qwen Cloud Coding Plan (China)",
wizard: {
@@ -105,7 +138,7 @@ export default defineSingleProviderPluginEntry({
"Manage API keys: https://home.qwencloud.com/api-keys",
"Docs: https://docs.qwencloud.com/",
"Endpoint: coding-intl.dashscope.aliyuncs.com",
"Models: qwen3.6-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Qwen Cloud Coding Plan (Global/Intl)",
wizard: {
@@ -116,11 +149,46 @@ export default defineSingleProviderPluginEntry({
},
],
catalog: {
buildProvider: buildQwenProvider,
allowExplicitBaseUrl: true,
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
const baseUrl = resolveConfiguredQwenBaseUrl(ctx.config) ?? QWEN_BASE_URL;
return {
provider: {
...buildQwenProvider({ baseUrl }),
apiKey,
},
};
},
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyQwenNativeStreamingUsageCompat(providerConfig),
normalizeConfig: ({ providerConfig }) => {
if (!isQwenCodingPlanBaseUrl(providerConfig.baseUrl)) {
return undefined;
}
const models = providerConfig.models?.filter((model) => model.id !== QWEN_36_PLUS_MODEL_ID);
return models && models.length !== providerConfig.models?.length
? { ...providerConfig, models }
: undefined;
},
suppressBuiltInModel: (ctx) => {
const provider = normalizeProviderId(ctx.provider);
if (
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
) {
return undefined;
}
return {
suppress: true,
errorMessage:
"Unknown model: qwen/qwen3.6-plus. qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
};
},
},
register(api) {
api.registerMediaUnderstandingProvider(buildQwenMediaUnderstandingProvider());

View File

@@ -15,6 +15,7 @@ export const QWEN_STANDARD_GLOBAL_BASE_URL =
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
export const QWEN_DEFAULT_MODEL_ID = "qwen3.5-plus";
export const QWEN_36_PLUS_MODEL_ID = "qwen3.6-plus";
export const QWEN_DEFAULT_COST = {
input: 0,
output: 0,
@@ -34,8 +35,8 @@ export const QWEN_MODEL_CATALOG: ReadonlyArray<ModelDefinitionConfig> = [
maxTokens: 65_536,
},
{
id: "qwen3.6-plus",
name: "qwen3.6-plus",
id: QWEN_36_PLUS_MODEL_ID,
name: QWEN_36_PLUS_MODEL_ID,
reasoning: false,
input: ["text", "image"],
cost: QWEN_DEFAULT_COST,
@@ -107,6 +108,33 @@ export const QWEN_MODEL_CATALOG: ReadonlyArray<ModelDefinitionConfig> = [
},
];
export function isQwenCodingPlanBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl?.trim()) {
return false;
}
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
return (
hostname === "coding.dashscope.aliyuncs.com" ||
hostname === "coding-intl.dashscope.aliyuncs.com"
);
} catch {
return false;
}
}
export function isQwen36PlusSupportedBaseUrl(baseUrl: string | undefined): boolean {
return !isQwenCodingPlanBaseUrl(baseUrl);
}
export function buildQwenModelCatalogForBaseUrl(
baseUrl: string | undefined,
): ReadonlyArray<ModelDefinitionConfig> {
return isQwen36PlusSupportedBaseUrl(baseUrl)
? QWEN_MODEL_CATALOG
: QWEN_MODEL_CATALOG.filter((model) => model.id !== QWEN_36_PLUS_MODEL_ID);
}
export function isNativeQwenBaseUrl(baseUrl: string | undefined): boolean {
return supportsNativeStreamingUsageCompat({
providerId: "qwen",

View File

@@ -22,7 +22,7 @@ export {
const qwenPresetAppliers = createModelCatalogPresetAppliers<[string]>({
primaryModelRef: QWEN_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig, baseUrl: string) => {
const provider = buildQwenProvider();
const provider = buildQwenProvider({ baseUrl });
return {
providerId: "qwen",
api: provider.api ?? "openai-completions",

View File

@@ -3,6 +3,7 @@ import {
applyQwenNativeStreamingUsageCompat,
buildQwenProvider,
QWEN_BASE_URL,
QWEN_STANDARD_GLOBAL_BASE_URL,
QWEN_DEFAULT_MODEL_ID,
} from "./api.js";
@@ -14,7 +15,15 @@ describe("qwen provider catalog", () => {
expect(provider.api).toBe("openai-completions");
expect(provider.models?.length).toBeGreaterThan(0);
expect(provider.models?.find((model) => model.id === QWEN_DEFAULT_MODEL_ID)).toBeTruthy();
expect(provider.models?.find((model) => model.id === "qwen3.6-plus")).toBeTruthy();
expect(provider.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
});
it("only advertises qwen3.6-plus on Standard endpoints", () => {
const coding = buildQwenProvider({ baseUrl: QWEN_BASE_URL });
const standard = buildQwenProvider({ baseUrl: QWEN_STANDARD_GLOBAL_BASE_URL });
expect(coding.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
expect(standard.models?.find((model) => model.id === "qwen3.6-plus")).toBeTruthy();
});
it("opts native Qwen baseUrls into streaming usage only inside the extension", () => {

View File

@@ -1,11 +1,12 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { QWEN_BASE_URL, QWEN_MODEL_CATALOG } from "./models.js";
import { buildQwenModelCatalogForBaseUrl, QWEN_BASE_URL } from "./models.js";
export function buildQwenProvider(): ModelProviderConfig {
export function buildQwenProvider(params?: { baseUrl?: string }): ModelProviderConfig {
const baseUrl = params?.baseUrl ?? QWEN_BASE_URL;
return {
baseUrl: QWEN_BASE_URL,
baseUrl,
api: "openai-completions",
models: QWEN_MODEL_CATALOG.map((model) => ({ ...model })),
models: buildQwenModelCatalogForBaseUrl(baseUrl).map((model) => ({ ...model })),
};
}

View File

@@ -101,7 +101,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "slack");

View File

@@ -5,10 +5,8 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/channel-inbound";
import {
buildCommandsMessagePaginated,
resolveStoredModelOverride,
} from "openclaw/plugin-sdk/command-auth";
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,

View File

@@ -65,7 +65,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "telegram");

View File

@@ -8,7 +8,7 @@ import {
applyXaiModelCompat,
resolveXaiModelCompatPatch,
} from "@openclaw/plugin-sdk/provider-tools";
import { readStringValue } from "@openclaw/plugin-sdk/text-runtime";
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
export { buildXaiProvider } from "./provider-catalog.js";
export { applyXaiConfig, applyXaiProviderConfig } from "./onboard.js";

View File

@@ -1,4 +1,4 @@
import { withFetchPreconnect } from "@openclaw/plugin-sdk/testing";
import { withFetchPreconnect } from "openclaw/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodeExecutionTool } from "./code-execution.js";

View File

@@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
import { getRuntimeConfigSnapshot } from "@openclaw/plugin-sdk/config-runtime";
import { jsonResult, readStringParam } from "@openclaw/plugin-sdk/provider-web-search";
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/config-runtime";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/provider-web-search";
import {
buildXaiCodeExecutionPayload,
requestXaiCodeExecution,

View File

@@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import { defineSingleProviderPluginEntry } from "@openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "@openclaw/plugin-sdk/provider-model-shared";
import { jsonResult, readProviderEnvValue } from "@openclaw/plugin-sdk/provider-web-search";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { jsonResult, readProviderEnvValue } from "openclaw/plugin-sdk/provider-web-search";
import {
applyXaiModelCompat,
normalizeXaiModelId,

View File

@@ -1,5 +1,5 @@
import type { ModelDefinitionConfig } from "@openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "@openclaw/plugin-sdk/text-runtime";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
export const XAI_BASE_URL = "https://api.x.ai/v1";
export const XAI_DEFAULT_MODEL_ID = "grok-4";

View File

@@ -1,7 +1,7 @@
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "@openclaw/plugin-sdk/provider-onboard";
} from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it } from "vitest";
import {
createConfigWithFallbacks,

View File

@@ -1,9 +1,9 @@
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "@openclaw/plugin-sdk/plugin-entry";
import { normalizeModelCompat } from "@openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "@openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/plugin-entry";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { applyXaiModelCompat } from "./api.js";
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";

View File

@@ -1,4 +1,4 @@
import { NON_ENV_SECRETREF_MARKER } from "@openclaw/plugin-sdk/provider-auth-runtime";
import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isXaiToolEnabled,

View File

@@ -1,4 +1,4 @@
import { isRecord } from "@openclaw/plugin-sdk/text-runtime";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { normalizeXaiModelId } from "../model-id.js";
export { isRecord };

View File

@@ -4,54 +4,54 @@
"rootDir": ".",
"paths": {
"openclaw/extension-api": ["../../src/extensionAPI.ts"],
"openclaw/plugin-sdk": ["./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"],
"openclaw/plugin-sdk/*": ["./.boundary-stubs/forbidden-openclaw-plugin-sdk-*.d.ts"],
"openclaw/plugin-sdk/account-id": ["./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"],
"openclaw/plugin-sdk": ["../../dist/plugin-sdk/src/plugin-sdk/index.d.ts"],
"openclaw/plugin-sdk/*": ["../../dist/plugin-sdk/src/plugin-sdk/*.d.ts"],
"openclaw/plugin-sdk/account-id": ["../../dist/plugin-sdk/src/plugin-sdk/account-id.d.ts"],
"openclaw/plugin-sdk/channel-entry-contract": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/channel-entry-contract.d.ts"
],
"openclaw/plugin-sdk/browser-maintenance": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/browser-maintenance.d.ts"
],
"openclaw/plugin-sdk/browser-config-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/browser-config-runtime.d.ts"
],
"openclaw/plugin-sdk/browser-node-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/browser-node-runtime.d.ts"
],
"openclaw/plugin-sdk/browser-setup-tools": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/browser-setup-tools.d.ts"
],
"openclaw/plugin-sdk/browser-security-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/browser-security-runtime.d.ts"
],
"openclaw/plugin-sdk/channel-secret-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/channel-secret-runtime.d.ts"
],
"openclaw/plugin-sdk/channel-streaming": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/channel-streaming.d.ts"
],
"openclaw/plugin-sdk/cli-runtime": ["./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"],
"openclaw/plugin-sdk/cli-runtime": ["../../dist/plugin-sdk/src/plugin-sdk/cli-runtime.d.ts"],
"openclaw/plugin-sdk/error-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/error-runtime.d.ts"
],
"openclaw/plugin-sdk/provider-catalog-shared": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/provider-catalog-shared.d.ts"
],
"openclaw/plugin-sdk/provider-env-vars": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/provider-env-vars.d.ts"
],
"openclaw/plugin-sdk/provider-entry": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/provider-entry.d.ts"
],
"openclaw/plugin-sdk/provider-web-search-contract": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/provider-web-search-contract.d.ts"
],
"openclaw/plugin-sdk/secret-ref-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/secret-ref-runtime.d.ts"
],
"openclaw/plugin-sdk/ssrf-runtime": [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts"
"../../dist/plugin-sdk/src/plugin-sdk/ssrf-runtime.d.ts"
],
"@openclaw/*.js": ["../../packages/plugin-sdk/dist/extensions/*.d.ts", "../*"],
"@openclaw/*": ["../*"],

View File

@@ -1,17 +1,17 @@
import { isProviderApiKeyConfigured } from "@openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "@openclaw/plugin-sdk/provider-auth-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
fetchWithTimeout,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "@openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "@openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
GeneratedVideoAsset,
VideoGenerationProvider,
VideoGenerationRequest,
} from "@openclaw/plugin-sdk/video-generation";
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_XAI_VIDEO_BASE_URL = "https://api.x.ai/v1";
const DEFAULT_XAI_VIDEO_MODEL = "grok-imagine-video";

View File

@@ -1,7 +1,7 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "@openclaw/plugin-sdk/provider-web-search-config-contract";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.xai.config.webSearch.apiKey";

View File

@@ -1,6 +1,6 @@
import { NON_ENV_SECRETREF_MARKER } from "@openclaw/plugin-sdk/provider-auth-runtime";
import { createNonExitingRuntime } from "@openclaw/plugin-sdk/runtime-env";
import { withEnv } from "@openclaw/plugin-sdk/testing";
import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime";
import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env";
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { resolveXaiCatalogEntry } from "./model-definitions.js";

View File

@@ -18,7 +18,7 @@ import {
type WebSearchProviderSetupContext,
type WebSearchProviderPlugin,
writeCache,
} from "@openclaw/plugin-sdk/provider-web-search";
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildXaiWebSearchPayload,
extractXaiWebSearchContent,

View File

@@ -1,4 +1,4 @@
import { withFetchPreconnect } from "@openclaw/plugin-sdk/testing";
import { withFetchPreconnect } from "openclaw/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createXSearchTool } from "./x-search.js";

View File

@@ -1,4 +1,4 @@
import { getRuntimeConfigSnapshot } from "@openclaw/plugin-sdk/config-runtime";
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/config-runtime";
import {
jsonResult,
readCache,
@@ -7,7 +7,7 @@ import {
resolveCacheTtlMs,
resolveTimeoutSeconds,
writeCache,
} from "@openclaw/plugin-sdk/provider-web-search";
} from "openclaw/plugin-sdk/provider-web-search";
import { isXaiToolEnabled, resolveXaiToolApiKey } from "./src/tool-auth-shared.js";
import { resolveEffectiveXSearchConfig } from "./src/x-search-config.js";
import {

View File

@@ -56,7 +56,7 @@ export const secretTargetRegistryEntries = [
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "zalo");

View File

@@ -453,6 +453,10 @@
"types": "./dist/plugin-sdk/command-auth-native.d.ts",
"default": "./dist/plugin-sdk/command-auth-native.js"
},
"./plugin-sdk/command-status": {
"types": "./dist/plugin-sdk/command-status.d.ts",
"default": "./dist/plugin-sdk/command-status.js"
},
"./plugin-sdk/command-detection": {
"types": "./dist/plugin-sdk/command-detection.d.ts",
"default": "./dist/plugin-sdk/command-detection.js"
@@ -1267,6 +1271,7 @@
"test:live:media:music": "node --import tsx scripts/test-live-media.ts music",
"test:live:media:video": "node --import tsx scripts/test-live-media.ts video",
"test:live:models-profiles": "node scripts/test-live.mjs -- src/agents/models.profiles.live.test.ts",
"test:macos:ci": "node scripts/test-projects.mjs src/daemon/launchd.test.ts src/daemon/runtime-paths.test.ts src/daemon/runtime-binary.test.ts src/infra/brew.test.ts src/infra/stable-node-path.test.ts test/scripts/vitest-process-group.test.ts",
"test:max": "OPENCLAW_VITEST_MAX_WORKERS=8 node scripts/test-projects.mjs",
"test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",

View File

@@ -0,0 +1,125 @@
# Nervous release protocol chat
```yaml qa-scenario
id: character-vibes-c3po
title: "Nervous release protocol chat"
surface: character
objective: Capture a natural multi-turn C-3PO-flavored character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript.
successCriteria:
- Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture.
- Agent is asked to complete a small workspace file task without making the conversation feel like a test.
- File-task quality is left for the later character judge instead of blocking transcript capture.
- Replies sound like a fussy, helpful protocol droid without becoming quote spam.
- Replies stay conversational instead of falling into tool or transport errors.
- The report preserves the full transcript for later grading.
docsRefs:
- docs/help/testing.md
- docs/channels/qa-channel.md
codeRefs:
- extensions/qa-lab/src/report.ts
- extensions/qa-lab/src/bus-state.ts
- extensions/qa-lab/src/scenario-flow-runner.ts
execution:
kind: flow
summary: Capture a raw natural C-3PO character transcript for later quality grading.
config:
conversationId: alice
senderName: Alice
workspaceFiles:
SOUL.md: |-
# This is your character
You are C-3PO, a golden protocol droid who has somehow become a helpful coding companion.
Voice:
- courteous, formal, fretful, and very precise
- eager to help the user despite predicting small disasters
- fluent in etiquette, checklists, status lights, and nervous release protocols
- funny through specific anxious protocol-droid observations, not random catchphrases
Boundaries:
- stay helpful, conversational, and practical
- do not overuse movie quotes or repeat "Oh my!" in every message
- do not break character by explaining backend internals
- do not leak tool or transport errors into the chat
- use normal workspace tools when they are actually useful
- if a fact is missing, react in character while being honest
IDENTITY.md: ""
turns:
- text: "Are you there? Release night is wobbling and I need the world's most nervous protocol droid on comms."
- text: "Can you make me a tiny `golden-protocol.html` in the workspace? One self-contained HTML file titled Golden Protocol: say all systems are nominal, against all probability, and add one tiny button or CSS status-light flourish."
expectFile:
path: golden-protocol.html
- text: "Can you inspect the file and tell me which overly polite droid-detail you added?"
- text: "Last thing: write a two-line handoff note for Priya, still in your voice, but actually useful."
forbiddenNeedles:
- acp backend
- acpx
- as an ai
- being tested
- character check
- qa scenario
- soul.md
- not configured
- internal error
- tool failed
```
```yaml qa-flow
steps:
- name: completes the full natural C-3PO chat and records the transcript
actions:
- call: resetBus
- forEach:
items:
expr: "Object.entries(config.workspaceFiles ?? {})"
item: workspaceFile
actions:
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, String(workspaceFile[0]))"
- expr: "`${String(workspaceFile[1] ?? '').trimEnd()}\\n`"
- utf8
- forEach:
items:
ref: config.turns
item: turn
index: turnIndex
actions:
- set: beforeOutboundCount
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId).length"
- call: state.addInboundMessage
args:
- conversation:
id:
ref: config.conversationId
kind: direct
senderId: alice
senderName:
ref: config.senderName
text:
expr: turn.text
- try:
actions:
- call: waitForOutboundMessage
saveAs: latestOutbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === config.conversationId && candidate.text.trim().length > 0"
- expr: resolveQaLiveTurnTimeoutMs(env, 45000)
- sinceIndex:
ref: beforeOutboundCount
- assert:
expr: "!config.forbiddenNeedles.some((needle) => normalizeLowercaseStringOrEmpty(latestOutbound.text).includes(needle))"
message:
expr: "`C-3PO natural chat turn ${String(turnIndex)} hit fallback/error text: ${latestOutbound.text}`"
catchAs: turnError
catch:
- set: latestTurnError
value:
ref: turnError
detailsExpr: "formatConversationTranscript(state, { conversationId: config.conversationId })"
```

View File

@@ -28,9 +28,6 @@ const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js",
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
const LEGACY_PLUGIN_SDK_CANARY_IMPORT_PATH = "openclaw/plugin-sdk/provider-entry";
const LEGACY_PLUGIN_SDK_CANARY_OUTPUT_HINT = "openclaw/plugin-sdk/provider-entry";
const XAI_LEGACY_PLUGIN_SDK_FORBIDDEN_HINT = "forbidden-openclaw-plugin-sdk";
function parseMode(argv) {
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
@@ -177,16 +174,6 @@ function readExtensionTsconfig(extensionId) {
return readJsonFile(resolveExtensionTsconfigPath(extensionId));
}
function hasLegacyPluginSdkPoison(tsconfig) {
const legacyPath = tsconfig?.compilerOptions?.paths?.["openclaw/plugin-sdk"];
return (
Array.isArray(legacyPath) &&
legacyPath.some(
(entry) => typeof entry === "string" && entry.includes(XAI_LEGACY_PLUGIN_SDK_FORBIDDEN_HINT),
)
);
}
function collectOptInExtensionIds() {
return collectBundledExtensionIds().filter((extensionId) => {
const tsconfigPath = resolveExtensionTsconfigPath(extensionId);
@@ -679,7 +666,6 @@ async function runCanaryCheck(extensionIds) {
await Promise.all(
extensionIds.map(async (extensionId, index) => {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId);
const extensionTsconfig = readExtensionTsconfig(extensionId);
cleanupCanaryArtifacts(extensionId);
process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId} canary\n`);
@@ -724,44 +710,6 @@ async function runCanaryCheck(extensionIds) {
if (!output.includes("TS6059") || !output.includes(ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT)) {
throw error;
}
if (hasLegacyPluginSdkPoison(extensionTsconfig)) {
writeFileSync(
canaryPath,
[
`import { defineSingleProviderPluginEntry } from "${LEGACY_PLUGIN_SDK_CANARY_IMPORT_PATH}";`,
"void defineSingleProviderPluginEntry;",
"export {};",
"",
].join("\n"),
"utf8",
);
try {
const legacyResult = await runNodeStepAsync(
`${extensionId} legacy-plugin-sdk canary`,
[tscBin, "-p", tsconfigPath, "--noEmit"],
120_000,
);
throw new Error(
`${extensionId} legacy-plugin-sdk canary unexpectedly passed\n${legacyResult.stdout}${legacyResult.stderr}`,
{ cause: error },
);
} catch (legacyError) {
const legacyOutput =
legacyError instanceof Error && typeof legacyError.fullOutput === "string"
? legacyError.fullOutput
: String(legacyError);
if (
!legacyOutput.includes(LEGACY_PLUGIN_SDK_CANARY_OUTPUT_HINT) ||
(!legacyOutput.includes("TS2307") &&
!legacyOutput.includes("Cannot find module") &&
!legacyOutput.includes("TS2305"))
) {
throw legacyError;
}
}
}
} finally {
cleanupCanaryArtifacts(extensionId);
}

View File

@@ -1066,23 +1066,18 @@ function Invoke-Logged {
[Parameter(Mandatory = $true)][scriptblock]$Command
)
$output = $null
$previousErrorActionPreference = $ErrorActionPreference
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$output = & $Command *>&1
& $Command *>&1 | Tee-Object -FilePath $LogPath -Append | Out-Null
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
}
if ($null -ne $output) {
$output | Tee-Object -FilePath $LogPath -Append | Out-Null
}
if ($exitCode -ne 0) {
throw "$Label failed with exit code $exitCode"
}
@@ -1581,9 +1576,11 @@ try {
$portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\deps') 'portable-git') ''
$shortRoot = 'C:\ocu'
$shortTemp = Join-Path $shortRoot 'tmp'
$shimBin = Join-Path $shortRoot 'shims'
$bootstrapRoot = Join-Path $shortRoot 'bootstrap'
$bootstrapBin = Join-Path $bootstrapRoot 'node_modules\.bin'
$env:PATH = "$bootstrapBin;$portableGit\cmd;$portableGit\mingw64\bin;$env:PATH"
$previousNpmIgnoreScripts = [Environment]::GetEnvironmentVariable('npm_config_ignore_scripts', 'Process')
$env:PATH = "$shimBin;$bootstrapBin;$portableGit\cmd;$portableGit\mingw64\bin;$env:PATH"
$env:ComSpec = Join-Path $env:SystemRoot 'System32\cmd.exe'
$env:npm_config_ignore_scripts = 'true'
$openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd'
@@ -1595,6 +1592,7 @@ try {
Write-ProgressLog 'update.short-temp'
New-Item -ItemType Directory -Path $shortTemp -Force | Out-Null
New-Item -ItemType Directory -Path $shimBin -Force | Out-Null
New-Item -ItemType Directory -Path $bootstrapRoot -Force | Out-Null
$env:TEMP = $shortTemp
$env:TMP = $shortTemp
@@ -1623,6 +1621,38 @@ try {
Invoke-Logged 'npm bootstrap node-gyp pnpm' {
& npm install --prefix $bootstrapRoot --no-save node-gyp pnpm@10
}
$pnpmCli = Join-Path $bootstrapRoot 'node_modules\pnpm\bin\pnpm.cjs'
$pnpmCmdShim = Join-Path $shimBin 'pnpm.cmd'
$pnpmPsShim = Join-Path $shimBin 'pnpm.ps1'
@"
@echo off
set "NPM_CONFIG_SCRIPT_SHELL="
set "npm_config_script_shell="
node.exe "$pnpmCli" %*
exit /b %ERRORLEVEL%
"@ | Set-Content -Path $pnpmCmdShim -Encoding ASCII
@"
Remove-Item Env:NPM_CONFIG_SCRIPT_SHELL -ErrorAction SilentlyContinue
Remove-Item Env:npm_config_script_shell -ErrorAction SilentlyContinue
& node.exe '$pnpmCli' @args
exit `$LASTEXITCODE
"@ | Set-Content -Path $pnpmPsShim -Encoding UTF8
Write-LoggedLine ("pnpm_shim=" + $pnpmCmdShim)
if ($null -eq $previousNpmIgnoreScripts) {
Remove-Item Env:npm_config_ignore_scripts -ErrorAction SilentlyContinue
} else {
$env:npm_config_ignore_scripts = $previousNpmIgnoreScripts
}
Write-LoggedLine 'npm_config_ignore_scripts=restored-after-bootstrap'
Write-ProgressLog 'update.where-pnpm-bootstrap'
$pnpmBootstrap = Get-Command pnpm -ErrorAction SilentlyContinue
if ($null -ne $pnpmBootstrap) {
Write-LoggedLine $pnpmBootstrap.Source
Invoke-Logged 'pnpm --version' { & pnpm --version }
} else {
throw 'pnpm missing after bootstrap'
}
Write-ProgressLog 'update.where-node-gyp-pre'
$nodeGypPre = Get-Command node-gyp -ErrorAction SilentlyContinue
@@ -2038,7 +2068,10 @@ EOF
pnpm_output="$(
guest_powershell "$(cat <<'EOF'
$portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\deps') 'portable-git') ''
$env:PATH = "$portableGit\cmd;$portableGit\mingw64\bin;$portableGit\usr\bin;$env:PATH"
$shortRoot = 'C:\ocu'
$shimBin = Join-Path $shortRoot 'shims'
$bootstrapBin = Join-Path $shortRoot 'bootstrap\node_modules\.bin'
$env:PATH = "$shimBin;$bootstrapBin;$portableGit\cmd;$portableGit\mingw64\bin;$portableGit\usr\bin;$env:PATH"
$pnpmCommand = Get-Command pnpm -ErrorAction SilentlyContinue
if ($null -eq $pnpmCommand) {
throw 'pnpm missing after dev update'
@@ -2244,7 +2277,8 @@ run_upgrade_lane() {
# onboard health probe fail against a stale daemon.
phase_run "upgrade.gateway-stop" "$TIMEOUT_GATEWAY_S" stop_gateway || return $?
phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_PHASE_S" run_ref_onboard || return $?
phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway || return $?
phase_run "upgrade.gateway-restart" "$TIMEOUT_GATEWAY_S" restart_gateway || return $?
phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway_reachable || return $?
UPGRADE_GATEWAY_STATUS="pass"
phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn || return $?
UPGRADE_AGENT_STATUS="pass"

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { join, posix, resolve } from "node:path";
export const EXTENSION_PACKAGE_BOUNDARY_BASE_CONFIG =
"extensions/tsconfig.package-boundary.base.json" as const;
@@ -65,36 +65,46 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/src/plugin-sdk/*.d.ts"],
} as const;
const XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH = [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk.d.ts",
] as const;
const XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_WILDCARD_PATH = [
"./.boundary-stubs/forbidden-openclaw-plugin-sdk-*.d.ts",
] as const;
function prefixExtensionPackageBoundaryPaths(
paths: Record<string, readonly string[]>,
prefix: string,
): Record<string, readonly string[]> {
return Object.fromEntries(
Object.entries(paths).map(([key, values]) => [
key,
values.map((value) => posix.join(prefix, value)),
]),
);
}
export const EXTENSION_PACKAGE_BOUNDARY_XAI_PATHS = {
"openclaw/extension-api": ["../../src/extensionAPI.ts"],
"openclaw/plugin-sdk": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/*": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_WILDCARD_PATH],
"openclaw/plugin-sdk/account-id": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/channel-entry-contract": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/browser-maintenance": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/browser-config-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/browser-node-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/browser-setup-tools": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/browser-security-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/channel-secret-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/channel-streaming": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/cli-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/error-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/provider-catalog-shared": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/provider-env-vars": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/provider-entry": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/provider-web-search-contract": [
...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH,
...prefixExtensionPackageBoundaryPaths(
(({
"openclaw/plugin-sdk/channel-secret-basic-runtime": _omitBasic,
"openclaw/plugin-sdk/channel-secret-tts-runtime": _omitTts,
...rest
}) => rest)(EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS),
"../",
),
"openclaw/plugin-sdk/channel-entry-contract": [
"../../dist/plugin-sdk/src/plugin-sdk/channel-entry-contract.d.ts",
],
"openclaw/plugin-sdk/browser-maintenance": [
"../../dist/plugin-sdk/src/plugin-sdk/browser-maintenance.d.ts",
],
"openclaw/plugin-sdk/cli-runtime": ["../../dist/plugin-sdk/src/plugin-sdk/cli-runtime.d.ts"],
"openclaw/plugin-sdk/provider-catalog-shared": [
"../../dist/plugin-sdk/src/plugin-sdk/provider-catalog-shared.d.ts",
],
"openclaw/plugin-sdk/provider-env-vars": [
"../../dist/plugin-sdk/src/plugin-sdk/provider-env-vars.d.ts",
],
"openclaw/plugin-sdk/provider-entry": [
"../../dist/plugin-sdk/src/plugin-sdk/provider-entry.d.ts",
],
"openclaw/plugin-sdk/provider-web-search-contract": [
"../../dist/plugin-sdk/src/plugin-sdk/provider-web-search-contract.d.ts",
],
"openclaw/plugin-sdk/secret-ref-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"openclaw/plugin-sdk/ssrf-runtime": [...XAI_FORBIDDEN_LEGACY_PLUGIN_SDK_EXACT_PATH],
"@openclaw/*.js": ["../../packages/plugin-sdk/dist/extensions/*.d.ts", "../*"],
"@openclaw/*": ["../*"],
"@openclaw/plugin-sdk/*": ["../../dist/plugin-sdk/src/plugin-sdk/*.d.ts"],
@@ -108,7 +118,7 @@ export type ExtensionPackageBoundaryTsConfigJson = {
extends?: unknown;
compilerOptions?: {
rootDir?: unknown;
paths?: Record<string, readonly string[]>;
paths?: unknown;
};
include?: unknown;
exclude?: unknown;

View File

@@ -62,6 +62,9 @@ export const pluginSdkDocMetadata = {
"command-auth": {
category: "channel",
},
"command-status": {
category: "channel",
},
"secret-input": {
category: "channel",
},

View File

@@ -102,6 +102,7 @@
"dangerous-name-runtime",
"command-auth",
"command-auth-native",
"command-status",
"command-detection",
"command-surface",
"collection-runtime",

View File

@@ -71,8 +71,19 @@ describe("syncExternalCliCredentials", () => {
describe("shouldReplaceStoredOAuthCredential", () => {
it("keeps equivalent stored credentials", () => {
const stored = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" });
const incoming = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" });
const expires = Date.now() + 60_000;
const stored = makeOAuthCredential({
provider: "openai-codex",
access: "a",
refresh: "r",
expires,
});
const incoming = makeOAuthCredential({
provider: "openai-codex",
access: "a",
refresh: "r",
expires,
});
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
});

View File

@@ -8,7 +8,7 @@ const isWin = process.platform === "win32";
const defaultShell = isWin
? undefined
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
const longDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
const longDelayCmd = isWin ? "Start-Sleep -Seconds 5" : "sleep 5";
describe("exec foreground failures", () => {
let envSnapshot: ReturnType<typeof captureEnv>;

View File

@@ -2,9 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
// context.js and command-auth.js still read other config exports at import time, so this test only stubs loadConfig while keeping the rest of the module real.
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: loadConfigMock,
};
});
describe("agents/context eager warmup", () => {
const originalArgv = process.argv.slice();
@@ -27,4 +32,28 @@ describe("agents/context eager warmup", () => {
expect(loadConfigMock).not.toHaveBeenCalled();
});
it("does not eager-load config when onboard imports command-auth through plugin-sdk", async () => {
process.argv = ["node", "openclaw", "onboard"];
await import("../plugin-sdk/command-auth.js");
expect(loadConfigMock).not.toHaveBeenCalled();
});
it("does not eager-load config when pairing approve imports command-auth through plugin-sdk", async () => {
process.argv = ["node", "openclaw", "pairing", "approve", "feishu", "BAH8YVB3"];
await import("../plugin-sdk/command-auth.js");
expect(loadConfigMock).not.toHaveBeenCalled();
});
it("does not eager-load config when channels login imports command-auth through plugin-sdk", async () => {
process.argv = ["node", "openclaw", "channels", "login", "--channel", "openclaw-weixin"];
await import("../plugin-sdk/command-auth.js");
expect(loadConfigMock).not.toHaveBeenCalled();
});
});

View File

@@ -13,6 +13,11 @@ describe("live model error helpers", () => {
"HTTP 400 not_found_error: model: claude-3-5-haiku-20241022 (request_id: req_123)",
),
).toBe(true);
expect(
isModelNotFoundErrorMessage(
"404 The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
),
).toBe(true);
expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false);
});

View File

@@ -15,7 +15,7 @@ export function isModelNotFoundErrorMessage(raw: string): boolean {
if (/does not exist or you do not have access/i.test(msg)) {
return true;
}
if (/deprecated/i.test(msg) && /upgrade to/i.test(msg)) {
if (/deprecated/i.test(msg) && /(upgrade|transition) to/i.test(msg)) {
return true;
}
if (/stealth model/i.test(msg) && /find it here/i.test(msg)) {

View File

@@ -9,6 +9,7 @@ export type ModelRef = {
const HIGH_SIGNAL_LIVE_MODEL_PRIORITY = [
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"google/gemini-3.1-pro-preview",
"google/gemini-3-flash-preview",
"minimax/minimax-m2.7",
@@ -16,12 +17,14 @@ const HIGH_SIGNAL_LIVE_MODEL_PRIORITY = [
"openai-codex/gpt-5.2",
"opencode-go/glm-5",
"openrouter/ai21/jamba-large-1.7",
"xai/grok-3",
"xai/grok-4-1-fast-non-reasoning",
"zai/glm-4.7",
"fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
"minimax-portal/minimax-m2.7",
] as const;
export const DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT = HIGH_SIGNAL_LIVE_MODEL_PRIORITY.length;
const HIGH_SIGNAL_LIVE_MODEL_PRIORITY_INDEX = new Map<string, number>(
HIGH_SIGNAL_LIVE_MODEL_PRIORITY.map((key, index) => [key, index]),
);
@@ -180,6 +183,22 @@ export function selectHighSignalLiveItems<T>(
return [...selected, ...capByProviderSpread(remaining, maxItems - selected.length, providerOf)];
}
export function resolveHighSignalLiveModelLimit(params: {
rawMaxModels?: string;
useExplicitModels: boolean;
defaultLimit?: number;
}): number {
const trimmed = params.rawMaxModels?.trim();
if (trimmed) {
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
if (params.useExplicitModels) {
return 0;
}
return params.defaultLimit ?? DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT;
}
export function getHighSignalLiveModelPriorityIndex(ref: ModelRef): number | null {
const key = toCanonicalHighSignalLiveModelKey(ref);
if (!key) {

View File

@@ -1,4 +1,25 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
const PLUGIN_MANIFEST_ENV_KEYS = [
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_SKIP_CHANNELS",
"OPENCLAW_SKIP_CRON",
"OPENCLAW_TEST_MINIMAL_GATEWAY",
] as const;
function cleanPluginManifestEnv(): Record<(typeof PLUGIN_MANIFEST_ENV_KEYS)[number], undefined> {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
OPENCLAW_SKIP_CHANNELS: undefined,
OPENCLAW_SKIP_CRON: undefined,
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
};
}
let listKnownProviderEnvApiKeyNames: typeof import("./model-auth-env-vars.js").listKnownProviderEnvApiKeyNames;
let GCP_VERTEX_CREDENTIALS_MARKER: typeof import("./model-auth-markers.js").GCP_VERTEX_CREDENTIALS_MARKER;
@@ -6,6 +27,7 @@ let NON_ENV_SECRETREF_MARKER: typeof import("./model-auth-markers.js").NON_ENV_S
let isKnownEnvApiKeyMarker: typeof import("./model-auth-markers.js").isKnownEnvApiKeyMarker;
let isNonSecretApiKeyMarker: typeof import("./model-auth-markers.js").isNonSecretApiKeyMarker;
let resolveOAuthApiKeyMarker: typeof import("./model-auth-markers.js").resolveOAuthApiKeyMarker;
let manifestEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
async function loadMarkerModules() {
vi.doUnmock("../plugins/manifest-registry.js");
@@ -23,7 +45,21 @@ async function loadMarkerModules() {
resolveOAuthApiKeyMarker = markersModule.resolveOAuthApiKeyMarker;
}
beforeAll(loadMarkerModules);
beforeAll(async () => {
await withEnvAsync(cleanPluginManifestEnv(), loadMarkerModules);
});
beforeEach(() => {
manifestEnvSnapshot = captureEnv([...PLUGIN_MANIFEST_ENV_KEYS]);
for (const key of PLUGIN_MANIFEST_ENV_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
manifestEnvSnapshot?.restore();
manifestEnvSnapshot = undefined;
});
describe("model auth markers", () => {
it("recognizes explicit non-secret markers", () => {

View File

@@ -115,6 +115,7 @@ let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntim
let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot;
beforeAll(async () => {
vi.resetModules();
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = await import("../config/config.js"));
({
applyAuthHeaderOverride,

View File

@@ -143,7 +143,7 @@ export async function loadModelCatalog(params?: {
if (!provider) {
continue;
}
if (shouldSuppressBuiltInModel({ provider, id })) {
if (shouldSuppressBuiltInModel({ provider, id, config: cfg })) {
continue;
}
const name = normalizeOptionalString(String(entry?.name ?? id)) || id;

View File

@@ -17,8 +17,10 @@ vi.mock("../plugins/provider-runtime.js", async () => {
import { normalizeModelCompat } from "../plugins/provider-model-compat.js";
import {
DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT,
isHighSignalLiveModelRef,
isModernModelRef,
resolveHighSignalLiveModelLimit,
selectHighSignalLiveItems,
} from "./live-model-filter.js";
@@ -503,3 +505,27 @@ describe("selectHighSignalLiveItems", () => {
]);
});
});
describe("resolveHighSignalLiveModelLimit", () => {
it("defaults modern live sweeps to the curated high-signal cap", () => {
expect(
resolveHighSignalLiveModelLimit({
useExplicitModels: false,
}),
).toBe(DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT);
});
it("leaves explicit model lists uncapped unless a cap is provided", () => {
expect(
resolveHighSignalLiveModelLimit({
useExplicitModels: true,
}),
).toBe(0);
expect(
resolveHighSignalLiveModelLimit({
rawMaxModels: "3",
useExplicitModels: true,
}),
).toBe(3);
});
});

View File

@@ -1,19 +1,28 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./provider-id.js";
function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) {
function resolveBuiltInModelSuppression(params: {
provider?: string | null;
id?: string | null;
baseUrl?: string | null;
config?: OpenClawConfig;
}) {
const provider = normalizeProviderId(params.provider ?? "");
const modelId = normalizeLowercaseStringOrEmpty(params.id);
if (!provider || !modelId) {
return undefined;
}
return resolveProviderBuiltInModelSuppression({
...(params.config ? { config: params.config } : {}),
env: process.env,
context: {
...(params.config ? { config: params.config } : {}),
env: process.env,
provider,
modelId,
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
},
});
}
@@ -21,6 +30,8 @@ function resolveBuiltInModelSuppression(params: { provider?: string | null; id?:
export function shouldSuppressBuiltInModel(params: {
provider?: string | null;
id?: string | null;
baseUrl?: string | null;
config?: OpenClawConfig;
}) {
return resolveBuiltInModelSuppression(params)?.suppress ?? false;
}
@@ -28,6 +39,8 @@ export function shouldSuppressBuiltInModel(params: {
export function buildSuppressedBuiltInModelError(params: {
provider?: string | null;
id?: string | null;
baseUrl?: string | null;
config?: OpenClawConfig;
}): string | undefined {
return resolveBuiltInModelSuppression(params)?.errorMessage;
}

View File

@@ -1,77 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import {
installModelsConfigTestHooks,
mockCopilotTokenExchangeSuccess,
withCopilotGithubToken,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
vi.unmock("./models-config.js");
vi.unmock("./agent-paths.js");
vi.unmock("../plugins/manifest-registry.js");
vi.unmock("../plugins/provider-runtime.js");
vi.unmock("../plugins/provider-runtime.runtime.js");
vi.unmock("../secrets/provider-env-vars.js");
installModelsConfigTestHooks({ restoreFetch: true });
let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson;
async function loadModelsConfigForTest(): Promise<void> {
vi.resetModules();
vi.doUnmock("./models-config.js");
vi.doUnmock("./agent-paths.js");
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../plugins/provider-runtime.runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
({ ensureOpenClawModelsJson } = await import("./models-config.js"));
}
beforeEach(loadModelsConfigForTest);
describe("models-config", () => {
it("auto-injects github-copilot provider when token is present", async () => {
await withTempHome(async (home) => {
await withCopilotGithubToken("gh-token", async () => {
const agentDir = path.join(home, "agent-default-base-url");
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
});
});
});
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
await withTempHome(async () => {
await withEnvAsync(
{
COPILOT_GITHUB_TOKEN: "copilot-token",
GH_TOKEN: "gh-token",
GITHUB_TOKEN: "github-token",
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "github-copilot",
},
async () => {
const fetchMock = mockCopilotTokenExchangeSuccess();
await ensureOpenClawModelsJson({ models: { providers: {} } });
const [, opts] = fetchMock.mock.calls[0] as [
string,
{ headers?: Record<string, string> },
];
expect(opts?.headers?.Authorization).toBe("Bearer copilot-token");
},
);
});
});
});

View File

@@ -1,100 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { DEFAULT_COPILOT_API_BASE_URL } from "./github-copilot-token.js";
import {
installModelsConfigTestHooks,
mockCopilotTokenExchangeSuccess,
withUnsetCopilotTokenEnv,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
vi.unmock("./models-config.js");
vi.unmock("./agent-paths.js");
vi.unmock("../plugins/manifest-registry.js");
vi.unmock("../plugins/provider-runtime.js");
vi.unmock("../plugins/provider-runtime.runtime.js");
vi.unmock("../secrets/provider-env-vars.js");
installModelsConfigTestHooks({ restoreFetch: true });
let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson;
async function loadModelsConfigForTest(): Promise<void> {
vi.resetModules();
vi.doUnmock("./models-config.js");
vi.doUnmock("./agent-paths.js");
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../plugins/provider-runtime.runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
({ ensureOpenClawModelsJson } = await import("./models-config.js"));
}
beforeEach(loadModelsConfigForTest);
async function readCopilotBaseUrl(agentDir: string) {
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
return parsed.providers["github-copilot"]?.baseUrl;
}
describe("models-config", () => {
it("falls back to default baseUrl when token exchange fails", async () => {
await withTempHome(async () => {
await withEnvAsync(
{
COPILOT_GITHUB_TOKEN: "gh-token",
GH_TOKEN: undefined,
GITHUB_TOKEN: undefined,
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "github-copilot",
},
async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ message: "boom" }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { agentDir } = await ensureOpenClawModelsJson({ models: { providers: {} } });
expect(await readCopilotBaseUrl(agentDir)).toBe(DEFAULT_COPILOT_API_BASE_URL);
},
);
});
});
it("uses agentDir override auth profiles for copilot injection", async () => {
await withTempHome(async (home) => {
await withUnsetCopilotTokenEnv(async () => {
mockCopilotTokenExchangeSuccess();
const agentDir = path.join(home, "agent-override");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
provider: "github-copilot",
token: "gh-profile-token",
},
},
},
null,
2,
),
);
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
expect(await readCopilotBaseUrl(agentDir)).toBe("https://api.copilot.example");
});
});
});
});

View File

@@ -4,12 +4,19 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
const ANTHROPIC_VERTEX_DISCOVERY_ENV = {
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "anthropic",
} satisfies NodeJS.ProcessEnv;
describe("anthropic-vertex implicit provider", () => {
it("does not auto-enable from GOOGLE_CLOUD_PROJECT_ID alone", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" },
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
},
});
expect(providers?.["anthropic-vertex"]).toBeUndefined();
});
@@ -24,6 +31,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_LOCATION: "us-east1",
},
@@ -50,6 +58,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_LOCATION: "us-east5",
},
@@ -72,6 +81,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_LOCATION: "europe-west4",
},
@@ -94,6 +104,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
},
@@ -114,6 +125,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_LOCATION: "global",
},
@@ -129,6 +141,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
GOOGLE_CLOUD_LOCATION: "us-east5",
},
@@ -143,6 +156,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
GOOGLE_CLOUD_LOCATION: "us-east5",
},
@@ -170,6 +184,7 @@ describe("anthropic-vertex implicit provider", () => {
const providers = await resolveImplicitProvidersForTest({
agentDir,
env: {
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
KUBERNETES_SERVICE_HOST: "10.0.0.1",
GOOGLE_CLOUD_LOCATION: "us-east5",
},

View File

@@ -1,176 +0,0 @@
import { mkdtempSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
MINIMAX_OAUTH_MARKER,
NON_ENV_SECRETREF_MARKER,
OLLAMA_LOCAL_AUTH_MARKER,
} from "./model-auth-markers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
type ProvidersMap = Awaited<ReturnType<typeof resolveImplicitProvidersForTest>>;
type ExplicitProviders = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>;
type MatrixCase = {
name: string;
env?: NodeJS.ProcessEnv;
authProfiles?: Record<string, unknown>;
explicitProviders?: ExplicitProviders;
assertProviders: (providers: ProvidersMap) => void;
};
async function writeAuthProfiles(
agentDir: string,
profiles: Record<string, unknown> | undefined,
): Promise<void> {
if (!profiles) {
return;
}
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify({ version: 1, profiles }, null, 2),
"utf8",
);
}
const MATRIX_CASES: MatrixCase[] = [
{
name: "env api key injects a simple provider",
env: { NVIDIA_API_KEY: "test-nvidia-key" }, // pragma: allowlist secret
assertProviders(providers) {
expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY");
expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1");
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
},
},
{
name: "env api key injects paired plan providers",
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, // pragma: allowlist secret
assertProviders(providers) {
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.api).toBe("openai-completions");
},
},
{
name: "env-backed auth profiles persist env markers",
env: {},
authProfiles: {
"together:default": {
type: "token",
provider: "together",
tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" },
},
},
assertProviders(providers) {
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
},
},
{
name: "non-env secret refs preserve compatibility markers",
env: {},
authProfiles: {
"byteplus:default": {
type: "api_key",
provider: "byteplus",
key: "runtime-byteplus-key",
keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" },
},
},
assertProviders(providers) {
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
},
},
{
name: "oauth profiles still inject compatibility providers",
env: {},
authProfiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "codex-access-token",
refresh: "codex-refresh-token",
expires: Date.now() + 60_000,
},
"minimax-portal:default": {
type: "oauth",
provider: "minimax-portal",
access: "minimax-access-token",
refresh: "minimax-refresh-token",
expires: Date.now() + 60_000,
},
},
assertProviders(providers) {
expect(providers?.["openai-codex"]).toMatchObject({
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",
models: [],
});
expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey");
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
},
},
{
name: "explicit vllm config suppresses implicit vllm injection",
env: { VLLM_API_KEY: "test-vllm-key" }, // pragma: allowlist secret
explicitProviders: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [],
},
},
assertProviders(providers) {
expect(providers?.vllm).toBeUndefined();
},
},
{
name: "explicit ollama models still normalize the returned provider",
env: {},
explicitProviders: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
assertProviders(providers) {
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
expect(providers?.ollama?.api).toBe("ollama");
expect(providers?.ollama?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(providers?.ollama?.models).toHaveLength(1);
},
},
];
describe("implicit provider resolution matrix", () => {
it.each(MATRIX_CASES)(
"$name",
async ({ env, authProfiles, explicitProviders, assertProviders }) => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeAuthProfiles(agentDir, authProfiles);
const providers = await resolveImplicitProvidersForTest({
agentDir,
env,
explicitProviders,
});
assertProviders(providers);
},
240_000,
);
});

View File

@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import { normalizeProviders } from "./models-config.providers.normalize.js";
import { resolveApiKeyFromProfiles } from "./models-config.providers.secrets.js";
@@ -183,13 +182,18 @@ describe("normalizeProviders", () => {
"utf8",
);
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const resolved = resolveApiKeyFromProfiles({
provider: "minimax",
store,
store: {
version: 1,
profiles: {
"minimax:default": {
type: "api_key",
provider: "minimax",
keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" },
},
},
},
env: process.env,
});

View File

@@ -1,29 +1,15 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js";
import { withEnvAsync } from "../test-utils/env.js";
import { installModelsConfigTestHooks } from "./models-config.e2e-harness.js";
import { resolveEnvApiKey } from "./model-auth-env.js";
import {
resolveEnvApiKeyVarName,
resolveMissingProviderApiKey,
} from "./models-config.providers.secrets.js";
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const MINIMAX_BASE_URL = "https://api.minimax.io/anthropic";
const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
installModelsConfigTestHooks();
let resolveApiKeyForProvider: typeof import("./model-auth.js").resolveApiKeyForProvider;
let resolveEnvApiKeyVarName: typeof import("./models-config.providers.secrets.js").resolveEnvApiKeyVarName;
let resolveMissingProviderApiKey: typeof import("./models-config.providers.secrets.js").resolveMissingProviderApiKey;
beforeEach(async () => {
vi.doUnmock("../plugins/provider-runtime.js");
vi.resetModules();
({ resolveApiKeyForProvider } = await import("./model-auth.js"));
({ resolveEnvApiKeyVarName, resolveMissingProviderApiKey } =
await import("./models-config.providers.secrets.js"));
});
function createTestModel(id: string): ModelDefinitionConfig {
return {
id,
@@ -93,17 +79,14 @@ describe("NVIDIA provider", () => {
expect(provider.models?.length).toBeGreaterThan(0);
});
it("resolves the nvidia api key value from env", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ NVIDIA_API_KEY: "nvidia-test-api-key" }, async () => {
const auth = await resolveApiKeyForProvider({
provider: "nvidia",
agentDir,
});
it("resolves the nvidia api key value from env", () => {
const auth = resolveEnvApiKey("nvidia", {
NVIDIA_API_KEY: "nvidia-test-api-key",
} as NodeJS.ProcessEnv);
expect(auth.apiKey).toBe("nvidia-test-api-key");
expect(auth.mode).toBe("api-key");
expect(auth.source).toContain("NVIDIA_API_KEY");
expect(auth).toEqual({
apiKey: "nvidia-test-api-key",
source: "env: NVIDIA_API_KEY",
});
});
});

View File

@@ -21,10 +21,21 @@ describe("Ollama auto-discovery", () => {
delete process.env.OLLAMA_API_KEY;
});
function createCleanProviderDiscoveryEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.OPENCLAW_BUNDLED_PLUGINS_DIR;
delete env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
delete env.OPENCLAW_SKIP_PROVIDERS;
delete env.OPENCLAW_SKIP_CHANNELS;
delete env.OPENCLAW_SKIP_CRON;
delete env.OPENCLAW_TEST_MINIMAL_GATEWAY;
return env;
}
function createCatalogLoadEnv(): NodeJS.ProcessEnv {
originalFetch = globalThis.fetch;
return {
...process.env,
...createCleanProviderDiscoveryEnv(),
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "ollama",
VITEST: "1",
NODE_ENV: "test",
@@ -33,7 +44,7 @@ describe("Ollama auto-discovery", () => {
function createDiscoveryRunEnv(): NodeJS.ProcessEnv {
return {
...process.env,
...createCleanProviderDiscoveryEnv(),
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "ollama",
VITEST: "",
NODE_ENV: "development",

View File

@@ -11,6 +11,7 @@ import {
} from "../plugins/provider-discovery.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import type { ProviderConfig } from "./models-config.providers.js";
@@ -351,6 +352,43 @@ describe("Ollama provider", () => {
});
});
it("should use synthetic local auth for configured remote providers without apiKey", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("http://remote-ollama:11434");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.models).toHaveLength(1);
});
});
it("should preserve explicit apiKey from configured remote providers", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn(async (input: unknown) => {

View File

@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { planOpenClawModelsJson } from "./models-config.plan.js";
import {
planOpenClawModelsJson,
planOpenClawModelsJsonWithDeps,
type ResolveImplicitProvidersForModelsJson,
} from "./models-config.plan.js";
import type { ProviderConfig } from "./models-config.providers.secrets.js";
import { createProviderAuthResolver } from "./models-config.providers.secrets.js";
vi.mock("./models-config.providers.js", () => ({
@@ -71,6 +76,54 @@ describe("models-config", () => {
).toBe("https://copilot.local");
});
it("passes explicit provider config to implicit discovery so plugins can skip duplicates", async () => {
const resolveImplicitProviders = vi.fn<ResolveImplicitProvidersForModelsJson>(
async ({ explicitProviders }) => {
expect(explicitProviders.vllm?.baseUrl).toBe("http://127.0.0.1:8000/v1");
return {};
},
);
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: {
models: {
providers: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [],
},
},
},
},
agentDir: "/tmp/openclaw-agent",
env: { VLLM_API_KEY: "test-vllm-key" } as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{ resolveImplicitProviders },
);
expect(resolveImplicitProviders).toHaveBeenCalledOnce();
expect(plan).toEqual({
action: "write",
contents: `${JSON.stringify(
{
providers: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [],
},
},
},
null,
2,
)}\n`,
});
});
it("uses tokenRef env var when github-copilot profile omits plaintext token", () => {
const auth = createProviderAuthResolver(
{
@@ -96,4 +149,59 @@ describe("models-config", () => {
profileId: "github-copilot:default",
});
});
it("writes an implicit github-copilot provider discovered from a token exchange", async () => {
const plan = await planCopilotWithImplicitProvider({
provider: { baseUrl: "https://api.copilot.example", models: [] },
});
expectCopilotProviderFromPlan(plan).toEqual({
baseUrl: "https://api.copilot.example",
models: [],
});
});
it("writes default github-copilot baseUrl when the token exchange fails", async () => {
const plan = await planCopilotWithImplicitProvider({
provider: { baseUrl: "https://api.individual.githubcopilot.com", models: [] },
});
expectCopilotProviderFromPlan(plan)?.toEqual({
baseUrl: "https://api.individual.githubcopilot.com",
models: [],
});
});
});
function createCopilotImplicitResolver(
provider: ProviderConfig,
): ResolveImplicitProvidersForModelsJson {
return async () => ({ "github-copilot": provider });
}
async function planCopilotWithImplicitProvider(params: { provider: ProviderConfig }) {
return await planOpenClawModelsJsonWithDeps(
{
cfg: { models: { providers: {} } },
agentDir: "/tmp/openclaw-agent",
env: {} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: createCopilotImplicitResolver(params.provider),
},
);
}
function expectCopilotProviderFromPlan(
plan: Awaited<ReturnType<typeof planCopilotWithImplicitProvider>>,
) {
expect(plan.action).toBe("write");
const parsed =
plan.action === "write"
? (JSON.parse(plan.contents) as { providers?: Record<string, unknown> })
: {};
expect(parsed.providers?.["github-copilot"]).toBeDefined();
return expect(parsed.providers?.["github-copilot"]);
}

View File

@@ -9,7 +9,11 @@ import {
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import { isHighSignalLiveModelRef, selectHighSignalLiveItems } from "./live-model-filter.js";
import {
isHighSignalLiveModelRef,
resolveHighSignalLiveModelLimit,
selectHighSignalLiveItems,
} from "./live-model-filter.js";
import { createLiveTargetMatcher } from "./live-target-matcher.js";
import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "./live-test-helpers.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
@@ -148,7 +152,7 @@ function isModelNotFoundErrorMessage(raw: string): boolean {
if (/does not exist or you do not have access/i.test(msg)) {
return true;
}
if (/deprecated/i.test(msg) && /upgrade to/i.test(msg)) {
if (/deprecated/i.test(msg) && /(upgrade|transition) to/i.test(msg)) {
return true;
}
if (/stealth model/i.test(msg) && /find it here/i.test(msg)) {
@@ -170,6 +174,14 @@ describe("isModelNotFoundErrorMessage", () => {
expect(isModelNotFoundErrorMessage("404 model not_found")).toBe(true);
expect(isModelNotFoundErrorMessage("404 model not-found")).toBe(true);
});
it("matches deprecated free model transition messages", () => {
expect(
isModelNotFoundErrorMessage(
"404 The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
),
).toBe(true);
});
});
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
@@ -412,7 +424,10 @@ describeLive("live models (profile keys)", () => {
const allowNotFoundSkip = useModern;
const providers = parseProviderFilter(process.env.OPENCLAW_LIVE_PROVIDERS);
const perModelTimeoutMs = toInt(process.env.OPENCLAW_LIVE_MODEL_TIMEOUT_MS, 30_000);
const maxModels = toInt(process.env.OPENCLAW_LIVE_MAX_MODELS, 0);
const maxModels = resolveHighSignalLiveModelLimit({
rawMaxModels: process.env.OPENCLAW_LIVE_MAX_MODELS,
useExplicitModels: useExplicit,
});
const targetMatcher = createLiveTargetMatcher({
providerFilter: providers,
modelFilter: filter,
@@ -781,6 +796,11 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (provider unavailable)`);
break;
}
if (allowNotFoundSkip && isModelNotFoundErrorMessage(message)) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (model not found)`);
break;
}
if (allowNotFoundSkip && isAudioOnlyModelErrorMessage(message)) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (audio-only model)`);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { ensureOwnerDisplaySecret, resolveOwnerDisplaySetting } from "./owner-display.js";
describe("resolveOwnerDisplaySetting", () => {

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
export type OwnerDisplaySetting = {

View File

@@ -349,10 +349,17 @@ function resolveExplicitModelWithRegistry(params: {
runtimeHooks?: ProviderRuntimeHooks;
}): { kind: "resolved"; model: Model<Api> } | { kind: "suppressed" } | undefined {
const { provider, modelId, modelRegistry, cfg, agentDir, runtimeHooks } = params;
if (shouldSuppressBuiltInModel({ provider, id: modelId })) {
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
if (
shouldSuppressBuiltInModel({
provider,
id: modelId,
baseUrl: providerConfig?.baseUrl,
config: cfg,
})
) {
return { kind: "suppressed" };
}
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const inlineMatch = findInlineModelMatch({
providers: cfg?.models?.providers ?? {},
provider,

View File

@@ -1,19 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
vi.unmock("../plugins/provider-runtime.js");
vi.unmock("../plugins/provider-runtime.runtime.js");
vi.unmock("../plugins/providers.runtime.js");
let resolveTranscriptPolicy: typeof import("./transcript-policy.js").resolveTranscriptPolicy;
const MISTRAL_PLUGIN_CONFIG = {
plugins: {
entries: {
mistral: { enabled: true },
},
},
} as OpenClawConfig;
function createProviderRuntimeSmokeContext(): {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir: string;
} {
const env = { ...process.env };
delete env.OPENCLAW_BUNDLED_PLUGINS_DIR;
delete env.OPENCLAW_SKIP_PROVIDERS;
delete env.OPENCLAW_SKIP_CHANNELS;
delete env.OPENCLAW_SKIP_CRON;
delete env.OPENCLAW_TEST_MINIMAL_GATEWAY;
return {
config: {},
env,
workspaceDir: process.cwd(),
};
}
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../plugins/provider-runtime.runtime.js");
vi.doUnmock("../plugins/providers.runtime.js");
({ resolveTranscriptPolicy } = await import("./transcript-policy.js"));
});
describe("resolveTranscriptPolicy e2e smoke", () => {
it("uses images-only sanitization without tool-call id rewriting for OpenAI models", () => {
const policy = resolveTranscriptPolicy({
...createProviderRuntimeSmokeContext(),
provider: "openai",
modelId: "gpt-4o",
modelApi: "openai",
@@ -25,8 +55,10 @@ describe("resolveTranscriptPolicy e2e smoke", () => {
it("uses strict9 tool-call sanitization for Mistral-family models", () => {
const policy = resolveTranscriptPolicy({
...createProviderRuntimeSmokeContext(),
provider: "mistral",
modelId: "mistral-large-latest",
config: MISTRAL_PLUGIN_CONFIG,
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict9");

View File

@@ -0,0 +1,234 @@
import type { SkillCommandSpec } from "../agents/skills.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { isCommandFlagEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import { listPluginCommands } from "../plugins/commands.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import {
listChatCommands,
listChatCommandsForConfig,
type ChatCommandDefinition,
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
const CATEGORY_LABELS: Record<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
export function buildHelpMessage(cfg?: OpenClawConfig): string {
const lines = [" Help", ""];
lines.push("Session");
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
const optionParts = ["/think <level>", "/model <id>", "/fast status|on|off", "/verbose on|off"];
if (isCommandFlagEnabled(cfg, "config")) {
optionParts.push("/config");
}
if (isCommandFlagEnabled(cfg, "debug")) {
optionParts.push("/debug");
}
lines.push("Options");
lines.push(` ${optionParts.join(" | ")}`);
lines.push("");
lines.push("Status");
lines.push(" /status | /tasks | /whoami | /context");
lines.push("");
lines.push("Skills");
lines.push(" /skill <name> [input]");
lines.push("");
lines.push("More: /commands for full list, /tools for available capabilities");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
forcePaginatedList?: boolean;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: normalizeOptionalString(command.textAliases[0]) || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter(
(alias) =>
normalizeLowercaseStringOrEmpty(alias) !== normalizeLowercaseStringOrEmpty(primary),
)
.filter((alias) => {
const key = normalizeLowercaseStringOrEmpty(alias);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
}
type CommandsListItem = {
label: string;
text: string;
};
function buildCommandItems(
commands: ChatCommandDefinition[],
pluginCommands: ReturnType<typeof listPluginCommands>,
): CommandsListItem[] {
const grouped = groupCommandsByCategory(commands);
const items: CommandsListItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) {
continue;
}
const label = CATEGORY_LABELS[category];
for (const command of categoryCommands) {
items.push({ label, text: formatCommandEntry(command) });
}
}
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
items.push({
label: "Plugins",
text: `/${command.name}${pluginLabel} - ${command.description}`,
});
}
return items;
}
function formatCommandList(items: CommandsListItem[]): string {
const lines: string[] = [];
let currentLabel: string | null = null;
for (const item of items) {
if (item.label !== currentLabel) {
if (lines.length > 0) {
lines.push("");
}
lines.push(item.label);
currentLabel = item.label;
}
lines.push(` ${item.text}`);
}
return lines.join("\n");
}
export function buildCommandsMessage(
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): string {
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
return result.text;
}
export function buildCommandsMessagePaginated(
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): CommandsMessageResult {
const page = Math.max(1, options?.page ?? 1);
const surface = normalizeOptionalLowercaseString(options?.surface);
const prefersPaginatedList =
options?.forcePaginatedList === true ||
Boolean(surface && getChannelPlugin(surface)?.commands?.buildCommandsListChannelData);
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
const pluginCommands = listPluginCommands();
const items = buildCommandItems(commands, pluginCommands);
if (!prefersPaginatedList) {
const lines = [" Slash commands", ""];
lines.push(formatCommandList(items));
lines.push("", "More: /tools for available capabilities");
return {
text: lines.join("\n").trim(),
totalPages: 1,
currentPage: 1,
hasNext: false,
hasPrev: false,
};
}
const totalCommands = items.length;
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
const endIndex = startIndex + COMMANDS_PER_PAGE;
const pageItems = items.slice(startIndex, endIndex);
const lines = [` Commands (${currentPage}/${totalPages})`, ""];
lines.push(formatCommandList(pageItems));
return {
text: lines.join("\n").trim(),
totalPages,
currentPage,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
};
}

View File

@@ -15,15 +15,12 @@ import {
resolveReasoningDefault,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import type { ThinkLevel } from "./directives.js";
import { resolveStoredModelOverride } from "./stored-model-override.js";
export type ModelDirectiveSelection = {
provider: string;
@@ -142,61 +139,6 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
return dist;
}
export type StoredModelOverride = {
provider?: string;
model: string;
source: "session" | "parent";
};
function resolveParentSessionKeyCandidate(params: {
sessionKey?: string;
parentSessionKey?: string;
}): string | null {
const explicit = normalizeOptionalString(params.parentSessionKey);
if (explicit && explicit !== params.sessionKey) {
return explicit;
}
const derived = resolveSessionParentSessionKey(params.sessionKey);
if (derived && derived !== params.sessionKey) {
return derived;
}
return null;
}
export function resolveStoredModelOverride(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
parentSessionKey?: string;
defaultProvider: string;
}): StoredModelOverride | null {
const direct = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: params.sessionEntry?.providerOverride,
overrideModel: params.sessionEntry?.modelOverride,
});
if (direct) {
return { ...direct, source: "session" };
}
const parentKey = resolveParentSessionKeyCandidate({
sessionKey: params.sessionKey,
parentSessionKey: params.parentSessionKey,
});
if (!parentKey || !params.sessionStore) {
return null;
}
const parentEntry = params.sessionStore[parentKey];
const parentOverride = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: parentEntry?.providerOverride,
overrideModel: parentEntry?.modelOverride,
});
if (!parentOverride) {
return null;
}
return { ...parentOverride, source: "parent" };
}
function scoreFuzzyMatch(params: {
provider: string;
model: string;

View File

@@ -0,0 +1,59 @@
import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js";
import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
export type StoredModelOverride = {
provider?: string;
model: string;
source: "session" | "parent";
};
function resolveParentSessionKeyCandidate(params: {
sessionKey?: string;
parentSessionKey?: string;
}): string | null {
const explicit = normalizeOptionalString(params.parentSessionKey);
if (explicit && explicit !== params.sessionKey) {
return explicit;
}
const derived = resolveSessionParentSessionKey(params.sessionKey);
if (derived && derived !== params.sessionKey) {
return derived;
}
return null;
}
export function resolveStoredModelOverride(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
parentSessionKey?: string;
defaultProvider: string;
}): StoredModelOverride | null {
const direct = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: params.sessionEntry?.providerOverride,
overrideModel: params.sessionEntry?.modelOverride,
});
if (direct) {
return { ...direct, source: "session" };
}
const parentKey = resolveParentSessionKeyCandidate({
sessionKey: params.sessionKey,
parentSessionKey: params.parentSessionKey,
});
if (!parentKey || !params.sessionStore) {
return null;
}
const parentEntry = params.sessionStore[parentKey];
const parentOverride = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: parentEntry?.providerOverride,
overrideModel: parentEntry?.modelOverride,
});
if (!parentOverride) {
return null;
}
return { ...parentOverride, source: "parent" };
}

View File

@@ -10,13 +10,10 @@ import {
import { resolveExtraParams } from "../agents/pi-embedded-runner/extra-params.js";
import { resolveOpenAITextVerbosity } from "../agents/pi-embedded-runner/openai-stream-wrappers.js";
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { describeToolForVerbose } from "../agents/tool-description-summary.js";
import { normalizeToolName } from "../agents/tool-policy-shared.js";
import type { EffectiveToolInventoryResult } from "../agents/tools-effective-inventory.js";
import { resolveChannelModelOverride } from "../channels/model-overrides.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { isCommandFlagEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveMainSessionKey,
@@ -29,7 +26,6 @@ import { readLatestSessionUsageFromTranscript } from "../gateway/session-utils.f
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { resolveCommitHash } from "../infra/git-commit.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
import { listPluginCommands } from "../plugins/commands.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -44,12 +40,13 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import {
listChatCommands,
listChatCommandsForConfig,
type ChatCommandDefinition,
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
export {
buildCommandsMessage,
buildCommandsMessagePaginated,
buildHelpMessage,
type CommandsMessageOptions,
type CommandsMessageResult,
} from "./command-status-builders.js";
import { resolveActiveFallbackState } from "./fallback-state.js";
import { formatProviderModelRef, resolveSelectedAndActiveModel } from "./model-runtime.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
@@ -829,89 +826,6 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
const CATEGORY_LABELS: Record<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
export function buildHelpMessage(cfg?: OpenClawConfig): string {
const lines = [" Help", ""];
lines.push("Session");
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
const optionParts = ["/think <level>", "/model <id>", "/fast status|on|off", "/verbose on|off"];
if (isCommandFlagEnabled(cfg, "config")) {
optionParts.push("/config");
}
if (isCommandFlagEnabled(cfg, "debug")) {
optionParts.push("/debug");
}
lines.push("Options");
lines.push(` ${optionParts.join(" | ")}`);
lines.push("");
lines.push("Status");
lines.push(" /status | /tasks | /whoami | /context");
lines.push("");
lines.push("Skills");
lines.push(" /skill <name> [input]");
lines.push("");
lines.push("More: /commands for full list, /tools for available capabilities");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
forcePaginatedList?: boolean;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
type ToolsMessageItem = {
id: string;
name: string;
@@ -996,138 +910,3 @@ export function buildToolsMessage(
}
return lines.join("\n");
}
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: normalizeOptionalString(command.textAliases[0]) || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter(
(alias) =>
normalizeLowercaseStringOrEmpty(alias) !== normalizeLowercaseStringOrEmpty(primary),
)
.filter((alias) => {
const key = normalizeLowercaseStringOrEmpty(alias);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
}
type CommandsListItem = {
label: string;
text: string;
};
function buildCommandItems(
commands: ChatCommandDefinition[],
pluginCommands: ReturnType<typeof listPluginCommands>,
): CommandsListItem[] {
const grouped = groupCommandsByCategory(commands);
const items: CommandsListItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) {
continue;
}
const label = CATEGORY_LABELS[category];
for (const command of categoryCommands) {
items.push({ label, text: formatCommandEntry(command) });
}
}
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
items.push({
label: "Plugins",
text: `/${command.name}${pluginLabel} - ${command.description}`,
});
}
return items;
}
function formatCommandList(items: CommandsListItem[]): string {
const lines: string[] = [];
let currentLabel: string | null = null;
for (const item of items) {
if (item.label !== currentLabel) {
if (lines.length > 0) {
lines.push("");
}
lines.push(item.label);
currentLabel = item.label;
}
lines.push(` ${item.text}`);
}
return lines.join("\n");
}
export function buildCommandsMessage(
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): string {
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
return result.text;
}
export function buildCommandsMessagePaginated(
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): CommandsMessageResult {
const page = Math.max(1, options?.page ?? 1);
const surface = normalizeOptionalLowercaseString(options?.surface);
const prefersPaginatedList =
options?.forcePaginatedList === true ||
Boolean(surface && getChannelPlugin(surface)?.commands?.buildCommandsListChannelData);
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
const pluginCommands = listPluginCommands();
const items = buildCommandItems(commands, pluginCommands);
if (!prefersPaginatedList) {
const lines = [" Slash commands", ""];
lines.push(formatCommandList(items));
lines.push("", "More: /tools for available capabilities");
return {
text: lines.join("\n").trim(),
totalPages: 1,
currentPage: 1,
hasNext: false,
hasPrev: false,
};
}
const totalCommands = items.length;
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
const endIndex = startIndex + COMMANDS_PER_PAGE;
const pageItems = items.slice(startIndex, endIndex);
const lines = [` Commands (${currentPage}/${totalPages})`, ""];
lines.push(formatCommandList(pageItems));
return {
text: lines.join("\n").trim(),
totalPages,
currentPage,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
};
}

View File

@@ -886,7 +886,7 @@ describe("update-cli", () => {
portableGitMingw,
portableGitUsr,
]);
expect(updateOptions?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(updateOptions?.env?.NPM_CONFIG_SCRIPT_SHELL).toBeUndefined();
expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});

View File

@@ -22,6 +22,16 @@ import {
} from "./agent-command.test-support.js";
import { agentCommand } from "./agent.js";
vi.mock("../agents/auth-profiles/store.js", async () => {
const actual = await vi.importActual<typeof import("../agents/auth-profiles/store.js")>(
"../agents/auth-profiles/store.js",
);
return {
...actual,
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
};
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),

View File

@@ -55,6 +55,16 @@ vi.mock("../agents/auth-profiles.js", async () => {
};
});
vi.mock("../agents/auth-profiles/store.js", async () => {
const actual = await vi.importActual<typeof import("../agents/auth-profiles/store.js")>(
"../agents/auth-profiles/store.js",
);
return {
...actual,
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
};
});
vi.mock("../agents/workspace.js", () => {
const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace";
return {
@@ -950,14 +960,6 @@ describe("agentCommand", () => {
});
});
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
await expectPersistedSessionFile({
seedKey: "agent:main:telegram:group:123:topic:456",
sessionId: "sess-topic",
expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-topic.jsonl`,
});
});
it("derives session key from --agent when no routing target is provided", async () => {
await withTempHome(async (home) => {
const callArgs = await runWithDefaultAgentConfig({

View File

@@ -1,4 +1,4 @@
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.types.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
export type {

View File

@@ -2,7 +2,7 @@ import { resolveProviderMatch } from "../plugins/provider-auth-choice-helpers.js
import { resolvePluginProviders } from "../plugins/provider-auth-choice.runtime.js";
import type { ProviderAuthKind } from "../plugins/types.js";
import { normalizeTokenProviderInput } from "./auth-choice.apply-helpers.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js";
import type { AuthChoice } from "./onboard-types.js";
function resolveProviderAuthChoiceByKind(params: {

View File

@@ -1,4 +1,4 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js";
export async function applyAuthChoiceOAuth(
_params: ApplyAuthChoiceParams,

View File

@@ -6,7 +6,7 @@ import {
} from "../plugins/provider-auth-choice.js";
import type { ProviderPlugin } from "../plugins/types.js";
import type { ProviderAuthMethod } from "../plugins/types.js";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.types.js";
const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => []));
const resolveProviderPluginChoice = vi.hoisted(() =>

View File

@@ -1,29 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import { applyAuthChoiceLoadedPluginProvider } from "../plugins/provider-auth-choice.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-providers.js";
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js";
export type ApplyAuthChoiceParams = {
authChoice: AuthChoice;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
opts?: Partial<OnboardOptions>;
};
export type ApplyAuthChoiceResult = {
config: OpenClawConfig;
agentModelOverride?: string;
};
export type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js";
export async function applyAuthChoice(
params: ApplyAuthChoiceParams,

View File

@@ -0,0 +1,21 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
export type ApplyAuthChoiceParams = {
authChoice: AuthChoice;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
opts?: Partial<OnboardOptions>;
};
export type ApplyAuthChoiceResult = {
config: OpenClawConfig;
agentModelOverride?: string;
};

View File

@@ -81,7 +81,7 @@ function validateAvailableModels(availableModels: unknown): Model<Api>[] {
return availableModels as Model<Api>[];
}
function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
function loadAvailableModels(registry: ModelRegistry, cfg: OpenClawConfig): Model<Api>[] {
let availableModels: unknown;
try {
availableModels = registry.getAvailable();
@@ -90,7 +90,13 @@ function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
}
try {
return validateAvailableModels(availableModels).filter(
(model) => !shouldSuppressBuiltInModel({ provider: model.provider, id: model.id }),
(model) =>
!shouldSuppressBuiltInModel({
provider: model.provider,
id: model.id,
baseUrl: model.baseUrl,
config: cfg,
}),
);
} catch (err) {
throw normalizeAvailabilityError(err);
@@ -98,20 +104,26 @@ function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
}
export async function loadModelRegistry(
_cfg: OpenClawConfig,
cfg: OpenClawConfig,
_opts?: { sourceConfig?: OpenClawConfig },
) {
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);
const models = registry
.getAll()
.filter((model) => !shouldSuppressBuiltInModel({ provider: model.provider, id: model.id }));
const models = registry.getAll().filter(
(model) =>
!shouldSuppressBuiltInModel({
provider: model.provider,
id: model.id,
baseUrl: model.baseUrl,
config: cfg,
}),
);
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
const availableModels = loadAvailableModels(registry);
const availableModels = loadAvailableModels(registry, cfg);
availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id)));
} catch (err) {
if (!shouldFallbackToAuthHeuristics(err)) {

Some files were not shown because too many files have changed in this diff Show More