Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ For another API endpoint, open **Providers → Custom providers → Add provider
| [OpenAI API](https://platform.openai.com/api-keys) | `OPENAI_API_KEY` | `openai_api/gpt-5.6-sol` |
| [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/authenticate) | Connect GitHub Copilot in the Admin UI | `github_copilot/<model-id>` |
| [xAI (Grok)](https://console.x.ai/team/default/api-keys) | `XAI_API_KEY` | `xai/grok-4.5` |
| [Alibaba Cloud](https://www.alibabacloud.com/help/en/model-studio/get-api-key) | `ALIBABA_CLOUD_API_KEY` | `alibaba_cloud/<model-id>` |
| [QwenCloud Token Plan](https://home.qwencloud.com/api-keys) | `QWENCLOUD_API_KEY` | `qwencloud/qwen3.7-plus` |
| [QwenCloud Coding Plan](https://home.qwencloud.com/api-keys) | `QWENCLOUD_CODING_API_KEY` | `qwencloud_coding/qwen3.7-plus` |
| [Together AI](https://api.together.ai/settings/api-keys) | `TOGETHER_API_KEY` | `together/zai-org/GLM-5.2` |
Expand Down Expand Up @@ -288,6 +289,10 @@ For another API endpoint, open **Providers → Custom providers → Add provider
- Kimi Code subscription keys use `kimi_code/`. Kimi API credit keys use
`kimi/`. Kimi Code plans are for personal interactive coding-agent use under
[Kimi's community guidelines](https://www.kimi.com/code/docs/en/kimi-code/community-guidelines.html).
- Alibaba Cloud uses a Model Studio pay-as-you-go key and defaults to Singapore.
For another region or workspace, set `ALIBABA_CLOUD_BASE_URL` to its
[OpenAI-compatible endpoint](https://www.alibabacloud.com/help/en/model-studio/base-url)
ending in `/compatible-mode/v1`. The key and endpoint must use the same region.
- QwenCloud Coding Plan keys use `qwencloud_coding/`. QwenCloud Token Plan keys
use `qwencloud/`. The keys and endpoints are not interchangeable. Coding Plan
is for local, personal, interactive coding-agent use under the
Expand Down
17 changes: 11 additions & 6 deletions e2e/test_admin_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ def test_apply_network_error_unlocks_form_and_keeps_edits(


def test_restart_waits_for_a_new_running_server(page: Page, admin_base_url: str):
pending: list[Route] = []
status = {"status": "running", "instance_id": "old-server"}
status_url = f"{admin_base_url}/admin/api/status"
page.route(
"**/admin/api/config/apply",
lambda route: route.fulfill(
Expand All @@ -210,21 +211,25 @@ def test_restart_waits_for_a_new_running_server(page: Page, admin_base_url: str)
page.goto(f"{admin_base_url}/admin")
expect(page.locator("#messageArea")).to_have_text("")
page.wait_for_function("!state.startupRequest && !state.startupTimer")
page.route("**/admin/api/status", lambda route: pending.append(route))
page.route(status_url, lambda route: route.fulfill(json=status))
open_provider(page, "nvidia_nim")
page.locator("#field-NVIDIA_NIM_API_KEY").fill("new-key")
with page.expect_request("**/admin/api/status"):
page.get_by_role("button", name="Save", exact=True).click()
expect(page.locator("#applyButton")).to_have_text("Reconnecting…")
for status in (
for next_status in (
{"status": "running", "instance_id": "old-server"},
{"status": "stopping", "instance_id": "new-server"},
):
with page.expect_request("**/admin/api/status"):
pending.pop(0).fulfill(json=status)
with page.expect_response(
lambda response, expected=next_status: (
response.url == status_url and response.json() == expected
)
):
status = next_status
expect(page.locator("#view-providers")).to_have_attribute("inert", "")
expect(page.locator("#dirtyState")).to_have_text("Changes saved")
pending.pop(0).fulfill(json={"status": "running", "instance_id": "new-server"})
status = {"status": "running", "instance_id": "new-server"}
expect(page.locator('[data-provider="nvidia_nim"]')).to_be_enabled()
expect(page.locator("#dirtyState")).to_have_text("No changes")
expect(page.locator("#messageArea")).to_have_text("Applied")
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"grpcio-tools>=1.81.1",
"nvidia-riva-client>=2.26.0",
"pyperclip>=1.11.0",
"sqlalchemy>=2.1.1",
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions smoke/lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"groq": "groq/openai/gpt-oss-20b",
"cline_pass": "cline_pass/cline-pass/deepseek-v4-flash",
"xai": "xai/grok-4.5",
"alibaba_cloud": "alibaba_cloud/qwen3-coder-plus",
"qwencloud": "qwencloud/qwen3.7-plus",
"qwencloud_coding": "qwencloud_coding/qwen3.7-plus",
"together": "together/zai-org/GLM-5.2",
Expand Down
19 changes: 17 additions & 2 deletions smoke/product/test_client_product_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ def _json_object_lines(text: str) -> list[JsonObject]:
return objects


def _trace_log_events(text: str) -> list[JsonObject]:
events: list[JsonObject] = []
for row in _json_object_lines(text):
record = row.get("record")
if not isinstance(record, dict):
continue
extra = record.get("extra")
if not isinstance(extra, dict):
continue
payload = extra.get("trace_payload")
if isinstance(payload, dict):
events.append(payload)
return events


@pytest.mark.smoke_target("clients")
def test_vscode_protocol_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
Expand Down Expand Up @@ -985,7 +1000,7 @@ def test_claude_cli_web_search_e2e(smoke_config: SmokeConfig, tmp_path: Path) ->
assert '"type": "server_tool_use"' in automatic_payload
assert '"type": "web_search_tool_result"' in automatic_payload
assert "github.com" in automatic_payload
log_rows = _json_object_lines(server_log)
log_rows = _trace_log_events(server_log)
assert (
sum(
row.get("event") == "free_claude_code.api.web_search.automatic_recognized"
Expand Down Expand Up @@ -1087,7 +1102,7 @@ def test_claude_auto_mode_openai_connected_e2e(
):
assert unexpected not in combined_lower

log_rows = _json_object_lines(server_log)
log_rows = _trace_log_events(server_log)
policy_rows = [
row
for row in log_rows
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 2 additions & 4 deletions src/free_claude_code/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ def _run_bound(

from free_claude_code.runtime.bootstrap import build_asgi_app

from .uvicorn_server import RuntimeServer
from .uvicorn_server import RuntimeServer, uvicorn_log_config

asgi_app = build_asgi_app(
settings,
Expand All @@ -270,9 +270,7 @@ def _run_bound(
host=settings.host,
port=settings.port,
log_level="debug",
log_config=(
uvicorn.config.LOGGING_CONFIG if self._console_logging else None
),
log_config=uvicorn_log_config(console=self._console_logging),
timeout_graceful_shutdown=SERVER_GRACEFUL_SHUTDOWN_SECONDS,
)

Expand Down
16 changes: 16 additions & 0 deletions src/free_claude_code/cli/uvicorn_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@

import socket
from collections.abc import Awaitable, Callable
from copy import deepcopy

import uvicorn

from free_claude_code.config.logging_config import InterceptHandler


def uvicorn_log_config(*, console: bool) -> dict[str, object]:
"""Send Uvicorn records to FCC's file and optional standard console handlers."""
config = deepcopy(uvicorn.config.LOGGING_CONFIG)
if not console:
config["formatters"] = {}
config["handlers"] = {}
config["handlers"]["fcc"] = {"()": InterceptHandler}
for name in ("uvicorn", "uvicorn.access"):
handlers = config["loggers"][name]["handlers"]
config["loggers"][name]["handlers"] = [*handlers, "fcc"] if console else ["fcc"]
return config


class RuntimeServer(uvicorn.Server):
"""Notify the runtime before Uvicorn waits for long-lived HTTP responses."""
Expand Down
13 changes: 13 additions & 0 deletions src/free_claude_code/config/admin/provider_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,19 @@ class ProviderFieldOverride(TypedDict, total=False):
"models."
),
},
"ALIBABA_CLOUD_API_KEY": {
"description": (
"Alibaba Cloud Model Studio pay-as-you-go API key. Use a key from "
"the same region as the base URL."
),
},
"ALIBABA_CLOUD_BASE_URL": {
"description": (
"Optional Model Studio OpenAI-compatible base URL ending in "
"/compatible-mode/v1. Defaults to Singapore. Set your regional "
"or workspace URL when using a key from another region."
),
},
"QWENCLOUD_API_KEY": {
"label": "QwenCloud Token Plan API Key",
"description": (
Expand Down
71 changes: 5 additions & 66 deletions src/free_claude_code/config/logging_config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
"""Loguru-based structured logging configuration.

Structured logs are written as JSON lines to a configurable path (default
``logs/server.log``). Stdlib logging is intercepted and funneled to loguru.
Context vars (request_id, node_id, chat_id) from contextualize() are
included at top level for easy grep/filter.
``~/.fcc/logs/server.log``). Stdlib logging is intercepted and funneled to loguru.
Loguru's JSON stores exception metadata in ``record.exception`` and context in
``record.extra``. Structured traces live in ``record.extra.trace_payload``.
"""

import json
import logging
import re
import threading
from pathlib import Path

Expand All @@ -30,63 +28,6 @@
"telegram.ext",
)

# Loguru ``logger.bind()`` key used by structured TRACE payloads; ``core/trace.py``
# uses the identical string constant ``TRACE_PAYLOAD_BINDING``.
_TRACE_PAYLOAD_BINDING = "trace_payload"

# Context keys we promote to top-level JSON for traceability / grep
_CONTEXT_KEYS = (
"request_id",
"node_id",
"chat_id",
"claude_session_id",
"http_method",
"http_path",
)

_TELEGRAM_BOT_RE = re.compile(
r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)",
re.IGNORECASE,
)
# Authorization: Bearer <token> (HTTP client / proxy debug lines)
_AUTH_BEARER_RE = re.compile(
r"(\bAuthorization\s*:\s*Bearer\s+)([^\s'\"]+)",
re.IGNORECASE,
)


def _redact_sensitive_substrings(message: str) -> str:
"""Remove obvious API tokens and secrets before JSON log line emission."""
text = _TELEGRAM_BOT_RE.sub(r"\1bot<redacted>\3", message)
return _AUTH_BEARER_RE.sub(r"\1<redacted>", text)


def _serialize_with_context(record) -> str:
"""Format record as JSON with context vars at top level.
Returns a format template; we inject _json into record for output.
"""
extra = record.get("extra", {})
out = {
"time": str(record["time"]),
"level": record["level"].name,
"message": _redact_sensitive_substrings(str(record["message"])),
"module": record["name"],
"function": record["function"],
"line": record["line"],
}
trace_payload = extra.get(_TRACE_PAYLOAD_BINDING)
for key in _CONTEXT_KEYS:
if key in extra and extra[key] is not None:
out[key] = extra[key]
if isinstance(trace_payload, dict):
for tk, tv in trace_payload.items():
if tk in out:
continue
out[tk] = tv
out["trace"] = True
record["_json"] = json.dumps(out, default=str)
return "{_json}\n"


class InterceptHandler(logging.Handler):
"""Redirect stdlib logging to loguru."""
Expand Down Expand Up @@ -129,7 +70,7 @@ def _add_file_sink(log_file: str | Path, level: str) -> int:
return logger.add(
log_path,
level=level,
format=_serialize_with_context,
serialize=True,
encoding="utf-8",
mode="a",
rotation="50 MB",
Expand All @@ -150,7 +91,7 @@ def configure_logging(
Idempotent: skips if already configured with the same path, level, and verbosity.
On path or level change, replaces only the file sink without truncating.
On verbosity change alone, updates only the third-party logger levels.
Use force=True to reconfigure from scratch.
Use force=True to rebuild handlers while preserving existing log records.

When ``verbose_third_party`` is false, managed noisy third-party loggers
are capped at WARNING unless explicitly configured otherwise.
Expand All @@ -174,8 +115,6 @@ def configure_logging(

logger.remove()

log_path.write_text("")

_sink_id = _add_file_sink(log_path, level)

intercept = InterceptHandler()
Expand Down
13 changes: 13 additions & 0 deletions src/free_claude_code/config/provider_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
OPENAI_CODEX_DEFAULT_BASE = "https://chatgpt.com/backend-api/codex"
# xAI OpenAI-compatible Chat Completions API.
XAI_DEFAULT_BASE = "https://api.x.ai/v1"
ALIBABA_CLOUD_DEFAULT_BASE = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
# QwenCloud Token Plan OpenAI-compatible Chat Completions API.
QWENCLOUD_DEFAULT_BASE = (
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
Expand Down Expand Up @@ -219,6 +220,18 @@ def configuration_attrs(self) -> tuple[str, ...]:
default_base_url=XAI_DEFAULT_BASE,
proxy_attr="xai_proxy",
),
"alibaba_cloud": ProviderDescriptor(
provider_id="alibaba_cloud",
display_name="Alibaba Cloud",
website_url="https://www.alibabacloud.com/en/product/modelstudio",
logo_filename="alibabacloud-color.svg",
credential_env="ALIBABA_CLOUD_API_KEY",
credential_url="https://www.alibabacloud.com/help/en/model-studio/get-api-key",
credential_attr="alibaba_cloud_api_key",
default_base_url=ALIBABA_CLOUD_DEFAULT_BASE,
base_url_attr="alibaba_cloud_base_url",
proxy_attr="alibaba_cloud_proxy",
),
"qwencloud": ProviderDescriptor(
provider_id="qwencloud",
display_name="QwenCloud Token Plan",
Expand Down
11 changes: 11 additions & 0 deletions src/free_claude_code/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,14 @@ def validate_provider_references(self) -> Settings:
default=None, validation_alias="XAI_API_KEY"
)

# ==================== Alibaba Cloud Model Studio ====================
alibaba_cloud_api_key: OptionalNonEmptyString = Field(
default=None, validation_alias="ALIBABA_CLOUD_API_KEY"
)
alibaba_cloud_base_url: OptionalNonEmptyString = Field(
default=None, validation_alias="ALIBABA_CLOUD_BASE_URL"
)

# ==================== QwenCloud Token Plan (OpenAI-compatible) ====================
qwencloud_api_key: OptionalNonEmptyString = Field(
default=None, validation_alias="QWENCLOUD_API_KEY"
Expand Down Expand Up @@ -433,6 +441,9 @@ def validate_provider_references(self) -> Settings:
xai_proxy: OptionalNonEmptyString = Field(
default=None, validation_alias="XAI_PROXY"
)
alibaba_cloud_proxy: OptionalNonEmptyString = Field(
default=None, validation_alias="ALIBABA_CLOUD_PROXY"
)
qwencloud_proxy: OptionalNonEmptyString = Field(
default=None, validation_alias="QWENCLOUD_PROXY"
)
Expand Down
4 changes: 2 additions & 2 deletions src/free_claude_code/core/trace.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Structured DEBUG traces for end-to-end request / CLI / provider logging.

Emitted lines are merged into JSON log rows by ``config.logging_config``.
Trace payloads are preserved in ``record.extra.trace_payload`` by Loguru.
Conversation and Claude Code prompts are logged verbatim unless values live under
sanitized credential keys (e.g. ``api_key``, ``authorization``). The default
INFO log level excludes these detailed request traces.
Expand Down Expand Up @@ -50,7 +50,7 @@ def sanitize_trace_value(obj: Any) -> Any:


def trace_event(*, stage: str, event: str, source: str, **fields: Any) -> None:
"""Emit one structured DEBUG trace row merged into JSON by the log sink."""
"""Emit a DEBUG record with a structured payload bound to its extra fields."""
payload = sanitize_trace_value(
{
"stage": stage,
Expand Down
5 changes: 5 additions & 0 deletions src/free_claude_code/providers/alibaba_cloud/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Alibaba Cloud provider exports."""

from .client import AlibabaCloudProvider

__all__ = ["AlibabaCloudProvider"]
Loading
Loading