Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
a3c2365b7b fix(agent/openclaw): extract real model from meta.agentMeta.model
OpenClaw's `--json` result blob carries the actual LLM identifier in
`meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`),
alongside `provider` and the usage breakdown. The backend was reading
the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping
the `model` field entirely, then attributing every run's tokens to
`opts.Model` — which for openclaw is the *agent name* passed via
`--agent`, not a real model identifier — falling all the way through to
"unknown" when no agent.model was configured.

Surface the runtime-reported model:

- `openclawEventResult` gains a `model` string.
- `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty
  string when absent for forward-compat with older runtimes / partial
  outputs).
- `processOutput` propagates it through the result-blob branch.
- `Execute`'s usage map prefers `scanResult.model`, falling back to
  `opts.Model` then `"unknown"` — preserving the prior behavior path
  for any runtime that doesn't surface its own model yet.

Two unit tests cover both the populated and missing cases.

Refs: #1395
2026-04-21 14:20:24 +08:00
2 changed files with 105 additions and 3 deletions

View File

@@ -108,12 +108,19 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
// Build usage map. OpenClaw doesn't report model per-step, so we
// attribute all usage to the configured model (or "unknown").
// Build usage map. Prefer the model openclaw reported in
// `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`).
// Fall back to opts.Model — which for openclaw is the agent name
// passed via `--agent`, not a real model identifier — only when
// the runtime didn't surface its own model. Last resort is the
// daemon's `unknown` placeholder.
var usage map[string]TokenUsage
u := scanResult.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := opts.Model
model := scanResult.model
if model == "" {
model = opts.Model
}
if model == "" {
model = "unknown"
}
@@ -186,6 +193,12 @@ type openclawEventResult struct {
output string
sessionID string
usage TokenUsage
// model is the LLM identifier reported by openclaw in its result blob
// (`meta.agentMeta.model`). Empty when the run did not emit it (older
// openclaw versions, partial outputs). Distinct from `opts.Model`,
// which for the openclaw backend is the openclaw *agent* name passed
// via `--agent`, not the underlying model.
model string
}
// processOutput reads the JSON output from openclaw --json stderr and returns
@@ -204,6 +217,7 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
var output strings.Builder
var sessionID string
var model string
var usage TokenUsage
finalStatus := "completed"
var finalError string
@@ -282,6 +296,9 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
if res.sessionID != "" {
sessionID = res.sessionID
}
if res.model != "" {
model = res.model
}
// Prefer usage from the final result if no streaming events reported it.
u := res.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
@@ -331,6 +348,7 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
output: output.String(),
sessionID: sessionID,
usage: usage,
model: model,
}
}
@@ -379,11 +397,19 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
}
var sessionID string
var model string
var usage TokenUsage
if result.Meta.AgentMeta != nil {
if sid, ok := result.Meta.AgentMeta["sessionId"].(string); ok {
sessionID = sid
}
// `meta.agentMeta.model` is openclaw's true LLM identifier
// (e.g. "deepseek-chat", "claude-sonnet-4"). Take it as-is — the
// dashboard expects whatever string the runtime reports, mirroring
// claude/pi/codex which read model directly off their stream.
if m, ok := result.Meta.AgentMeta["model"].(string); ok {
model = strings.TrimSpace(m)
}
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
usage = parseOpenclawUsage(u)
}
@@ -394,6 +420,7 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
output: output.String(),
sessionID: sessionID,
usage: usage,
model: model,
}
}

View File

@@ -1079,6 +1079,81 @@ func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) {
}
}
func TestOpenclawProcessOutputExtractsModelFromAgentMeta(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Mirrors a real openclaw `--json` blob captured locally: agentMeta
// carries the actual LLM identifier under `model`, alongside the
// session id, provider, and usage. The dashboard previously bucketed
// usage under `unknown` because this field wasn't read; we now want
// it surfaced as the runtime's reported model string.
result := openclawResult{
Payloads: []openclawPayload{{Text: "ok"}},
Meta: openclawMeta{
DurationMs: 9501,
AgentMeta: map[string]any{
"sessionId": "multica-1776752018613706000",
"provider": "deepseek",
"model": "deepseek-chat",
"usage": map[string]any{
"input": float64(414),
"output": float64(163),
"cacheRead": float64(33280),
"cacheWrite": float64(0),
},
},
},
}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.model != "deepseek-chat" {
t.Errorf("model: got %q, want %q", res.model, "deepseek-chat")
}
if res.sessionID != "multica-1776752018613706000" {
t.Errorf("sessionID: got %q", res.sessionID)
}
if res.usage.InputTokens != 414 {
t.Errorf("input tokens: got %d, want 414", res.usage.InputTokens)
}
}
func TestOpenclawProcessOutputModelEmptyWhenAgentMetaOmitsIt(t *testing.T) {
t.Parallel()
// Older openclaw versions / partial outputs may not include `model`
// in agentMeta. processOutput must surface "" so the Execute loop
// can fall back to opts.Model (the agent name) and ultimately the
// daemon's "unknown" placeholder, preserving prior behavior for
// runtimes that haven't been upgraded.
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "ok"}},
Meta: openclawMeta{
AgentMeta: map[string]any{
"sessionId": "ses_xyz",
"usage": map[string]any{
"input": float64(10),
"output": float64(5),
},
},
},
}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.model != "" {
t.Errorf("model: got %q, want empty", res.model)
}
}
func countOccurrences(args []string, s string) int {
n := 0
for _, a := range args {