From 56fc87657c00fe97fd4b0d1db41634ef254afaee Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar <67579112+Siddhesh2377@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:39:06 +0530 Subject: [PATCH] harness: make claude-code and opencode model-aware from the catalog (context, price, usage, errors, passthrough) --- contracts/generate_console_binding.py | 26 ++- contracts/wally-cli-v1.openapi.json | 217 ++++++++++++++++++++++++++ src/account/console.cpp | 85 ++++++++++ src/account/console.h | 26 +++ src/account/console_contract.h | 169 +++++++++++++++++++- src/anthropic/messages.cpp | 79 +++++++++- src/anthropic/translate.cpp | 77 ++++++++- src/anthropic/translate.h | 10 ++ src/app.cpp | 70 ++++++++- src/app.h | 8 + src/commands/cmd_editors.cpp | 177 ++++++++++++++++++++- src/harness/opencode.cpp | 65 +++++++- src/harness/opencode.h | 9 +- tests/CMakeLists.txt | 2 +- tests/test_wally_opencode.cpp | 31 ++++ tests/test_wally_unit.cpp | 110 +++++++++++++ 16 files changed, 1136 insertions(+), 25 deletions(-) diff --git a/contracts/generate_console_binding.py b/contracts/generate_console_binding.py index 4910bf5..d64e2d5 100644 --- a/contracts/generate_console_binding.py +++ b/contracts/generate_console_binding.py @@ -44,13 +44,37 @@ def _enum_constant(value: str) -> str: def _resolve_type(schema: dict, schemas: dict) -> tuple[str, bool]: """Return (c++ type, is_optional). Nullable/anyOf-null collapses to optional.""" if "$ref" in schema: - return schema["$ref"].split("/")[-1], False + name = schema["$ref"].split("/")[-1] + target = schemas.get(name, {}) + # A constrained/plain string newtype (type "string", not an enum, not an + # object) has no emitted type of its own -- only objects and enums get + # one -- so inline it as std::string rather than name an undefined type. + if target.get("type") == "string" and "enum" not in target: + return "std::string", False + return name, False + if "const" in schema: + # A fixed literal (e.g. `object: {const: "model"}`). Typed by its value; + # the reader still parses it, it just can only be that one value. + const = schema["const"] + if isinstance(const, bool): + return "bool", False + if isinstance(const, int): + return INT, False + return "std::string", False if "anyOf" in schema: branches = [b for b in schema["anyOf"] if b.get("type") != "null"] had_null = any(b.get("type") == "null" for b in schema["anyOf"]) inner, _ = _resolve_type(branches[0], schemas) return inner, had_null kind = schema.get("type") + # JSON Schema nullable form `type: ["integer", "null"]`: strip the null, + # resolve the remaining type, and mark it optional. Same meaning as an + # anyOf-with-null, just spelled the compact way OpenAPI 3.1 emits. + if isinstance(kind, list): + non_null = [t for t in kind if t != "null"] + had_null = "null" in kind + inner, _ = _resolve_type({**schema, "type": non_null[0]}, schemas) + return inner, had_null if kind == "string": return "std::string", False if kind == "integer": diff --git a/contracts/wally-cli-v1.openapi.json b/contracts/wally-cli-v1.openapi.json index 0cc1f25..64953d7 100644 --- a/contracts/wally-cli-v1.openapi.json +++ b/contracts/wally-cli-v1.openapi.json @@ -725,6 +725,163 @@ ], "title": "UsageTotals", "type": "object" + }, + "ModelList": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PublicModel" + }, + "type": "array" + }, + "object": { + "const": "list" + } + }, + "required": [ + "object", + "data" + ], + "type": "object" + }, + "ModelOwner": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "PublicModel": { + "additionalProperties": false, + "properties": { + "created": { + "description": "OpenAI-shaped creation timestamp (unix seconds) the gateway reports; informational.", + "minimum": 0, + "type": "integer" + }, + "id": { + "$ref": "#/components/schemas/PublicModelId" + }, + "max_input_tokens": { + "description": "The advertised ceiling on input tokens for this model -- what an OpenAI-shaped coding agent reads to decide when to compact (contracts/public/status_semantics.json context_window). null means the deployment has not established one.", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "max_output_tokens": { + "description": "The advertised ceiling on output tokens, when the deployment declares one separately from the context window (gateway/litellm/config/proxy_config.yaml model_info; gemma-4 does, qwen3.8-27b shares one budget and omits it).", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "mode": { + "description": "The gateway's model mode; every Wally model is a chat model.", + "enum": [ + "chat" + ], + "type": "string" + }, + "object": { + "const": "model" + }, + "owned_by": { + "$ref": "#/components/schemas/ModelOwner" + } + }, + "required": [ + "id", + "object", + "owned_by" + ], + "type": "object" + }, + "PublicModelId": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$", + "type": "string" + }, + "CatalogModelResponse": { + "additionalProperties": false, + "description": "One entitled model, priced. Straight from `pricing.catalog()` - the same\nnumbers the credit gate charges against, so a published price cannot drift\nfrom a billed one (G7).", + "properties": { + "cached_input_per_mtok": { + "maximum": 9007199254740991.0, + "minimum": 0.0, + "title": "Cached Input Per Mtok", + "type": "integer" + }, + "display_name": { + "maxLength": 255, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$", + "title": "Id", + "type": "string" + }, + "input_per_mtok": { + "maximum": 9007199254740991.0, + "minimum": 0.0, + "title": "Input Per Mtok", + "type": "integer" + }, + "output_per_mtok": { + "maximum": 9007199254740991.0, + "minimum": 0.0, + "title": "Output Per Mtok", + "type": "integer" + } + }, + "required": [ + "id", + "display_name", + "input_per_mtok", + "output_per_mtok", + "cached_input_per_mtok" + ], + "title": "CatalogModelResponse", + "type": "object" + }, + "ModelCatalogResponse": { + "additionalProperties": false, + "properties": { + "effective_from": { + "maxLength": 35, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", + "title": "Effective From", + "type": "string" + }, + "models": { + "items": { + "$ref": "#/components/schemas/CatalogModelResponse" + }, + "title": "Models", + "type": "array" + }, + "pricing_version": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Pricing Version", + "type": "string" + } + }, + "required": [ + "pricing_version", + "effective_from", + "models" + ], + "title": "ModelCatalogResponse", + "type": "object" } } }, @@ -963,6 +1120,66 @@ "cli auth" ] } + }, + "/v1/models": { + "get": { + "operationId": "listModels", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelList" + } + } + }, + "description": "Available model aliases" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List models available to the current key", + "tags": [ + "inference" + ] + } + }, + "/v1/models/catalog": { + "get": { + "description": "Which models we serve and what they cost.\n\nThe console used to name a single model from a config constant, which was\ntrue when the endpoint served one and became wrong the moment LiteLLM sat in\nfront of three. Prices come from the same catalog the gate charges against,\nso a published price cannot drift from a billed one.", + "operationId": "getModelCatalog", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelCatalogResponse" + } + } + }, + "description": "Successful Response" + }, + "default": { + "$ref": "#/components/responses/ApiError" + } + }, + "summary": "Model Catalog", + "tags": [ + "console" + ] + } } } } diff --git a/src/account/console.cpp b/src/account/console.cpp index 8d00c6e..9b176ee 100644 --- a/src/account/console.cpp +++ b/src/account/console.cpp @@ -824,6 +824,91 @@ IdentityResult ConsoleClient::WhoAmI(const std::string& console_url, return IdentityResult::Ok; } +IdentityResult ConsoleClient::FetchModels(const std::string& console_url, + const std::string& access_token, + std::vector* models, + std::string* error) const { + if (models == nullptr || !SessionTokenIsSafe(access_token)) { + if (error != nullptr) { + *error = "no access token is available"; + } + return IdentityResult::Failed; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return IdentityResult::Failed; + } + HttpResponse response; + if (!Send(transport_, {"GET", origin + "/v1/models", {}, access_token}, &response, error)) { + return IdentityResult::Failed; + } + if (response.status == 401) { + if (error != nullptr) { + *error = "console session expired"; + } + return IdentityResult::Unauthorized; + } + if (response.status != 200) { + HttpError("models request", origin, response, error); + return IdentityResult::Failed; + } + contract::ModelList parsed; + if (!ParseContract(response, &parsed, error)) { + return IdentityResult::Failed; + } + models->clear(); + models->reserve(parsed.data.size()); + for (const contract::PublicModel& model : parsed.data) { + ModelInfo info; + info.id = model.id; + info.context_window = model.max_input_tokens.value_or(0); + info.max_output_tokens = model.max_output_tokens.value_or(0); + models->push_back(std::move(info)); + } + return IdentityResult::Ok; +} + +IdentityResult ConsoleClient::FetchCatalog(const std::string& console_url, + const std::string& access_token, + std::vector* prices, + std::string* error) const { + if (prices == nullptr || !SessionTokenIsSafe(access_token)) { + if (error != nullptr) { + *error = "no access token is available"; + } + return IdentityResult::Failed; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return IdentityResult::Failed; + } + HttpResponse response; + if (!Send(transport_, {"GET", origin + "/v1/models/catalog", {}, access_token}, &response, + error)) { + return IdentityResult::Failed; + } + if (response.status == 401) { + if (error != nullptr) { + *error = "console session expired"; + } + return IdentityResult::Unauthorized; + } + if (response.status != 200) { + HttpError("model catalog request", origin, response, error); + return IdentityResult::Failed; + } + contract::ModelCatalogResponse parsed; + if (!ParseContract(response, &parsed, error)) { + return IdentityResult::Failed; + } + prices->clear(); + prices->reserve(parsed.models.size()); + for (const contract::CatalogModelResponse& model : parsed.models) { + prices->push_back(CatalogPrice{model.id, model.input_per_mtok, model.output_per_mtok}); + } + return IdentityResult::Ok; +} + namespace { /// A console string that is safe to print in a terminal. Anything else is diff --git a/src/account/console.h b/src/account/console.h index 77e7311..1556bd3 100644 --- a/src/account/console.h +++ b/src/account/console.h @@ -141,6 +141,24 @@ struct Grant { enum class PollResult { Pending, Approved, Denied, Expired, Failed }; enum class IdentityResult { Ok, Unauthorized, Failed }; +/// One served model as `/v1/models` advertises it. `context_window` is the +/// input-token ceiling a coding agent reads to decide when to compact; 0 means +/// the deployment declared none. `max_output_tokens` is 0 for self-hosted +/// shared-budget models and non-zero only where a separate cap exists. +struct ModelInfo { + std::string id; + std::int64_t context_window = 0; + std::int64_t max_output_tokens = 0; +}; + +/// One model's price, straight from the catalog the credit gate charges against. +/// Micro-dollars per million tokens (1 USD = 1,000,000 micros). +struct CatalogPrice { + std::string id; + std::int64_t input_per_mtok = 0; + std::int64_t output_per_mtok = 0; +}; + /// Console client independent of SDK/bootstrap state. /// /// The default transport uses WinHTTP on Windows and libcurl elsewhere. Tests @@ -161,6 +179,14 @@ class ConsoleClient { const std::string& refresh_token, std::string* error) const; IdentityResult FetchUsage(const std::string& console_url, const std::string& access_token, const UsageQuery& query, Usage* usage, std::string* error) const; + /// The served model catalog from `/v1/models`, used to feed a harness the + /// real context window (so its auto-compaction fires at the right point). + IdentityResult FetchModels(const std::string& console_url, const std::string& access_token, + std::vector* models, std::string* error) const; + /// Per-model pricing from `/v1/models/catalog`, so a harness can show real + /// spend instead of $0.00. + IdentityResult FetchCatalog(const std::string& console_url, const std::string& access_token, + std::vector* prices, std::string* error) const; private: Transport transport_; diff --git a/src/account/console_contract.h b/src/account/console_contract.h index 9cbaee0..05f78f6 100644 --- a/src/account/console_contract.h +++ b/src/account/console_contract.h @@ -17,7 +17,7 @@ namespace wally::account::contract { // SHA-256 of contracts/wally-cli-v1.openapi.json this header was built from. -inline constexpr char kContractSha256[] = "5069ea05fa10339c0ec316f269e1aa2ed486114ec1e248abe82c2ed702fdc20a"; +inline constexpr char kContractSha256[] = "db39bcceea58bd09380f4175089adc378da56b436247f68784d360ae5a27a0f0"; enum class ApiErrorCode { kInvalidRequest, @@ -292,6 +292,51 @@ inline void to_json(nlohmann::json& j, const ApiError& value) { j["message"] = value.message; } +struct CatalogModelResponse { + std::int64_t cached_input_per_mtok; + std::string display_name; + std::string id; + std::int64_t input_per_mtok; + std::int64_t output_per_mtok; +}; + +inline void from_json(const nlohmann::json& j, CatalogModelResponse& value) { + if (j.contains("cached_input_per_mtok") && !j.at("cached_input_per_mtok").is_null()) { + value.cached_input_per_mtok = j.at("cached_input_per_mtok").get(); + } else { + value.cached_input_per_mtok = std::int64_t{}; + } + if (j.contains("display_name") && !j.at("display_name").is_null()) { + value.display_name = j.at("display_name").get(); + } else { + value.display_name = std::string{}; + } + if (j.contains("id") && !j.at("id").is_null()) { + value.id = j.at("id").get(); + } else { + value.id = std::string{}; + } + if (j.contains("input_per_mtok") && !j.at("input_per_mtok").is_null()) { + value.input_per_mtok = j.at("input_per_mtok").get(); + } else { + value.input_per_mtok = std::int64_t{}; + } + if (j.contains("output_per_mtok") && !j.at("output_per_mtok").is_null()) { + value.output_per_mtok = j.at("output_per_mtok").get(); + } else { + value.output_per_mtok = std::int64_t{}; + } +} + +inline void to_json(nlohmann::json& j, const CatalogModelResponse& value) { + j = nlohmann::json::object(); + j["cached_input_per_mtok"] = value.cached_input_per_mtok; + j["display_name"] = value.display_name; + j["id"] = value.id; + j["input_per_mtok"] = value.input_per_mtok; + j["output_per_mtok"] = value.output_per_mtok; +} + struct CliPollRequest { std::string poll_secret; std::string request_code; @@ -834,6 +879,128 @@ inline void to_json(nlohmann::json& j, const IdentityResponse& value) { j["tokens_this_month"] = value.tokens_this_month; } +struct ModelCatalogResponse { + std::string effective_from; + std::vector models; + std::string pricing_version; +}; + +inline void from_json(const nlohmann::json& j, ModelCatalogResponse& value) { + if (j.contains("effective_from") && !j.at("effective_from").is_null()) { + value.effective_from = j.at("effective_from").get(); + } else { + value.effective_from = std::string{}; + } + if (j.contains("models") && !j.at("models").is_null()) { + value.models = j.at("models").get>(); + } else { + value.models = std::vector{}; + } + if (j.contains("pricing_version") && !j.at("pricing_version").is_null()) { + value.pricing_version = j.at("pricing_version").get(); + } else { + value.pricing_version = std::string{}; + } +} + +inline void to_json(nlohmann::json& j, const ModelCatalogResponse& value) { + j = nlohmann::json::object(); + j["effective_from"] = value.effective_from; + j["models"] = value.models; + j["pricing_version"] = value.pricing_version; +} + +struct PublicModel { + std::optional created; + std::string id; + std::optional max_input_tokens; + std::optional max_output_tokens; + std::optional mode; + std::string object; + std::string owned_by; +}; + +inline void from_json(const nlohmann::json& j, PublicModel& value) { + if (j.contains("created") && !j.at("created").is_null()) { + value.created = j.at("created").get(); + } else { + value.created = std::nullopt; + } + if (j.contains("id") && !j.at("id").is_null()) { + value.id = j.at("id").get(); + } else { + value.id = std::string{}; + } + if (j.contains("max_input_tokens") && !j.at("max_input_tokens").is_null()) { + value.max_input_tokens = j.at("max_input_tokens").get(); + } else { + value.max_input_tokens = std::nullopt; + } + if (j.contains("max_output_tokens") && !j.at("max_output_tokens").is_null()) { + value.max_output_tokens = j.at("max_output_tokens").get(); + } else { + value.max_output_tokens = std::nullopt; + } + if (j.contains("mode") && !j.at("mode").is_null()) { + value.mode = j.at("mode").get(); + } else { + value.mode = std::nullopt; + } + if (j.contains("object") && !j.at("object").is_null()) { + value.object = j.at("object").get(); + } else { + value.object = std::string{}; + } + if (j.contains("owned_by") && !j.at("owned_by").is_null()) { + value.owned_by = j.at("owned_by").get(); + } else { + value.owned_by = std::string{}; + } +} + +inline void to_json(nlohmann::json& j, const PublicModel& value) { + j = nlohmann::json::object(); + if (value.created.has_value()) { + j["created"] = *value.created; + } + j["id"] = value.id; + if (value.max_input_tokens.has_value()) { + j["max_input_tokens"] = *value.max_input_tokens; + } + if (value.max_output_tokens.has_value()) { + j["max_output_tokens"] = *value.max_output_tokens; + } + if (value.mode.has_value()) { + j["mode"] = *value.mode; + } + j["object"] = value.object; + j["owned_by"] = value.owned_by; +} + +struct ModelList { + std::vector data; + std::string object; +}; + +inline void from_json(const nlohmann::json& j, ModelList& value) { + if (j.contains("data") && !j.at("data").is_null()) { + value.data = j.at("data").get>(); + } else { + value.data = std::vector{}; + } + if (j.contains("object") && !j.at("object").is_null()) { + value.object = j.at("object").get(); + } else { + value.object = std::string{}; + } +} + +inline void to_json(nlohmann::json& j, const ModelList& value) { + j = nlohmann::json::object(); + j["data"] = value.data; + j["object"] = value.object; +} + struct PollResponse { std::optional access_token; std::optional email; diff --git a/src/anthropic/messages.cpp b/src/anthropic/messages.cpp index ebebf9e..adb4172 100644 --- a/src/anthropic/messages.cpp +++ b/src/anthropic/messages.cpp @@ -1,14 +1,20 @@ #include "anthropic/messages.h" +#include #include +#include +#include +#include #include #include +#include #include #include #include #include "anthropic/translate.h" +#include "config/cli_paths.h" #include "io/output.h" #include "net/loopback_auth.h" @@ -17,6 +23,44 @@ namespace { using Json = nlohmann::json; +/// Appends one line about a failed upstream call to a log file, best effort. +/// +/// A file, not stderr: the wrapped tool (Claude Code) owns the terminal, and a +/// line printed into its TUI corrupts the display — which is why a real error +/// used to vanish into a blind "API error, retrying" with nowhere to look. The +/// upstream response body is recorded; the bearer token never is (it is only +/// ever on the request, never echoed here). +void LogUpstreamError(const std::string& model, bool streaming, int status, + const std::string& body) { + const std::string dir = paths::state_dir(); + if (dir.empty()) { + return; + } + std::error_code ec; + std::filesystem::create_directories(dir, ec); + std::ofstream log(dir + "/shim.log", std::ios::app); + if (!log.good()) { + return; + } + const std::time_t now = std::time(nullptr); + std::tm utc{}; +#if defined(_WIN32) + gmtime_s(&utc, &now); +#else + gmtime_r(&now, &utc); +#endif + char when[32] = {0}; + std::strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &utc); + std::string snippet = body.substr(0, 2000); + for (char& character : snippet) { + if (character == '\n' || character == '\r') { + character = ' '; + } + } + log << when << " model=" << model << " stream=" << (streaming ? 1 : 0) + << " status=" << status << " body=" << snippet << '\n'; +} + /// Split "http://host:port/v1" into the host root and the path prefix httplib /// wants separately. bool SplitBaseUrl(const std::string& base_url, std::string* origin, std::string* prefix) { @@ -83,22 +127,26 @@ void HandleNonStreaming(Runtime& runtime, const Json& request, httplib::Response const httplib::Result reply = client.Post(runtime.prefix + "/chat/completions", upstream.dump(), "application/json"); if (!reply || reply->status < 200 || reply->status >= 300) { + const int status = reply ? reply->status : 0; + const std::string body = reply ? reply->body : std::string(); + LogUpstreamError(runtime.model, false, status, body); response.status = reply ? reply->status : 502; // A 429 from the hosted API carries a Retry-After the wrapped tool // should honor; httplib drops upstream headers unless we copy them. if (reply && reply->status == 429 && reply->has_header("Retry-After")) { response.set_header("Retry-After", reply->get_header_value("Retry-After")); } - response.set_content( - translate::ErrorBody("api_error", - reply ? reply->body : std::string("the model endpoint did not answer")), - "application/json"); + std::string type; + std::string message; + translate::UpstreamFailure(status, body, &type, &message); + response.set_content(translate::ErrorBody(type, message), "application/json"); return; } Json parsed; try { parsed = Json::parse(reply->body); } catch (const Json::exception& error) { + LogUpstreamError(runtime.model, false, reply->status, reply->body); response.status = 502; response.set_content(translate::ErrorBody("api_error", error.what()), "application/json"); return; @@ -106,6 +154,7 @@ void HandleNonStreaming(Runtime& runtime, const Json& request, httplib::Response std::string failure_type; std::string failure; if (translate::PayloadError(parsed, &failure_type, &failure)) { + LogUpstreamError(runtime.model, false, reply->status, reply->body); response.status = failure_type == "rate_limit_error" ? 429 : 502; // A rate-limit error can arrive as a 200 body rather than a 429 status; // forward the upstream Retry-After either way so the tool backs off. @@ -139,10 +188,21 @@ void HandleStreaming(Runtime& runtime, const Json& request, httplib::Response& r translate::StreamState state; state.model = *model; std::string pending; + // The upstream status is only known once Post returns, so the start + // of the body is kept regardless. On a non-2xx reply that is the + // error body, which would otherwise be fed to the SSE frame parser + // and silently dropped; capped so a real (2xx) stream of any size + // costs only these few KB. + std::string error_body; + constexpr size_t kErrorBodyCap = 8192; const httplib::Result reply = client.Post( *path, httplib::Headers(), *upstream, "application/json", [&](const char* data, size_t length) { + if (error_body.size() < kErrorBodyCap) { + error_body.append(data, + std::min(length, kErrorBodyCap - error_body.size())); + } pending.append(data, length); // SSE frames are separated by a blank line, and a chunk can // split one in half, so only whole frames are consumed. @@ -182,11 +242,14 @@ void HandleStreaming(Runtime& runtime, const Json& request, httplib::Response& r return true; }); - if (!reply) { + if (!reply || reply->status < 200 || reply->status >= 300) { + const int status = reply ? reply->status : 0; + LogUpstreamError(*model, true, status, error_body); + std::string type; + std::string message; + translate::UpstreamFailure(status, error_body, &type, &message); const std::string body = - "event: error\ndata: " + - translate::ErrorBody("api_error", "the model endpoint stopped answering") + - "\n\n"; + "event: error\ndata: " + translate::ErrorBody(type, message) + "\n\n"; sink.write(body.data(), body.size()); sink.done(); return false; diff --git a/src/anthropic/translate.cpp b/src/anthropic/translate.cpp index 245170a..fa77927 100644 --- a/src/anthropic/translate.cpp +++ b/src/anthropic/translate.cpp @@ -216,7 +216,15 @@ Json RequestToOpenAI(const Json& anthropic, const std::string& model) { Json openai; openai["model"] = model; const auto stream = anthropic.find("stream"); - openai["stream"] = stream != anthropic.end() && stream->is_boolean() && stream->get(); + const bool streaming = stream != anthropic.end() && stream->is_boolean() && stream->get(); + openai["stream"] = streaming; + // Ask for the token counts on the stream. Without this the upstream sends no + // usage chunk, so prompt/completion tokens never arrive and the wrapped + // tool's context gauge sits at zero — the whole reason auto-compaction never + // fires. The counts ride a final `choices: []` chunk after the content. + if (streaming) { + openai["stream_options"] = Json{{"include_usage", true}}; + } Json messages = Json::array(); // Anthropic carries the system prompt beside the conversation; OpenAI wants @@ -530,10 +538,17 @@ std::string StreamCloseToAnthropic(StreamState* state) { const std::string stop = StopWithTools( state->stop_reason.empty() ? std::string("end_turn") : state->stop_reason, emitted_tool_block); + // `input_tokens` too, not just output. message_start had to emit 0 (the + // upstream reports prompt_tokens only at the end of the stream), so this + // final usage is the one place the real prompt count reaches the client. + // Without it a wrapped tool's context gauge reads 0 forever and its + // auto-compaction never fires — no context-window setting can rescue a + // numerator that is always zero. out += Event("message_delta", Json{{"type", "message_delta"}, {"delta", Json{{"stop_reason", stop}, {"stop_sequence", nullptr}}}, - {"usage", Json{{"output_tokens", state->output_tokens}}}}); + {"usage", Json{{"input_tokens", state->input_tokens}, + {"output_tokens", state->output_tokens}}}}); out += Event("message_stop", Json{{"type", "message_stop"}}); return out; } @@ -562,6 +577,64 @@ bool PayloadError(const Json& payload, std::string* type, std::string* message) return true; } +namespace { + +/// The Anthropic error `type` that matches an HTTP status. Anything without a +/// closer match is an `api_error` (a retryable dead-endpoint signal), which is +/// why the mapping is deliberate: a 403 that read as `api_error` would have the +/// tool retry a refusal it can never satisfy. +std::string ErrorTypeForStatus(int status) { + switch (status) { + case 401: + return "authentication_error"; + case 403: + return "permission_error"; + case 400: + case 404: + case 413: + case 422: + return "invalid_request_error"; + case 429: + return "rate_limit_error"; + default: + return "api_error"; + } +} + +} // namespace + +void UpstreamFailure(int status, const std::string& body, std::string* type, + std::string* message) { + std::string extracted; + std::string ignored_type; + const Json parsed = Json::parse(body, nullptr, /*allow_exceptions=*/false); + if (!parsed.is_discarded()) { + PayloadError(parsed, &ignored_type, &extracted); + } + if (extracted.empty()) { + // No structured error message: fall back to the raw body (trimmed and + // capped so a huge HTML error page does not become the message), then a + // status line, then a plain no-answer note. + const auto first = body.find_first_not_of(" \t\r\n"); + const auto last = body.find_last_not_of(" \t\r\n"); + const std::string trimmed = + first == std::string::npos ? std::string() : body.substr(first, last - first + 1); + if (!trimmed.empty()) { + extracted = trimmed.substr(0, 1000); + } else if (status != 0) { + extracted = "the model endpoint returned status " + std::to_string(status); + } else { + extracted = "the model endpoint did not answer"; + } + } + if (message != nullptr) { + *message = extracted; + } + if (type != nullptr) { + *type = status == 0 ? "api_error" : ErrorTypeForStatus(status); + } +} + std::string ErrorBody(const std::string& type, const std::string& message) { return Json{{"type", "error"}, {"error", Json{{"type", type}, {"message", message}}}}.dump(); } diff --git a/src/anthropic/translate.h b/src/anthropic/translate.h index 4cd1079..bb8a4ac 100644 --- a/src/anthropic/translate.h +++ b/src/anthropic/translate.h @@ -82,6 +82,16 @@ std::string ErrorBody(const std::string& type, const std::string& message); /// Returns false when `payload` carries no error, which is the ordinary case. bool PayloadError(const Json& payload, std::string* type, std::string* message); +/// Maps a failed upstream reply to the (type, message) an Anthropic-shaped error +/// should carry. `status` 0 means the endpoint never answered. +/// +/// The HTTP status picks the error type, so a 403 reads as `permission_error` +/// and a 429 as `rate_limit_error` rather than the generic `api_error` a tool +/// will retry forever; the message is pulled from an OpenAI-style error body +/// when there is one, otherwise the raw body, otherwise a plain status line. +void UpstreamFailure(int status, const std::string& body, std::string* type, + std::string* message); + } // namespace wally::anthropic::translate #endif // WALLY_ANTHROPIC_TRANSLATE_H diff --git a/src/app.cpp b/src/app.cpp index d92580d..5b93441 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -151,6 +152,62 @@ void configure_app(CLI::App& app, GlobalOptions& options) { } } +namespace { + +/// The subcommands that hand the terminal to another tool and forward the rest +/// of the command line to it. Kept in step with register_editors and +/// register_harness; a name here that is not a real subcommand is harmless. +bool IsPassthroughCommand(const std::string& token) { + static const std::set kNames = {"claude-code", "claude-desktop", "clion", + "rustrover", "opencode", "codex"}; + return kNames.count(token) != 0; +} + +/// wally's own flags on those subcommands. `-m`/`--model` take a following +/// value; the rest are booleans. The `=` forms carry their value inline. +bool ConsumesFollowingValue(const std::string& token) { + return token == "-m" || token == "--model"; +} +bool IsWallyFlag(const std::string& token) { + return token == "-m" || token == "--model" || token.rfind("--model=", 0) == 0 || + token.rfind("-m=", 0) == 0 || token == "--serve" || token == "--restore" || + token == "--cloud"; +} + +} // namespace + +/// Inserts a `--` ahead of the first token that belongs to the wrapped tool, so +/// CLI11 stops reading the tool's own flags (`--dangerously-skip-permissions`, +/// `-p`) as unknown wally options and rejecting the whole line. Left untouched +/// when this is not a passthrough command, a `--` is already present, or nothing +/// but wally flags follow. `argv` includes the program name at index 0. +std::vector SplitPassthroughArgv(const std::vector& argv) { + std::vector out = argv; + + std::size_t sub = 0; + for (std::size_t i = 1; i < out.size(); ++i) { + if (IsPassthroughCommand(out[i])) { + sub = i; + break; + } + } + if (sub == 0) { + return out; + } + + for (std::size_t i = sub + 1; i < out.size();) { + if (out[i] == "--") { + return out; // the reader separated it already + } + if (!IsWallyFlag(out[i])) { + out.insert(out.begin() + static_cast(i), "--"); + return out; + } + i += ConsumesFollowingValue(out[i]) ? 2 : 1; + } + return out; // only wally flags, nothing to forward +} + int run(int argc, char** argv) { GlobalOptions options; @@ -179,9 +236,20 @@ int run(int argc, char** argv) { "`wally claude-code -m `, `wally opencode --cloud -m ` or " "`wally codex -m `, not `run`/`llm generate`."); + // A `--` before the wrapped tool's own arguments, added for the reader, so + // `wally claude-code --dangerously-skip-permissions` forwards the flag + // instead of failing on it. Kept alive for the whole parse below. + std::vector forwarded = + SplitPassthroughArgv(std::vector(argv, argv + argc)); + std::vector forwarded_argv; + forwarded_argv.reserve(forwarded.size()); + for (std::string& token : forwarded) { + forwarded_argv.push_back(token.data()); + } + int exit_code = 0; try { - app.parse(argc, argv); + app.parse(static_cast(forwarded_argv.size()), forwarded_argv.data()); if (app.get_subcommands().empty()) { // Bare `wally` prints help like `ollama` does. out::status_line(app.help()); diff --git a/src/app.h b/src/app.h index 2f8f500..41f08d9 100644 --- a/src/app.h +++ b/src/app.h @@ -6,6 +6,9 @@ #ifndef WALLY_APP_H #define WALLY_APP_H +#include +#include + #include #include "bootstrap.h" @@ -15,6 +18,11 @@ namespace wally { void configure_app(CLI::App& app, GlobalOptions& options); int run(int argc, char** argv); +/// Rewrites a command line so a passthrough subcommand's tool arguments survive +/// CLI11's parse: a `--` is inserted before the first token that belongs to the +/// wrapped tool. Exposed for tests; `argv` includes the program name at 0. +std::vector SplitPassthroughArgv(const std::vector& argv); + } // namespace wally /// The one entry point both binaries use: `main()` here, and the Swift MLX host diff --git a/src/commands/cmd_editors.cpp b/src/commands/cmd_editors.cpp index c40f44c..c577024 100644 --- a/src/commands/cmd_editors.cpp +++ b/src/commands/cmd_editors.cpp @@ -1,13 +1,19 @@ #include +#include #include +#include #include #include +#include +#include #include +#include #include #include #include "anthropic/messages.h" #include "commands/commands.h" +#include "config/cli_paths.h" #include "io/output.h" #include "desktop/claude_profile.h" #include "harness/harness.h" @@ -117,10 +123,11 @@ std::vector OpenArgs(const std::string& bundle, const anthropic::Sh // start the app with the reader's login environment instead of ours. args.push_back("--env"); args.push_back("ANTHROPIC_BASE_URL=" + shim.base_url); + // Bearer token only, no ANTHROPIC_API_KEY: the token outranks a key and + // setting a key is what makes Claude Code warn about claude.ai + // connectors being off. See the ScopedEnv path below. args.push_back("--env"); args.push_back("ANTHROPIC_AUTH_TOKEN=" + shim.auth_token); - args.push_back("--env"); - args.push_back("ANTHROPIC_API_KEY=" + shim.auth_token); } args.push_back("-a"); args.push_back(bundle); @@ -191,6 +198,141 @@ class ScopedEnv { bool had_previous_ = false; }; +/// Removes `name` for the child and restores it on scope exit — the mirror of +/// ScopedEnv. Used to keep a stray ANTHROPIC_API_KEY in the reader's shell from +/// reaching Claude Code: our bearer token already outranks it, but its mere +/// presence makes Claude Code prompt to approve the key and warn that claude.ai +/// connectors are off. +class ScopedUnsetEnv { + public: + explicit ScopedUnsetEnv(std::string name) : name_(std::move(name)) { + const char* previous = std::getenv(name_.c_str()); + had_previous_ = previous != nullptr; + if (!had_previous_) { + return; + } + previous_ = previous; +#if defined(_WIN32) + _putenv_s(name_.c_str(), ""); +#else + unsetenv(name_.c_str()); +#endif + } + + ~ScopedUnsetEnv() { + if (!had_previous_) { + return; + } +#if defined(_WIN32) + _putenv_s(name_.c_str(), previous_.c_str()); +#else + setenv(name_.c_str(), previous_.c_str(), 1); +#endif + } + + ScopedUnsetEnv(const ScopedUnsetEnv&) = delete; + ScopedUnsetEnv& operator=(const ScopedUnsetEnv&) = delete; + + private: + std::string name_; + std::string previous_; + bool had_previous_ = false; +}; + +/// A wally-owned config directory for the Claude Code we launch, seeded from the +/// reader's real `~/.claude` so their settings, agents, rules, skills and memory +/// come along, but WITHOUT the login: a separate dir has no claude.ai session to +/// collide with, which is exactly what silences the "connectors are disabled" +/// warning. `.credentials.json` (the login file) and the runtime/cache trees are +/// never copied. Full context is seeded on first run; the small settings files +/// refresh every run so later edits to the real profile flow through. +/// +/// On macOS the active login is a Keychain entry keyed to the config-dir path, +/// so even the account metadata in `~/.claude.json` is safe to bring — verified +/// that a seeded dir still prints no warning. +std::string PrepareClaudeConfigDir() { + namespace fs = std::filesystem; + const std::string ours_str = paths::state_dir() + "/claude"; + const fs::path ours(ours_str); + std::error_code ec; + + const char* home = std::getenv("HOME"); + const fs::path og_dir = home != nullptr ? fs::path(home) / ".claude" : fs::path(); + const fs::path og_json = home != nullptr ? fs::path(home) / ".claude.json" : fs::path(); + + const bool first_run = !fs::exists(ours, ec); + fs::create_directories(ours, ec); + + // Ours to own, never seeded from the real profile: the login, plus the + // runtime and cache trees. Everything else in ~/.claude is context to keep. + static const std::set kRuntime = { + ".credentials.json", "projects", "sessions", + "shell-snapshots", "statsig", "cache", + "caches", "telemetry", "downloads", + "uploads", "paste-cache", "file-history", + "backups", "history.jsonl", ".last-cleanup", + "chrome", "ide", "session-env", + "mcp-needs-auth-cache.json", "stats-cache.json", ".last-update-result.json", + }; + + if (first_run && !og_dir.empty() && fs::exists(og_dir, ec)) { + for (fs::directory_iterator it(og_dir, ec), end; it != end; it.increment(ec)) { + if (ec) { + break; + } + if (kRuntime.count(it->path().filename().string()) != 0) { + continue; + } + std::error_code copy_ec; + fs::copy(it->path(), ours / it->path().filename(), + fs::copy_options::recursive | fs::copy_options::overwrite_existing, copy_ec); + } + } + + // A cheap refresh every run so edits to the real settings and memory flow + // through without re-copying the heavy trees. + if (!og_dir.empty()) { + for (const char* file : {"settings.json", "CLAUDE.md"}) { + const fs::path src = og_dir / file; + if (fs::exists(src, ec)) { + fs::copy_file(src, ours / file, fs::copy_options::overwrite_existing, ec); + } + } + } + if (!og_json.empty() && fs::exists(og_json, ec)) { + fs::copy_file(og_json, ours / ".claude.json", fs::copy_options::overwrite_existing, ec); + } + + return ours_str; +} + +/// The context window `/v1/models` advertises for `model`, or 0 when it can't be +/// learned. A failed catalog fetch WARNS and returns 0 — it must never block a +/// launch. Fed to Claude Code as CLAUDE_CODE_MAX_CONTEXT_TOKENS, this is what +/// makes its auto-compaction fire at the model's real limit instead of a guessed +/// default (which overruns qwen/gemma's 256k and wastes glm's 1M). +std::int64_t CloudContextWindow(const std::string& model) { + account::Credentials credentials; + std::string error; + if (!account::Load(&credentials, &error) || !credentials.signed_in()) { + return 0; + } + const account::ConsoleClient console; + std::vector models; + if (console.FetchModels(credentials.console_url, credentials.access_token, &models, &error) != + account::IdentityResult::Ok) { + out::status_line("could not read the model catalog (" + error + + "); launching without a context-window hint"); + return 0; + } + for (const account::ModelInfo& info : models) { + if (info.id == model) { + return info.context_window; + } + } + return 0; +} + /// Starts the translator and holds it open, printing what to point at it. /// /// Worth having beyond debugging: it is how anything that speaks the Anthropic @@ -374,10 +516,29 @@ int Run(const Editor& editor, const std::string& model, // Scoped so the reader's own environment is back before we report // anything, and before a later call in the same process reads it. const ScopedEnv base("ANTHROPIC_BASE_URL", shim.base_url); + // The bearer token only (auth precedence rank 2), never ANTHROPIC_API_KEY + // (rank 3): the token already outranks any key, and setting a key is what + // makes Claude Code prompt to approve it and warn that claude.ai + // connectors are off. A stray key in the reader's shell is unset for the + // same reason. This mirrors how Ollama wires Claude Code. const ScopedEnv token("ANTHROPIC_AUTH_TOKEN", shim.auth_token); - // An API key set in the environment outranks the token above and would - // send the session to Anthropic instead of to us. - const ScopedEnv key("ANTHROPIC_API_KEY", shim.auth_token); + const ScopedUnsetEnv no_key("ANTHROPIC_API_KEY"); + // Its own config dir, seeded from the reader's ~/.claude minus the login, + // so there is no claude.ai session to collide with (no warning) but their + // settings and memory still apply. See PrepareClaudeConfigDir. + const ScopedEnv config_dir("CLAUDE_CONFIG_DIR", PrepareClaudeConfigDir()); + // The real context window, for an upstream model, so Claude Code's + // auto-compaction fires at the model's limit rather than its own guess. + // Only for a hosted model (a local one is not in `/v1/models`), and only + // when the catalog actually answered — a miss just launches as before. + std::optional context_window; + if (!endpoint.serving) { + const std::int64_t context = CloudContextWindow(model); + if (context > 0) { + context_window.emplace("CLAUDE_CODE_MAX_CONTEXT_TOKENS", std::to_string(context)); + out::status_line("context window: " + std::to_string(context) + " tokens"); + } + } status = harness::Launch(editor.command, {}, args); } @@ -404,7 +565,11 @@ void register_editors(CLI::App& app, GlobalOptions& options) { command->add_flag("--restore", *restore, "undo what we configured and launch nothing"); } - command->add_option("args", *rest, "passed through")->allow_extra_args(); + // Tokens after the wally flags belong to the tool, its own flags + // included. They reach here as positionals because `run()` inserts a + // `--` ahead of them (see SplitPassthroughArgv); CLI11 would otherwise + // read a leading `--flag` as an unknown wally option and reject it. + command->add_option("args", *rest, "passed through to the tool")->allow_extra_args(); command->prefix_command(); command->callback([&options, &editor, model, rest, serve, restore] { if (*restore) { diff --git a/src/harness/opencode.cpp b/src/harness/opencode.cpp index d6f21d5..389907f 100644 --- a/src/harness/opencode.cpp +++ b/src/harness/opencode.cpp @@ -2,8 +2,10 @@ #include "harness/harness.h" +#include #include #include +#include #include #include #include @@ -187,13 +189,32 @@ int Spawn(const std::string& executable, const std::vector& argumen } // namespace std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url, - const std::string& access_token) { + const std::string& access_token, std::int64_t context_window, + std::int64_t max_output, std::int64_t input_per_mtok, + std::int64_t output_per_mtok) { using Json = nlohmann::json; + Json entry = {{"name", model}}; + // The real limits, so opencode's context gauge and auto-compaction fire at + // the model's actual window instead of a wrong default (which makes it nag + // to compact and never stop). Output is a sane cap, never the whole context + // -- opencode's own docs warn against that. + if (context_window > 0) { + const std::int64_t output = + max_output > 0 ? max_output : std::min(context_window, 65536); + entry["limit"] = Json{{"context", context_window}, {"output", output}}; + } + // The real price, so opencode shows spend instead of $0.00. opencode's cost + // is USD per million tokens; the catalog is micro-dollars per million, so a + // million micros is one dollar. + if (input_per_mtok > 0 || output_per_mtok > 0) { + entry["cost"] = Json{{"input", static_cast(input_per_mtok) / 1'000'000.0}, + {"output", static_cast(output_per_mtok) / 1'000'000.0}}; + } const Json provider = { {"npm", "@ai-sdk/openai-compatible"}, {"name", "RunAnywhere"}, {"options", {{"baseURL", base_url}, {"apiKey", access_token}}}, - {"models", {{model, {{"name", model}}}}}, + {"models", {{model, entry}}}, }; return Json{{"provider", {{"runanywhere", provider}}}, {"model", "runanywhere/" + model}} .dump(); @@ -227,7 +248,45 @@ int LaunchOpenCodeCloud(const std::string& model, const std::vector } const std::string base_url = credentials.console_url + "/v1"; - const std::string config = BuildOpenCodeCloudConfig(model, base_url, credentials.access_token); + // The model's real context window and price, so opencode's compaction fires + // at the right point and its usage shows real spend. A failed fetch is not + // fatal -- launch with whatever we learned. + std::int64_t context_window = 0; + std::int64_t max_output = 0; + std::int64_t input_price = 0; + std::int64_t output_price = 0; + std::string fetch_error; + std::vector models; + if (console.FetchModels(credentials.console_url, credentials.access_token, &models, + &fetch_error) == account::IdentityResult::Ok) { + for (const account::ModelInfo& info : models) { + if (info.id == model) { + context_window = info.context_window; + max_output = info.max_output_tokens; + break; + } + } + } else { + out::status_line("could not read the model list (" + fetch_error + + "); launching without a context-window hint"); + } + std::vector prices; + if (console.FetchCatalog(credentials.console_url, credentials.access_token, &prices, + &fetch_error) == account::IdentityResult::Ok) { + for (const account::CatalogPrice& price : prices) { + if (price.id == model) { + input_price = price.input_per_mtok; + output_price = price.output_per_mtok; + break; + } + } + } + if (context_window > 0) { + out::status_line("context window: " + std::to_string(context_window) + " tokens"); + } + const std::string config = BuildOpenCodeCloudConfig( + model, base_url, credentials.access_token, context_window, max_output, input_price, + output_price); ScopedOpenCodeConfig environment; if (!environment.Activate(config)) { out::error_line("could not set the temporary OpenCode configuration"); diff --git a/src/harness/opencode.h b/src/harness/opencode.h index d2fc8ba..579c939 100644 --- a/src/harness/opencode.h +++ b/src/harness/opencode.h @@ -1,6 +1,7 @@ #ifndef WALLY_HARNESS_OPENCODE_H #define WALLY_HARNESS_OPENCODE_H +#include #include #include #include @@ -13,9 +14,13 @@ using SpawnFunction = std::function& arguments)>; /// OpenCode's complete, ephemeral provider configuration for a hosted model. -/// Exposed so the contract can be tested without launching a child process. +/// The model's real limits (context/output) and price are injected so OpenCode's +/// compaction and its usage display are correct; a 0 for any of them omits that +/// field. Exposed so the contract can be tested without launching a child. std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url, - const std::string& access_token); + const std::string& access_token, std::int64_t context_window, + std::int64_t max_output, std::int64_t input_per_mtok, + std::int64_t output_per_mtok); /// Launch OpenCode against the signed-in RunAnywhere cloud session. /// diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 41e1861..750c0f5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,7 +3,7 @@ enable_testing() add_executable(test_wally_unit test_wally_unit.cpp) target_include_directories(test_wally_unit PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(test_wally_unit PRIVATE wally_core) +target_link_libraries(test_wally_unit PRIVATE wally_core nlohmann_json::nlohmann_json) wally_stage_windows_runtime_dlls(test_wally_unit) add_test(NAME wally_unit_tests COMMAND test_wally_unit --run-all) diff --git a/tests/test_wally_opencode.cpp b/tests/test_wally_opencode.cpp index bd4479d..9205739 100644 --- a/tests/test_wally_opencode.cpp +++ b/tests/test_wally_opencode.cpp @@ -241,8 +241,39 @@ TestResult test_restores_config_when_spawn_throws() { } // namespace +TestResult test_config_injects_limit_and_cost() { + TestResult result; + result.test_name = "config_injects_limit_and_cost"; + using Json = nlohmann::json; + + // glm-like: 1M context, no separate output cap (0 -> sane default), and + // $0.60/$2.20 per Mtok (600000/2200000 micro-dollars). + const Json j = Json::parse(wally::harness::BuildOpenCodeCloudConfig( + "glm-5.3-flash", "https://x/v1", "tok", 1048576, 0, 600000, 2200000)); + const Json m = j["provider"]["runanywhere"]["models"]["glm-5.3-flash"]; + const double cin = m.value("cost", Json::object()).value("input", -1.0); + const double cout = m.value("cost", Json::object()).value("output", -1.0); + if (m["limit"]["context"].get() != 1048576 || + m["limit"]["output"].get() != 65536 || + cin < 0.5999 || cin > 0.6001 || cout < 2.1999 || cout > 2.2001) { + result.details = "limit/cost injection wrong: " + m.dump(); + return result; + } + // No metadata (all zeros) -> neither block is emitted. + const Json bare = Json::parse( + wally::harness::BuildOpenCodeCloudConfig("m", "https://x/v1", "tok", 0, 0, 0, 0)); + if (bare["provider"]["runanywhere"]["models"]["m"].contains("limit") || + bare["provider"]["runanywhere"]["models"]["m"].contains("cost")) { + result.details = "empty metadata should omit limit and cost"; + return result; + } + result.passed = true; + return result; +} + int main(int argc, char** argv) { TestSuite suite("wally_opencode"); + suite.add("config_injects_limit_and_cost", test_config_injects_limit_and_cost); suite.add("ephemeral_config_and_passthrough", test_ephemeral_config_and_passthrough); suite.add("refreshes_expired_session_without_sdk_bootstrap", test_refreshes_expired_session_without_sdk_bootstrap); diff --git a/tests/test_wally_unit.cpp b/tests/test_wally_unit.cpp index 6451595..61a18f5 100644 --- a/tests/test_wally_unit.cpp +++ b/tests/test_wally_unit.cpp @@ -29,6 +29,7 @@ #include "rac/foundation/rac_proto_buffer.h" #include "rac/infrastructure/model_management/rac_model_registry.h" +#include "anthropic/translate.h" #include "app.h" #include "net/loopback_auth.h" #include "catalog/catalog.h" @@ -2539,6 +2540,112 @@ TestResult test_default_model_store() { return result; } +TestResult test_upstream_failure_mapping() { + TestResult result; + result.test_name = "upstream_failure_mapping"; + namespace tr = wally::anthropic::translate; + + struct Case { + int status; + const char *body; + const char *want_type; + const char *want_message; + }; + // The status decides the type so the wrapped tool stops retrying a refusal it + // cannot satisfy; the message comes from an OpenAI-style error body, else the + // raw body, else a status line, else a no-answer note. + const Case cases[] = { + {0, "", "api_error", "the model endpoint did not answer"}, + {429, R"({"error":{"message":"Rate limit exceeded"}})", "rate_limit_error", + "Rate limit exceeded"}, + {403, R"({"error":{"message":"Out of credit."}})", "permission_error", "Out of credit."}, + {401, R"({"error":{"message":"bad key"}})", "authentication_error", "bad key"}, + {500, "upstream boom", "api_error", "upstream boom"}, + {502, " ", "api_error", "the model endpoint returned status 502"}, + }; + + for (const Case &c : cases) { + std::string type; + std::string message; + tr::UpstreamFailure(c.status, c.body, &type, &message); + if (type != c.want_type || message != c.want_message) { + result.details = "status " + std::to_string(c.status) + " gave (" + type + ", " + message + + "), wanted (" + c.want_type + ", " + c.want_message + ")"; + return result; + } + } + result.passed = true; + return result; +} + +TestResult test_passthrough_argv_split() { + TestResult result; + result.test_name = "passthrough_argv_split"; + + struct Case { + std::vector in; + std::vector want; + }; + const std::vector cases = { + // A leading tool flag is separated so CLI11 forwards it. + {{"wally", "claude-code", "--dangerously-skip-permissions"}, + {"wally", "claude-code", "--", "--dangerously-skip-permissions"}}, + // wally's own -m is consumed first; the tool flag after is separated. + {{"wally", "claude-code", "-m", "glm-5.3-flash", "-p", "hi"}, + {"wally", "claude-code", "-m", "glm-5.3-flash", "--", "-p", "hi"}}, + // --model=... inline form is a wally flag too. + {{"wally", "opencode", "--cloud", "--model=glm-5.3-flash", "run"}, + {"wally", "opencode", "--cloud", "--model=glm-5.3-flash", "--", "run"}}, + // An explicit -- is left exactly as the reader wrote it. + {{"wally", "claude-code", "--", "-p", "hi"}, {"wally", "claude-code", "--", "-p", "hi"}}, + // Only wally flags: nothing to forward, no -- added. + {{"wally", "claude-code", "-m", "glm-5.3-flash"}, + {"wally", "claude-code", "-m", "glm-5.3-flash"}}, + // Not a passthrough command: untouched. + {{"wally", "run", "qwen3-0.6b", "hi"}, {"wally", "run", "qwen3-0.6b", "hi"}}, + }; + + for (const Case &c : cases) { + const std::vector got = wally::SplitPassthroughArgv(c.in); + if (got != c.want) { + std::string g; + for (const std::string &t : got) g += t + " "; + result.details = "for [" + c.in[1] + " ...] got: " + g; + return result; + } + } + result.passed = true; + return result; +} + +TestResult test_stream_usage_reports_input_tokens() { + TestResult result; + result.test_name = "stream_usage_reports_input_tokens"; + namespace tr = wally::anthropic::translate; + + tr::StreamState state; + // A streaming chunk carrying content plus the final usage (gateways attach + // prompt/completion counts to a late data chunk). + const nlohmann::json chunk = nlohmann::json::parse( + R"({"id":"c1","choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}],)" + R"("usage":{"prompt_tokens":1234,"completion_tokens":56}})"); + tr::StreamChunkToAnthropic(chunk, &state); + const std::string closing = tr::StreamCloseToAnthropic(&state); + + // The closing message_delta must carry the REAL input_tokens, or a wrapped + // tool's context gauge stays at zero and auto-compaction never fires. + if (closing.find("\"input_tokens\":1234") == std::string::npos) { + result.details = "message_delta missing real input_tokens; got: " + closing.substr(0, 300); + return result; + } + if (closing.find("\"output_tokens\":56") == std::string::npos) { + result.details = "message_delta missing output_tokens"; + return result; + } + result.passed = true; + return result; +} + } // namespace int main(int argc, char **argv) { @@ -2583,5 +2690,8 @@ int main(int argc, char **argv) { suite.add("model_labels_format", test_model_labels_format); suite.add("default_model_resolution", test_default_model_resolution); suite.add("default_model_store", test_default_model_store); + suite.add("upstream_failure_mapping", test_upstream_failure_mapping); + suite.add("passthrough_argv_split", test_passthrough_argv_split); + suite.add("stream_usage_reports_input_tokens", test_stream_usage_reports_input_tokens); return suite.run(argc, argv); }