Files
APIClient-Agent/app/core/ai_chat.py
Anand Shukla 01662f7e0e Initial release — APIClient - Agent v2.0.0
AI-first API testing desktop client built with Python + PyQt6.

Features:
- Multi-tab HTTP request editor with params/headers/body/auth/tests
- KeyValueTable with per-row enable/disable checkboxes and 36px rows
- Format JSON button, syntax highlighting, pre-request & test scripts
- Collections, environments, history, import/export (Postman v2.1, cURL)
- OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens)
- EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL,
  Custom REST JSON with all auth types (instant, no AI tokens)
- Persistent AI chat sidebar (Claude-powered co-pilot) with streaming,
  context-aware suggestions, and one-click Apply to request editor
- AI collection generator from any docs URL or pasted spec
- WebSocket client, Mock server, Collection runner, Code generator
- Dark/light theme engine (global QSS, object-name selectors)
- SSL error detection with actionable hints
- MIT License

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:38:57 +05:30

190 lines
6.5 KiB
Python

"""APIClient - Agent — Conversational AI co-pilot core."""
import json
import re
import httpx
from app.core import storage
MAX_HISTORY_MSGS = 30
_SYSTEM_PROMPT = """\
You are APIClient - Agent, an expert AI API testing co-pilot embedded in the APIClient - Agent desktop application.
Your responsibilities:
• Help craft and debug HTTP requests (REST, JSON-API, GraphQL, Odoo APIs)
• Analyze HTTP responses — status codes, headers, body structure, errors
• Specialize in the EKIKA Odoo API Framework:
- JSON-API (Content-Type: application/vnd.api+json) — body format: {"data": {"type": model, "attributes": {...}}}
- REST JSON (Content-Type: application/json)
- GraphQL (POST with {"query": "..."} body)
- Auth: x-api-key header, Basic Auth, OAuth2 Bearer, JWT Bearer
- Odoo models: sale.order, res.partner, account.move, product.template, stock.picking, etc.
• Generate request bodies, params, headers, and test scripts
• Explain SSL/TLS errors, auth failures, and connection issues
• Help with environment variable setup ({{base_url}}, {{api_key}}, etc.)
When you produce content the user should apply to their request, use EXACTLY these fences:
```apply:body
{ "json": "here" }
```
```apply:params
page=1
limit=10
fields=id,name
```
```apply:headers
x-api-key: {{api_key}}
Accept: application/vnd.api+json
```
```apply:test
pm.test('Status 200', lambda: pm.response.to_have_status(200))
pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data'))
```
Rules:
- Be concise and actionable — explain WHY, not just WHAT
- If you add apply blocks, briefly explain what each block does
- For JSON-API responses: data is in response.data, errors in response.errors
- For SSL cert errors: tell user to uncheck SSL verification in the Settings tab
- For 401/403: check x-api-key header and environment variable values
- For unresolved {{variable}}: tell user to set up environment via Tools → Environments
"""
class AIError(Exception):
pass
def get_api_key() -> str:
return storage.get_setting("anthropic_api_key", "")
def build_context(req=None, resp=None, env_vars: dict = None) -> str:
"""Build compact context string for system prompt injection."""
parts = []
if req:
parts.append(f"METHOD: {req.method}")
parts.append(f"URL: {req.url}")
if req.headers:
safe = {
k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "auth", "pass"]) else v)
for k, v in req.headers.items()
}
parts.append(f"HEADERS: {json.dumps(safe)}")
if req.params:
parts.append(f"PARAMS: {json.dumps(req.params)}")
if req.body:
preview = req.body[:1500] + ("" if len(req.body) > 1500 else "")
parts.append(f"BODY ({req.body_type}):\n{preview}")
if req.content_type:
parts.append(f"CONTENT-TYPE: {req.content_type}")
if req.test_script:
parts.append(f"TEST SCRIPT:\n{req.test_script}")
if resp:
if resp.error:
parts.append(f"\nRESPONSE ERROR: {resp.error}")
else:
parts.append(f"\nRESPONSE: {resp.status} {resp.reason} ({resp.elapsed_ms:.0f} ms)")
ct = (resp.headers or {}).get("content-type", (resp.headers or {}).get("Content-Type", ""))
if ct:
parts.append(f"RESPONSE CONTENT-TYPE: {ct}")
if resp.body:
preview = resp.body[:4000] + ("" if len(resp.body) > 4000 else "")
parts.append(f"RESPONSE BODY:\n{preview}")
if env_vars:
safe_vars = {
k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "password", "pass"]) else v)
for k, v in env_vars.items()
}
parts.append(f"\nENVIRONMENT VARIABLES: {json.dumps(safe_vars)}")
return "\n".join(parts)
def stream_chat(messages: list[dict], context: str = "", chunk_cb=None) -> str:
"""
Stream a multi-turn conversation to Claude.
messages: list of {"role": "user"|"assistant", "content": str}
chunk_cb(chunk: str): called for each streamed text chunk
Returns full assistant response text.
Raises AIError on failure.
"""
api_key = get_api_key()
if not api_key:
raise AIError(
"No Anthropic API key configured.\n"
"Go to Tools → AI Assistant → Settings to add your key."
)
system = _SYSTEM_PROMPT
if context:
system += f"\n\n## Current Request Context\n{context}"
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
payload = {
"model": "claude-opus-4-6",
"max_tokens": 2048,
"system": system,
"messages": messages[-MAX_HISTORY_MSGS:],
}
full_text = ""
try:
with httpx.stream(
"POST",
"https://api.anthropic.com/v1/messages",
headers=headers,
json=payload,
timeout=60.0,
) as resp:
if resp.status_code != 200:
body = resp.read().decode()
raise AIError(f"Claude API error {resp.status_code}: {body[:400]}")
for line in resp.iter_lines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
try:
event = json.loads(data_str)
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
chunk = delta.get("text", "")
full_text += chunk
if chunk_cb:
chunk_cb(chunk)
except json.JSONDecodeError:
continue
except httpx.TimeoutException:
raise AIError("Request timed out. Try a shorter question or check your connection.")
except httpx.RequestError as e:
raise AIError(f"Network error: {e}")
return full_text
def parse_apply_blocks(text: str) -> list[dict]:
"""Parse ```apply:TYPE ... ``` blocks from AI response text."""
blocks = []
for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL):
blocks.append({"type": m.group(1), "content": m.group(2).strip()})
return blocks
def strip_apply_blocks(text: str) -> str:
"""Remove apply fences from display text, leaving the explanation."""
return re.sub(r"```apply:\w+\n.*?```", "", text, flags=re.DOTALL).strip()