mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
transcript-all
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ffa8e16369 |
MUL-5228 fix(usage): bill Grok at xAI's reported cost, fix $0 resumed sessions (#5841)
* fix(agent): attribute Grok usage from the turn's own model id A resumed Grok session with no configured model recorded its entire spend under the model id "unknown", which matches no pricing row — so the task reported $0 cost instead of its real spend. grok.go only learned the model from the session handshake, and ACP's `session/load` carries no model id (only `session/new` does). When neither the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately passes an empty model, leaving nothing to attribute the usage to. Every Grok turn stamps `result._meta.modelId` with what it actually billed against. Parse it in the shared ACP result parser and use it as the fallback in grok.go. Other ACP backends are untouched — they keep whatever the handshake gave them. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(metrics): price the Grok catalog in server-side cost metrics server/internal/metrics/pricing.go carried no Grok rows at all, so RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd reported zero Grok spend while the tokens accumulated in llm_unpriced_tokens. Internal cost monitoring simply could not see Grok. Add the six SKUs xAI publishes rates for, mirroring the frontend table in packages/views/runtimes/utils.ts. Aliases are anchored exact matches like the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the price sheet) stays unmapped instead of inheriting a guessed rate. Short-context tier on purpose: xAI bills a request at 2x once its prompt reaches 200K tokens, but a usage record aggregates every model call in a turn and cannot say which tier an individual request hit. A regression test re-derives the cost of a real grok 0.2.106 turn from the table and checks it against the costUsdTicks xAI returned for that turn. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): scope the Grok cost claim to what was actually fixed The v0.4.9 entry promised "accurate cost" in all four languages, but the fix corrected catalog pricing and cached-input double-counting — it did not implement xAI's 2x long-context tier, so a turn whose requests reach 200K prompt tokens still under-reports by up to 50%. Say what was fixed instead. Also correct two stale claims in the pricing comment: the daemon tags usage rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are what make them resolve), and record why thresholding the long-context tier on an aggregated row would be worse than not pricing it at all. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(usage): carry the provider's own cost through to the usage record Cost has always been derived client-side as tokens x a static rate, which cannot express request-level pricing rules. xAI bills a Grok request at 2x once its prompt reaches 200K tokens, and a task_usage row aggregates every model call in a turn — so the stored token counts genuinely cannot say which tier any individual request hit. Thresholding on the aggregate would be worse than the status quo: it turns a bounded 50% under-estimate into an unbounded over-estimate for turns made of many short requests. Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`. Parse it, carry it through agent -> daemon -> API, and store it on task_usage as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact end to end). NULL means the provider reported no cost — every pre-existing row and every provider that doesn't return one. No backfill: there is no authoritative figure to recover for those, and inventing one is the guess this removes. A single hourly bucket can mix rows that carry a cost with rows that don't, so task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative side, and `uncosted_*_tokens` carry exactly the tokens that still need a rate-table estimate. Consumers report authoritative + estimate(uncosted), which degrades to today's behaviour when nothing in the bucket is authoritative. The existing token columns keep covering every row, so token displays are untouched. The new columns are additive with defaults, so the unique key, the dirty-queue shape, and migration 102's triggers are unaffected. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(usage): prefer the provider's own cost over the rate table With the authoritative figure now stored, both cost consumers use it: the usage dashboard (estimateCost / estimateCostBreakdown) and the server-side llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`, so a row or bucket that mixes priced and unpriced sources stays whole. The static rate tables remain, but for Grok they are now a fallback — they still price usage recorded by a daemon too old to report cost, and every provider that reports none. Custom pricing overrides likewise apply only to the estimated half: they are a user's guess at a rate, and the authoritative half is not a guess. A model with no rate-table row but a provider-reported cost now also drops out of the "unmapped models" banner, since asking the user to supply a rate for it would invite overriding a real bill. llm_cost_usd is labelled by token_type and the provider reports one number per turn, so the charge is distributed across the buckets in the rate table's own proportions. Only the total is authoritative; the split stays an estimate, which is why this scales the existing buckets rather than inventing a label. estimateCostBreakdown does the same, keeping the stacked chart summing to the headline figure instead of silently under-drawing every Grok row. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): say Grok cost now follows xAI's actual charge The earlier wording scoped the claim down to catalog pricing and cached input because the long-context tier was still unhandled. It is handled now — the cost comes from what xAI charged for the turn — so the entry can say so. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(usage): keep the provider's cost when the model has no rate row Both cost consumers bailed out before reading the authoritative figure when the rate table had no row for the model. A `grok-composer-*` turn — in the Grok Build catalog, absent from xAI's price sheet — was therefore reported as $0 spend even though xAI told us exactly what it charged. Worse on the client: estimateCost returned the real cost while estimateCostBreakdown returned zeros, so the headline and the stacked chart disagreed on precisely the rows whose cost is exact — and the unmapped-models banner was (correctly) hidden, so nothing explained the discrepancy. Handle the charge before the rate lookup in both places. Without rates there is nothing to split a total by, so it lands whole in the `input` bucket, the same fallback distributeAuthoritativeCost already uses when it has no shape to scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced" describes the rate table, not the money. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * perf(usage): drop the historical rewrite from the cost-split migration Migration 213 rewrote every existing task_usage_hourly row to seed the uncosted counters. That is a full-table UPDATE inside a schema migration — lock time, WAL and bloat all scaling with table size — for rows this issue explicitly does not care about. Deleting the UPDATE alone would have zeroed historical cost: with `NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs estimating", so every pre-split bucket would report $0 until the rollup happened to touch it. Make the uncosted columns nullable with no default instead. NULL means "never recomputed since the split existed", readers COALESCE it to the row's own token total ("estimate all of it"), and the pre-split behaviour is preserved exactly — with nothing to seed, so no rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL. Rows heal into the split naturally as the rollup recomputes their buckets. Verified on a fresh database: a legacy-shaped row reads back as its full tokens to estimate, and a group mixing legacy and post-split buckets sums to the authoritative cost plus both rows' estimable tokens. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a5a42846e6 |
fix(daemon): retry with a fresh session only when the resume was actually rejected (MUL-4966) (#5715)
* fix(daemon): gate fresh-session retry on tools executed, not session id (MUL-4966) Switching provider accounts leaves the stored session id pointing at a conversation the new account does not own. The daemon still passes it to --resume, the provider rejects it, and the task dies before doing any work. The existing fresh-session fallback was supposed to catch this but was gated on `result.SessionID == ""`, which is not a lifecycle fact: - Too narrow: a backend that echoes the requested id back when it rejects a resume keeps SessionID non-empty, so the fallback never fired — the reported bug. - Too broad: a provider 401 before the first stream message also leaves SessionID empty, so an unrecoverable auth failure burned a second full run. Gate on `tools == 0` instead. That states the property that actually makes a retry safe — the agent executed no tool, so it mutated nothing, so re-running cannot double-post a comment (comment creation has no idempotency key and a duplicate re-fires its @mention triggers), reopen a PR, or re-plan on top of its own half-finished work in the reused workdir. Auth failures are excluded, mirroring retryableReasons in service/task.go. The predicate is extracted to shouldRetryWithFreshSession so the tests exercise production logic; both existing fallback tests re-implemented the condition inline and would not have caught a regression in it. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): gate fresh-session retry on a positive resume-rejected signal (MUL-4966) Review of the previous commit was right: `tools == 0` plus "not an auth error" answers whether re-running is *safe*, not whether a new session can *fix* the failure. Those are orthogonal, and answering the second by exclusion inverts the burden of proof — the failures a fresh session cures are a small enumerable set, while the ones it cannot are open-ended. Concretely, the previous predicate fresh-retried on provider_network, 429/529, quota, 5xx and unclassified startup failures. provider_network is the sharpest conflict: internal/service/task.go marks it resume-safe (MUL-4910) specifically so the platform retry inherits the session and continues the truncated conversation. Resetting the session first made that contract unsatisfiable, silently discarding conversation context on a transient blip — and rate limits got an immediate no-backoff re-run. Replace the inference with positive evidence: agent.Result gains an explicit ResumeRejected field, set only when a backend has proof the resume itself was refused. claude/codebuddy/qwen derive it from resumeWasRejected, which promotes the predicate resolveSessionID was already computing and encoding as the side effect of blanking SessionID — using an empty string to carry that meaning is what made the original bug possible. SessionID keeps being dropped for a rejected resume (a dead pointer must not be persisted), but it is no longer the signal the daemon reads to decide *why* a run failed. The six ACP backends that recover from "session not found" set the flag at the same points they already clear the id, so their existing recovery is not caught by the narrower gate. codex needs nothing: thread/resume already falls back to thread/start in-process, and deliberately does not on transport errors. Matching now includes the account-switch guardrail reported in #5704 (Claude Code 2.1.207, zh-CN): "400 此 session 已绑定另外的ai账号,请执行 /new 开启新 session". The en-US wording of the same guardrail has not been captured yet, so those variants are marked inferred in the source; a miss degrades to a terminal failure carrying the provider's raw text rather than a mis-routed run. Tests: backend-level fixtures drive ResumeRejected from real stream-json for both the account-binding 400 and a network drop, and the predicate now covers network/rate-limit/quota/5xx/auth/unclassified as explicit non-retries. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): restore fresh-session recovery for backends with no rejection signal (MUL-4966) Final review caught qwen regressing: its verified rejection string ("No saved session found with ID ...", already captured in testdata/qwen-code-0.20.0-resume-not-found.stderr.txt) was not in the phrase list, and qwen reports no session id on that path, so the new inclusion gate turned a working auto-recovery into a terminal failure. Auditing the other 17 resume-capable backends showed qwen was not alone. antigravity, copilot, cursor, deveco and opencode all recovered from a refused resume purely by reporting an empty SessionID, and none of them has any rejection detection to convert into ResumeRejected — copilot's own comment documents the hole (session.error before session.start), and antigravity's helper returns "" when "the CLI exited before dispatching". Making ResumeRejected the sole gate silently removed recovery from all five. Fixing that by guessing rejection phrases for five more CLIs is the wrong trade: no real output has been captured for any of them, and a false positive discards a recoverable session pointer. So the gate is now two tiers. Positive evidence (ResumeRejected) decides on its own where a backend can produce it. Where none is available, an empty SessionID still gates the retry — it proves no session was established, which is exactly what the pre-change behaviour relied on — minus the classes a fresh session provably cannot cure (network, rate limit, quota, provider 5xx, auth). That keeps the resume-safe contract in internal/service/task.go intact while restoring what these five backends had. Also renames claudeResumeRejectedPhrases to resumeRejectedPhrases: it is matched by claude, codebuddy and qwen, so a qwen-only string living under a claude-prefixed name would be actively misleading. Tests: qwen's existing missing-resume fixture now asserts ResumeRejected (verified failing without the phrase), and the predicate covers the no-signal tiers — retry when nothing was established, no retry once a session exists or the failure classifies as uncurable. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): scope the no-signal fallback to backends that cannot detect rejections (MUL-4966) Final review caught the compatibility path applying to every backend, not just the five it was justified for. shouldRetryWithFreshSession only saw (Result, priorSessionID, tools), so a false ResumeRejected could not be told apart from a backend that has no way to answer — and claude/codebuddy/qwen/ACP startup failures with no session id still fell through to the exclusion branch. That contradicted both the stated intent and the function's own doc comment ("where a backend can produce it, it is the whole answer"). Make the capability explicit. agent.ResumeRejectionUndetectable names the five backends that scrape SessionID out of stream output and have no rejection detection at all; the daemon takes provider and consults it, so a capable backend reporting false is now taken at its word. Membership is opt-in, so a new backend fails closed instead of silently inheriting a guess-based retry. Also completes the exclusion set: missing config, unavailable model, missing executable, unsupported runtime version and (defensively) agent timeout all have defined non-session remedies and were reaching `default: true`. What is left through stays narrow — unknown, process failure, unparseable output, context overflow — because a real rejection from these five most likely surfaces as a non-zero exit or unparseable output, none of them reporting one explicitly. Tests: one identical result asserted across all five undetectable backends (retries), twelve capable ones (no retry), and an unregistered provider (fails closed), plus table cases for each newly excluded reason. Classifier inputs were verified to map to the intended reasons rather than passing by accident. Also updates the ResumeRejected doc comment, which still said the daemon gates on it alone. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
66316c2614 |
fix(grok): harden ACP authentication and capabilities (#5440)
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
06d79c1750 |
feat(agent): add Grok Build CLI as an ACP runtime (#5285)
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP (`grok --no-auto-update agent --always-approve stdio`), reusing hermesClient like traecli and kimi. Includes daemon discovery, protocol_family migration 174, model discovery, MCP passthrough, thinking effort, frontend branding, and product docs. Follows xAI's documented headless ACP flow: after `initialize`, read the advertised `authMethods` and send `authenticate` (preferring xai.api_key when XAI_API_KEY is set, else the cached login token) before any session operation — a real, logged-in CLI rejects session/new and session/load without it. Model discovery performs the same handshake so it returns the live catalog instead of the static fallback. `--no-auto-update` is passed as a global flag (and kept daemon-owned in the blocked custom-arg set) so a background update check can't stall an unattended ACP task. Thinking effort uses the current `--effort` flag, and the minimum grok version is 0.2.89 (ACP + authenticate + session/load + session/set_model + MCP + --effort). Closes #2895 |