{"record":{"id":"e912ee0d07a6c9ce","repo":"Hmbown/CodeWhale","slug":"translate-unexpected-api-response-shape","errorCode":null,"errorMessage":"translate: unexpected API response shape","messagePattern":"translate: unexpected API response shape","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/tui/src/client.rs","lineNumber":2085,"sourceCode":"                }\n            ],\n            \"max_tokens\": max_tokens,\n            \"stream\": false\n        });\n        chat::apply_route_reasoning_controls(\n            &mut body,\n            self.api_provider,\n            &self.base_url,\n            &model,\n            Some(\"off\"),\n        );\n\n        let response = self.send_json_with_retry(&url, &body).await?;\n\n        let value: serde_json::Value = response.json().await?;\n        let translated = value[\"choices\"][0][\"message\"][\"content\"]\n            .as_str()\n            .ok_or_else(|| anyhow::anyhow!(\"translate: unexpected API response shape\"))?\n            .trim()\n            .to_string();\n\n        Ok(translated)\n    }\n\n    /// List available models from the provider.\n    pub async fn list_models(&self) -> Result<Vec<AvailableModel>> {\n        let url = api_url(&self.base_url, \"models\");\n        let response = self.send_with_retry(|| self.http_client.get(&url)).await?;\n\n        let status = response.status();\n        if !status.is_success() {\n            let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;\n            let error_text = sanitize_http_error_body(\n                Some(self.api_provider.display_name()),\n                status.as_u16(),\n                &raw_error_text,","sourceCodeStart":2067,"sourceCodeEnd":2103,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/8880682c63083a91624de936797efa3ce9e498fd/crates/tui/src/client.rs#L2067-L2103","documentation":"The ChatCompletions translation path (`translate()`) sends a minimal system+user chat request and then reads the translated text from `choices[0].message.content`, requiring it to be a JSON string. This error means the endpoint returned HTTP 200 but the body does not contain that shape — the response was still consumed by `send_json_with_retry`, so this is purely a response-shape mismatch, not a transport or status failure.","triggerScenarios":"Pointing the client at a provider that is not OpenAI-chat-completions-shaped (Anthropic-style `content` arrays, Google-style payloads), a gateway returning a 200 JSON error envelope, a proxy returning HTML that happens to parse as JSON, an empty `choices` array (some providers when the model filters the output), or `content` being `null`/an array instead of a string.","commonSituations":"Custom provider base_url pointing at the wrong endpoint version (`/v1` missing or doubled); using a model alias that routes to a non-chat completion surface; middleboxes (Cloudflare, corporate proxies) rewriting responses; providers that wrap chat responses in an envelope like `{\"data\": ...}`.","solutions":["Reproduce the exact request with curl against the same base_url/model and inspect the body: `curl -s $BASE_URL/chat/completions -H \"Authorization: Bearer $KEY\" -d '{\"model\":\"...\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'`.","Fix the base_url so it addresses an OpenAI-compatible chat-completions endpoint (correct `/v1` prefix, no trailing path duplicates).","Confirm the model id actually exists on that provider (wrong ids sometimes yield 200 with an error object on gateways).","If the provider legitimately returns `content` as an array of parts, switch to a provider/model that returns plain string content for translation."],"exampleFix":"# before\nbase_url = \"https://proxy.internal\"        # returns envelope-wrapped 200\n# after\nbase_url = \"https://proxy.internal/v1\"     # real chat-completions surface","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"fn has_chat_content_shape(v: &serde_json::Value) -> bool {\n    v.pointer(\"/choices/0/message/content\").is_some_and(serde_json::Value::is_string)\n}","tryCatchPattern":"let value: serde_json::Value = response.json().await?;\nlet translated = value\n    .pointer(\"/choices/0/message/content\")\n    .and_then(serde_json::Value::as_str)\n    .map(str::trim)\n    .filter(|s| !s.is_empty())\n    .ok_or_else(|| {\n        anyhow::anyhow!(\"translate: unexpected API response shape: {}\",\n            truncate(&value.to_string(), 512))  // keep raw shape for diagnosis\n    })?;","preventionTips":["Smoke-test a provider's chat surface with a one-message curl before enabling translate on it.","Log (at debug level) the top-level JSON keys of unexpected responses to identify envelope-wrapping proxies quickly.","Keep translate models on providers known to return string `message.content`."],"tags":["api","translation","response-parsing","chat-completions","provider"],"backgroundTag":null,"analyzedSha":"8880682c63083a91624de936797efa3ce9e498fd","analyzedAt":"2026-08-16T11:31:27.956Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}